Files
intelligence-terminal/apis/sources/acled.mjs
MrSphay bb139799d7
All checks were successful
Codex Template Compliance / template-compliance (pull_request) Successful in 5s
Build / test-and-image (pull_request) Successful in 49s
fix: improve acled auth diagnostics
2026-05-17 14:05:01 +02:00

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));
}