| import Papa from 'papaparse'; |
|
|
| import { |
| CHROME_UA, |
| loadSharedConfig, |
| } from './_seed-utils.mjs'; |
|
|
| export const HAPI_PAGE_LIMIT = 10_000; |
| export const HAPI_MAX_PAGES = 3; |
| export const HAPI_HDX_PACKAGE_URL = 'https://data.humdata.org/api/3/action/package_show?id=hdx-hapi-conflict-event'; |
| const HAPI_HDX_METADATA_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; |
| export const HAPI_HDX_MAX_RESPONSE_BYTES = 64 * 1024 * 1024; |
| export const HAPI_HDX_METADATA_TIMEOUT_MS = 60_000; |
| |
| |
| |
| export const HAPI_HDX_SNAPSHOT_TIMEOUT_MS = 120_000; |
|
|
| const ISO2_TO_ISO3 = loadSharedConfig('iso2-to-iso3.json'); |
| const ISO3_TO_ISO2 = new Map( |
| Object.entries(ISO2_TO_ISO3).map(([iso2, iso3]) => [String(iso3).toUpperCase(), iso2]), |
| ); |
| const HAPI_COUNTRY_NAMES = new Intl.DisplayNames(['en'], { type: 'region' }); |
|
|
| function previousMonthStart(nowMs) { |
| const now = new Date(nowMs); |
| return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)) |
| .toISOString() |
| .slice(0, 10); |
| } |
|
|
| export function hapiCountryCodeForIso3(value) { |
| return ISO3_TO_ISO2.get(String(value || '').toUpperCase()); |
| } |
|
|
| export function buildHapiConflictEventsUrl({ |
| nowMs = Date.now(), |
| offset = 0, |
| countryCode, |
| adminLevel = '0', |
| } = {}) { |
| const url = new URL('https://hapi.humdata.org/api/v2/coordination-context/conflict-events'); |
| url.searchParams.set('output_format', 'json'); |
| if (adminLevel != null) url.searchParams.set('admin_level', String(adminLevel)); |
| url.searchParams.set('start_date', previousMonthStart(nowMs)); |
| url.searchParams.set('limit', String(HAPI_PAGE_LIMIT)); |
| url.searchParams.set('offset', String(offset)); |
| if (countryCode) { |
| const iso3 = ISO2_TO_ISO3[countryCode]; |
| if (!iso3) throw new Error(`No ISO3 mapping for HAPI country ${countryCode}`); |
| url.searchParams.set('location_code', iso3); |
| } |
| return url.toString(); |
| } |
|
|
| function hapiHdxError(message, { |
| status, |
| reasonCode = 'HDX_FETCH_FAILED', |
| } = {}) { |
| return Object.assign(new Error(message), { |
| ...(status != null ? { status } : {}), |
| reasonCode, |
| }); |
| } |
|
|
| function hapiHdxResourceUrl(resource) { |
| let url; |
| try { |
| url = new URL(String(resource?.url || '')); |
| } catch { |
| throw hapiHdxError('HAPI HDX snapshot resource has an invalid URL', { |
| reasonCode: 'HDX_RESOURCE_INVALID', |
| }); |
| } |
| if (url.protocol !== 'https:' || url.hostname !== 'data.humdata.org') { |
| throw hapiHdxError(`HAPI HDX snapshot resource uses an unexpected origin: ${url.origin}`, { |
| reasonCode: 'HDX_RESOURCE_INVALID', |
| }); |
| } |
| return url.toString(); |
| } |
|
|
| export function selectHapiHdxCsvResources(resources, { nowMs = Date.now() } = {}) { |
| const startYear = Number(previousMonthStart(nowMs).slice(0, 4)); |
| const currentYear = new Date(nowMs).getUTCFullYear(); |
| const selected = []; |
|
|
| for (let year = startYear; year <= currentYear; year += 1) { |
| const yearPattern = new RegExp(`(?:^|[_-])${year}\\.csv$`, 'i'); |
| const candidates = (Array.isArray(resources) ? resources : []) |
| .filter((resource) => { |
| if (String(resource?.format || '').toUpperCase() !== 'CSV') return false; |
| const name = String(resource?.name || ''); |
| let pathname = ''; |
| try { |
| pathname = new URL(String(resource?.url || '')).pathname; |
| } catch { |
| |
| |
| } |
| return yearPattern.test(name) || yearPattern.test(pathname); |
| }) |
| .sort((left, right) => String(right?.last_modified || '').localeCompare( |
| String(left?.last_modified || ''), |
| )); |
|
|
| if (candidates.length === 0 && year === startYear) { |
| throw hapiHdxError(`HAPI HDX snapshot has no CSV resource for ${year}`, { |
| reasonCode: 'HDX_RESOURCE_NOT_FOUND', |
| }); |
| } |
| |
| |
| |
| if (candidates.length === 0) continue; |
| selected.push({ |
| year, |
| url: hapiHdxResourceUrl(candidates[0]), |
| }); |
| } |
| return selected; |
| } |
|
|
| export async function readBoundedHapiHdxText(response, maxResponseBytes) { |
| const contentLength = response.headers.get('content-length'); |
| if ( |
| contentLength != null |
| && Number.isFinite(Number(contentLength)) |
| && Number(contentLength) > maxResponseBytes |
| ) { |
| throw hapiHdxError( |
| `HAPI HDX response exceeds ${maxResponseBytes} bytes`, |
| { status: response.status, reasonCode: 'RESPONSE_TOO_LARGE' }, |
| ); |
| } |
|
|
| if (!response.body?.getReader) { |
| const body = await response.arrayBuffer(); |
| if (body.byteLength > maxResponseBytes) { |
| throw hapiHdxError( |
| `HAPI HDX response exceeds ${maxResponseBytes} bytes`, |
| { status: response.status, reasonCode: 'RESPONSE_TOO_LARGE' }, |
| ); |
| } |
| return new TextDecoder().decode(body); |
| } |
|
|
| const reader = response.body.getReader(); |
| const chunks = []; |
| let totalBytes = 0; |
| try { |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| totalBytes += value.byteLength; |
| if (totalBytes > maxResponseBytes) { |
| await reader.cancel().catch(() => {}); |
| throw hapiHdxError( |
| `HAPI HDX response exceeds ${maxResponseBytes} bytes`, |
| { status: response.status, reasonCode: 'RESPONSE_TOO_LARGE' }, |
| ); |
| } |
| chunks.push(Buffer.from(value)); |
| } |
| } finally { |
| reader.releaseLock(); |
| } |
| return Buffer.concat(chunks, totalBytes).toString('utf8'); |
| } |
|
|
| export function parseHapiHdxConflictCsv( |
| csvText, |
| { nowMs = Date.now(), countryCodes = Object.keys(ISO2_TO_ISO3) } = {}, |
| ) { |
| const earliestPeriod = previousMonthStart(nowMs); |
| const targetIso3 = new Set( |
| countryCodes |
| .map((countryCode) => ISO2_TO_ISO3[countryCode]) |
| .filter(Boolean) |
| .map((countryCode) => String(countryCode).toUpperCase()), |
| ); |
| const rows = []; |
| const parseErrors = []; |
| const parsed = Papa.parse(String(csvText || ''), { |
| header: true, |
| skipEmptyLines: 'greedy', |
| transformHeader: (header) => header.replace(/^\ufeff/, '').trim(), |
| step: (result) => { |
| if (Array.isArray(result.errors) && result.errors.length > 0) { |
| parseErrors.push(...result.errors.slice(0, 5 - parseErrors.length)); |
| } |
| const row = result.data; |
| const locationCode = String(row?.location_code || '').toUpperCase(); |
| if (!targetIso3.has(locationCode)) return; |
| if (String(row?.reference_period_start || '') < earliestPeriod) return; |
| rows.push(row); |
| }, |
| }); |
|
|
| const requiredFields = [ |
| 'location_code', |
| 'admin_level', |
| 'event_type', |
| 'events', |
| 'fatalities', |
| 'reference_period_start', |
| ]; |
| const fields = new Set(parsed.meta?.fields || []); |
| const missingFields = requiredFields.filter((field) => !fields.has(field)); |
| if (missingFields.length > 0 || parseErrors.length > 0) { |
| const details = missingFields.length > 0 |
| ? `missing fields: ${missingFields.join(', ')}` |
| : parseErrors[0].message; |
| throw hapiHdxError(`HAPI HDX CSV is invalid (${details})`, { |
| reasonCode: 'HDX_CSV_INVALID', |
| }); |
| } |
| return rows; |
| } |
|
|
| export async function fetchHapiHdxSnapshotRows({ |
| fetchFn = (...args) => globalThis.fetch(...args), |
| nowMs = Date.now(), |
| countryCodes = Object.keys(ISO2_TO_ISO3), |
| createTimeoutSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), |
| } = {}) { |
| const requestOptions = (accept, timeoutMs) => ({ |
| headers: { |
| Accept: accept, |
| 'User-Agent': CHROME_UA, |
| }, |
| signal: createTimeoutSignal(timeoutMs), |
| }); |
| const metadataResponse = await fetchFn( |
| HAPI_HDX_PACKAGE_URL, |
| requestOptions('application/json', HAPI_HDX_METADATA_TIMEOUT_MS), |
| ); |
| if (!metadataResponse.ok) { |
| throw hapiHdxError(`HAPI HDX metadata request failed: HTTP ${metadataResponse.status}`, { |
| status: metadataResponse.status, |
| reasonCode: `HDX_HTTP_${metadataResponse.status}`, |
| }); |
| } |
|
|
| const metadataText = await readBoundedHapiHdxText( |
| metadataResponse, |
| HAPI_HDX_METADATA_MAX_RESPONSE_BYTES, |
| ); |
| let metadata; |
| try { |
| metadata = JSON.parse(metadataText); |
| } catch { |
| throw hapiHdxError('HAPI HDX metadata response is not valid JSON', { |
| reasonCode: 'HDX_METADATA_INVALID', |
| }); |
| } |
| if (metadata?.success !== true || !Array.isArray(metadata?.result?.resources)) { |
| throw hapiHdxError('HAPI HDX metadata response is missing resources', { |
| reasonCode: 'HDX_METADATA_INVALID', |
| }); |
| } |
|
|
| const resources = selectHapiHdxCsvResources(metadata.result.resources, { nowMs }); |
| const rows = []; |
| for (const resource of resources) { |
| const response = await fetchFn( |
| resource.url, |
| requestOptions('text/csv', HAPI_HDX_SNAPSHOT_TIMEOUT_MS), |
| ); |
| if (!response.ok) { |
| throw hapiHdxError( |
| `HAPI HDX ${resource.year} snapshot request failed: HTTP ${response.status}`, |
| { |
| status: response.status, |
| reasonCode: `HDX_HTTP_${response.status}`, |
| }, |
| ); |
| } |
| const contentType = String(response.headers.get('content-type') || '').toLowerCase(); |
| if (contentType.includes('text/html')) { |
| throw hapiHdxError(`HAPI HDX ${resource.year} snapshot returned HTML`, { |
| status: response.status, |
| reasonCode: 'HDX_CSV_INVALID', |
| }); |
| } |
| const csvText = await readBoundedHapiHdxText(response, HAPI_HDX_MAX_RESPONSE_BYTES); |
| rows.push(...parseHapiHdxConflictCsv(csvText, { nowMs, countryCodes })); |
| } |
| return rows; |
| } |
|
|
| export function hapiHdxFailureReason(error) { |
| const seen = new Set(); |
| let reasonCode; |
| let sawTimeout = false; |
| let sawDns = false; |
| let current = error; |
| while (current && typeof current === 'object' && !seen.has(current)) { |
| seen.add(current); |
| reasonCode ||= current.reasonCode; |
| sawTimeout ||= ( |
| current.name === 'TimeoutError' |
| || current.name === 'AbortError' |
| || current.code === 'ETIMEDOUT' |
| || current.code === 'UND_ERR_CONNECT_TIMEOUT' |
| || current.code === 'UND_ERR_HEADERS_TIMEOUT' |
| || current.code === 'UND_ERR_BODY_TIMEOUT' |
| ); |
| sawDns ||= current.code === 'ENOTFOUND' || current.code === 'EAI_AGAIN'; |
| current = current.cause; |
| } |
|
|
| if (reasonCode) return reasonCode; |
| if (sawTimeout) return 'HDX_TIMEOUT'; |
| if (sawDns) return 'HDX_DNS_ERROR'; |
| return 'HDX_FETCH_FAILED'; |
| } |
|
|
| function finiteCount(value) { |
| const parsed = Number(value); |
| return Number.isFinite(parsed) ? parsed : 0; |
| } |
|
|
| export function aggregateHapiConflictEvents( |
| records, |
| { nowMs = Date.now(), countryCodes = Object.keys(ISO2_TO_ISO3) } = {}, |
| ) { |
| const targetCountries = new Set(countryCodes); |
| const aggregates = new Map(); |
|
|
| for (const row of Array.isArray(records) ? records : []) { |
| const countryCode = hapiCountryCodeForIso3(row?.location_code); |
| if (!countryCode || !targetCountries.has(countryCode)) continue; |
|
|
| const referencePeriod = String(row?.reference_period_start || ''); |
| if (!referencePeriod) continue; |
| const parsedAdminLevel = Number(row?.admin_level ?? 0); |
| const adminLevel = Number.isFinite(parsedAdminLevel) ? parsedAdminLevel : 0; |
|
|
| let aggregate = aggregates.get(countryCode); |
| if ( |
| !aggregate |
| || referencePeriod > aggregate.referencePeriod |
| || (referencePeriod === aggregate.referencePeriod && adminLevel > aggregate.adminLevel) |
| ) { |
| aggregate = { |
| referencePeriod, |
| adminLevel, |
| countryName: String( |
| row?.location_name |
| || HAPI_COUNTRY_NAMES.of(countryCode) |
| || countryCode, |
| ), |
| eventsTotal: 0, |
| eventsPV: 0, |
| eventsCT: 0, |
| eventsDem: 0, |
| fatalitiesPV: 0, |
| fatalitiesCT: 0, |
| }; |
| aggregates.set(countryCode, aggregate); |
| } |
| if ( |
| referencePeriod !== aggregate.referencePeriod |
| || adminLevel !== aggregate.adminLevel |
| ) continue; |
|
|
| const eventType = String(row?.event_type || '').toLowerCase(); |
| const events = finiteCount(row?.events); |
| const fatalities = finiteCount(row?.fatalities); |
| aggregate.eventsTotal += events; |
| if (eventType === 'political_violence') { |
| aggregate.eventsPV += events; |
| aggregate.fatalitiesPV += fatalities; |
| } else if (eventType === 'civilian_targeting') { |
| aggregate.eventsCT += events; |
| aggregate.fatalitiesCT += fatalities; |
| } else if (eventType === 'demonstration') { |
| aggregate.eventsDem += events; |
| } |
| } |
|
|
| const results = {}; |
| for (const [countryCode, aggregate] of aggregates) { |
| results[countryCode] = { |
| summary: { |
| countryCode, |
| countryName: aggregate.countryName, |
| conflictEventsTotal: aggregate.eventsTotal, |
| conflictPoliticalViolenceEvents: aggregate.eventsPV + aggregate.eventsCT, |
| conflictFatalities: aggregate.fatalitiesPV + aggregate.fatalitiesCT, |
| referencePeriod: aggregate.referencePeriod, |
| conflictDemonstrations: aggregate.eventsDem, |
| updatedAt: nowMs, |
| }, |
| }; |
| } |
| return results; |
| } |
|
|