// ACLED - Armed Conflict Location & Event Data. // Auth strategy (tries in order): // 1. OAuth Bearer token: POST /oauth/token -> Authorization header // 2. Cookie-based session: POST /user/login?_format=json -> session cookie // Set ACLED_EMAIL and ACLED_PASSWORD in .env. ACLED_USER or ACLED_USERNAME are // accepted as aliases for ACLED_EMAIL. import { daysAgo } from '../utils/fetch.mjs'; import '../utils/env.mjs'; const LOGIN_URL = 'https://acleddata.com/user/login?_format=json'; const TOKEN_URL = 'https://acleddata.com/oauth/token'; const API_BASE = 'https://acleddata.com/api/acled/read'; let sessionCache = { cookies: null, token: null, method: null, expires: 0 }; export function resetAcledSessionCache() { sessionCache = { cookies: null, token: null, method: null, expires: 0 }; } export function getAcledConfig(env = process.env) { const email = env.ACLED_EMAIL || env.ACLED_USER || env.ACLED_USERNAME || ''; const password = env.ACLED_PASSWORD || ''; const missing = []; if (!email) missing.push('ACLED_EMAIL'); if (!password) missing.push('ACLED_PASSWORD'); return { email, password, configured: missing.length === 0, missing }; } function acledError(status, error, message, extra = {}) { return { status, error, message, ...extra }; } function safeText(value, max = 200) { return String(value || '').replace(/Bearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [redacted]').slice(0, max); } async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetchImpl(url, { ...init, signal: controller.signal }); } finally { clearTimeout(timer); } } export async function loginCookie(email, password, opts = {}) { const fetchImpl = opts.fetchImpl || globalThis.fetch; try { const res = await fetchWithTimeout(fetchImpl, LOGIN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: email, pass: password }), redirect: 'manual', }, 15000); const setCookies = res.headers.getSetCookie?.() || []; const cookieStr = setCookies.map(c => c.split(';')[0]).join('; '); if ((res.ok || (res.status >= 300 && res.status < 400)) && cookieStr) { return { ok: true, cookies: cookieStr }; } const errText = await res.text().catch(() => ''); return acledError('auth_failed', `acled_cookie_http_${res.status}`, `ACLED cookie login failed with HTTP ${res.status}`, { detail: safeText(errText), }); } catch (e) { return acledError('auth_failed', 'acled_cookie_request_failed', `ACLED cookie login error: ${e.message}`); } } export async function loginOAuth(email, password, opts = {}) { const fetchImpl = opts.fetchImpl || globalThis.fetch; try { const body = new URLSearchParams({ username: email, password, grant_type: 'password', client_id: 'acled', }); const res = await fetchWithTimeout(fetchImpl, TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }, 15000); if (!res.ok) { const errText = await res.text().catch(() => ''); return acledError('auth_failed', `acled_oauth_http_${res.status}`, `ACLED OAuth failed with HTTP ${res.status}`, { detail: safeText(errText), }); } const data = await res.json(); if (!data.access_token) { return acledError('auth_failed', 'acled_oauth_missing_access_token', 'ACLED OAuth response did not include access_token'); } return { ok: true, token: data.access_token }; } catch (e) { return acledError('auth_failed', 'acled_oauth_request_failed', `ACLED OAuth error: ${e.message}`); } } export async function authenticate(opts = {}) { const env = opts.env || process.env; const fetchImpl = opts.fetchImpl || globalThis.fetch; const config = getAcledConfig(env); if (!config.configured) { return acledError('no_credentials', 'missing_acled_credentials', 'No ACLED credentials. Set ACLED_EMAIL and ACLED_PASSWORD in .env.', { missing: config.missing, }); } if (sessionCache.method && Date.now() < sessionCache.expires) { return sessionCache; } const diagnostics = []; const oauthResult = await loginOAuth(config.email, config.password, { fetchImpl }); if (oauthResult.ok) { sessionCache = { cookies: null, token: oauthResult.token, method: 'oauth', expires: Date.now() + 23 * 60 * 60 * 1000 }; return sessionCache; } diagnostics.push({ method: 'oauth', status: oauthResult.status, error: oauthResult.error, message: oauthResult.message }); if (opts.debug) console.error(`[ACLED DEBUG] OAuth failed: ${oauthResult.error}`); const cookieResult = await loginCookie(config.email, config.password, { fetchImpl }); if (cookieResult.ok) { sessionCache = { cookies: cookieResult.cookies, token: null, method: 'cookie', expires: Date.now() + 12 * 60 * 60 * 1000 }; return sessionCache; } diagnostics.push({ method: 'cookie', status: cookieResult.status, error: cookieResult.error, message: cookieResult.message }); if (opts.debug) console.error(`[ACLED DEBUG] Cookie login failed: ${cookieResult.error}`); return acledError('auth_failed', 'acled_auth_failed', 'All ACLED auth methods failed.', { diagnostics }); } function authHeaders(session) { const headers = { 'User-Agent': 'Crucix/2.0', 'Content-Type': 'application/json' }; if (session.method === 'cookie' && session.cookies) { headers['Cookie'] = session.cookies; } else if (session.method === 'oauth' && session.token) { headers['Authorization'] = `Bearer ${session.token}`; } return headers; } export const EVENT_TYPES = [ 'Battles', 'Explosions/Remote violence', 'Violence against civilians', 'Protests', 'Riots', 'Strategic developments', ]; export async function getEvents(opts = {}) { const { limit = 500, eventDateStart, eventDateEnd, eventType, country, region, env = process.env, fetchImpl = globalThis.fetch, debug = process.argv.includes('--debug'), } = opts; const session = await authenticate({ env, fetchImpl, debug }); if (session.error) return session; const params = new URLSearchParams({ _format: 'json', limit: String(limit) }); if (eventDateStart && eventDateEnd) { params.set('event_date', `${eventDateStart}|${eventDateEnd}`); params.set('event_date_where', 'BETWEEN'); } if (eventType) params.set('event_type', eventType); if (country) params.set('country', country); if (region) params.set('region', String(region)); try { const url = `${API_BASE}?${params}`; if (debug) console.error(`[ACLED DEBUG] Data request: GET ${url}`); const res = await fetchWithTimeout(fetchImpl, url, { headers: authHeaders(session) }, 25000); if (debug) console.error(`[ACLED DEBUG] Data response: HTTP ${res.status}`); if (!res.ok) { const errText = await res.text().catch(() => ''); if (res.status === 401 || res.status === 403) { sessionCache = { cookies: null, token: null, method: null, expires: 0 }; return acledError('access_denied', `acled_data_http_${res.status}`, `ACLED data access denied with HTTP ${res.status}`, { authMethod: session.method, detail: safeText(errText, 300), hint: 'Accept ACLED terms, complete profile fields, and confirm API access for the account.', }); } return acledError('api_failed', `acled_data_http_${res.status}`, `ACLED data request failed with HTTP ${res.status}`, { detail: safeText(errText), }); } const data = await res.json(); if (data?.status && data.status !== 200) { return acledError('api_failed', `acled_api_status_${data.status}`, `ACLED API returned status ${data.status}`, { detail: safeText(data.message), }); } return data; } catch (e) { if (e.name === 'AbortError') { return acledError('api_failed', 'acled_data_timeout', 'ACLED data request timed out after 25s'); } return acledError('api_failed', 'acled_data_request_failed', `ACLED data error: ${e.message}`); } } function groupBy(events, field) { const map = {}; for (const e of events) { const key = e[field] || 'Unknown'; if (!map[key]) map[key] = { count: 0, fatalities: 0 }; map[key].count += 1; map[key].fatalities += parseInt(e.fatalities, 10) || 0; } return map; } export async function briefing(opts = {}) { const env = opts.env || process.env; const fetchImpl = opts.fetchImpl || globalThis.fetch; const config = getAcledConfig(env); if (!config.configured) { return { source: 'ACLED', timestamp: new Date().toISOString(), status: 'no_credentials', error: 'missing_acled_credentials', missing: config.missing, message: 'Set ACLED_EMAIL and ACLED_PASSWORD in .env. Register at https://acleddata.com/user/register', }; } const start = daysAgo(7); const end = daysAgo(0); const data = await getEvents({ eventDateStart: start, eventDateEnd: end, limit: 2000, env, fetchImpl, debug: opts.debug, }); if (data?.error) { return { source: 'ACLED', timestamp: new Date().toISOString(), status: data.status || 'api_failed', error: data.error, message: data.message, detail: data.detail, hint: data.hint, diagnostics: data.diagnostics, }; } let events = data?.data || []; events = events.map(e => ({ ...e, lat: parseFloat(e.latitude) || null, lon: parseFloat(e.longitude) || null, })); const totalFatalities = events.reduce( (sum, e) => sum + (parseInt(e.fatalities, 10) || 0), 0 ); const byRegion = groupBy(events, 'region'); const byType = groupBy(events, 'event_type'); const byCountry = groupBy(events, 'country'); const topCountries = Object.entries(byCountry) .sort((a, b) => b[1].count - a[1].count) .slice(0, 10) .reduce((obj, [k, v]) => { obj[k] = v; return obj; }, {}); const deadliestEvents = events .filter(e => parseInt(e.fatalities, 10) > 0) .sort((a, b) => (parseInt(b.fatalities, 10) || 0) - (parseInt(a.fatalities, 10) || 0)) .slice(0, 15) .map(e => ({ date: e.event_date, type: e.event_type, subType: e.sub_event_type, country: e.country, location: e.location, fatalities: parseInt(e.fatalities, 10) || 0, lat: parseFloat(e.latitude) || null, lon: parseFloat(e.longitude) || null, notes: e.notes?.slice(0, 200), })); return { source: 'ACLED', timestamp: new Date().toISOString(), status: 'ok', period: { start, end }, totalEvents: events.length, totalFatalities, byRegion, byType, topCountries, deadliestEvents, }; } if (process.argv[1]?.endsWith('acled.mjs')) { const data = await briefing(); console.log(JSON.stringify(data, null, 2)); }