327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
// 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));
|
|
}
|