#!/usr/bin/env node // Build the daily open-data snapshot of WorldMonitor chokepoint status from the // public GetChokepointStatus RPC. ROW_FIELDS is the published column contract: // the JSON rows, the CSV header, and schema.json are all projected from it, so // a field cannot ship in one format and not the others. WITHHELD_FIELDS records // which API fields stay out of the dataset and why. // // Usage: // node scripts/build-open-data-chokepoints.mjs [--out ] [--date YYYY-MM-DD] [--input ] [--save-input ] [--dry-run] // node scripts/build-open-data-chokepoints.mjs --publish-redis [--date YYYY-MM-DD] [--input ] // node scripts/build-open-data-chokepoints.mjs --import-dir // API_BASE=https://www.worldmonitor.app WORLDMONITOR_API_KEY= node scripts/build-open-data-chokepoints.mjs // // --save-input writes the API response the run used, so a later run can rebuild // the same snapshot with --input against a different checkout of the dataset. import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { loadEnvFile, withRetry } from './_seed-utils.mjs'; import { isMainModule } from './lib/main-module.mjs'; loadEnvFile(import.meta.url, { only: ['WORLDMONITOR_API_KEY', 'API_BASE', 'UPSTASH_REDIS_REST_URL', 'UPSTASH_REDIS_REST_TOKEN'] }); export const SCHEMA_VERSION = '1.0.0'; export const DATASET_ID = 'worldmonitor-chokepoint-status'; export const DEFAULT_API_BASE = 'https://www.worldmonitor.app'; export const RPC_PATH = '/api/supply-chain/v1/get-chokepoint-status'; export const DEFAULT_OUT_DIR = 'public/data/v1/chokepoints'; export const LICENSE = Object.freeze({ spdx: 'CC-BY-4.0', url: 'https://creativecommons.org/licenses/by/4.0/' }); export const ATTRIBUTION = 'WorldMonitor (https://www.worldmonitor.app). Scores and statuses are computed by WorldMonitor from IMF PortWatch, NGA Maritime Safety Information, and AIS data via aisstream.io.'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'; const HTTP_TIMEOUT_MS = 30_000; const SNAPSHOT_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const DATED_JSON_RE = /^(\d{4}-\d{2}-\d{2})\.json$/; const todayCount = (key) => (cp) => (cp.transitSummary?.todayCountsAvailable ? cp.transitSummary[key] : null); export const ROW_FIELDS = [ { name: 'id', type: 'string', nullable: false, description: 'Stable WorldMonitor chokepoint identifier.', from: (cp) => cp.id }, { name: 'name', type: 'string', nullable: false, description: 'Chokepoint display name.', from: (cp) => cp.name }, { name: 'lat', type: 'number', nullable: false, description: 'Latitude of the chokepoint reference point, decimal degrees.', from: (cp) => cp.lat }, { name: 'lon', type: 'number', nullable: false, description: 'Longitude of the chokepoint reference point, decimal degrees.', from: (cp) => cp.lon }, { name: 'status', type: 'string', nullable: false, enum: ['green', 'yellow', 'red'], description: 'Traffic-light badge for disruption_score: green (<20), yellow (20-49), red (>=50). Not an operational closure status.', from: (cp) => cp.status, }, { name: 'disruption_score', type: 'integer', nullable: false, minimum: 0, maximum: 100, description: 'WorldMonitor composite disruption score, 0 (none) to 100 (severe).', from: (cp) => cp.disruptionScore, }, { name: 'war_risk_tier', type: 'string', nullable: false, enum: ['unspecified', 'normal', 'elevated', 'high', 'critical', 'war_zone'], description: "War-risk tier derived from Lloyd's JWC Listed Areas and OSINT threat classification.", from: (cp) => (cp.warRiskTier || 'WAR_RISK_TIER_UNSPECIFIED').replace(/^WAR_RISK_TIER_/, '').toLowerCase(), }, { name: 'congestion_level', type: 'string', nullable: true, enum: ['normal', 'low', 'elevated', 'high'], description: 'AIS-derived congestion level. Null when the AIS snapshot was unavailable.', from: (cp) => cp.congestionLevel, }, { name: 'active_warnings', type: 'integer', nullable: false, description: 'Active NGA navigational warnings matched to the chokepoint.', from: (cp) => cp.activeWarnings }, { name: 'ais_disruptions', type: 'integer', nullable: false, description: 'Disruption events in the AIS snapshot matched to the chokepoint.', from: (cp) => cp.aisDisruptions }, { name: 'navigational_warnings_available', type: 'boolean', nullable: false, description: 'True when the NGA navigational-warning source returned a valid response, including an empty list.', from: (cp) => cp.navigationalWarningsAvailable, }, { name: 'ais_snapshot_available', type: 'boolean', nullable: false, description: 'True when the AIS snapshot source returned a valid response. Governs ais_disruptions and congestion_level.', from: (cp) => cp.aisSnapshotAvailable, }, { name: 'transit_data_available', type: 'boolean', nullable: false, description: 'True when IMF PortWatch transit history was available this cycle. Governs transit_wow_change_pct.', from: (cp) => cp.transitSummary?.dataAvailable === true, }, { name: 'transit_today_counts_available', type: 'boolean', nullable: false, description: 'True when the 24-hour AIS window held crossings for this chokepoint. Governs the transit_today_* counts.', from: (cp) => cp.transitSummary?.todayCountsAvailable === true, }, { name: 'transit_today_total', type: 'integer', nullable: true, description: 'AIS transits in the last 24 hours, all vessel types. Null when not measured.', from: todayCount('todayTotal') }, { name: 'transit_today_tanker', type: 'integer', nullable: true, description: 'Tanker transits in the last 24 hours. Null when not measured.', from: todayCount('todayTanker') }, { name: 'transit_today_cargo', type: 'integer', nullable: true, description: 'Cargo transits in the last 24 hours. Null when not measured.', from: todayCount('todayCargo') }, { name: 'transit_today_other', type: 'integer', nullable: true, description: 'Other-vessel transits in the last 24 hours. Null when not measured.', from: todayCount('todayOther') }, { name: 'transit_wow_change_pct', type: 'number', nullable: true, description: 'Week-over-week change in transits, percent. Null when transit history was unavailable.', from: (cp) => (cp.transitSummary?.dataAvailable ? cp.transitSummary.wowChangePct : null), }, { name: 'flow_ratio', type: 'number', nullable: true, minimum: 0, maximum: 1.5, description: 'Latest 7-day PortWatch average over the preceding baseline window of up to 90 days, clamped to 0.0-1.5. Null when no flow estimate exists.', from: (cp) => cp.flowEstimate?.flowRatio, }, { name: 'flow_disrupted', type: 'boolean', nullable: true, description: 'True when each of the latest 3 daily flow ratios is below 85% of the baseline window. Null when no flow estimate exists.', from: (cp) => cp.flowEstimate?.disrupted, }, { name: 'flow_source', type: 'string', nullable: true, enum: ['portwatch-dwt', 'portwatch-counts'], description: 'PortWatch coverage basis of flow_ratio: deadweight tonnage or vessel counts. Null when no flow estimate exists.', from: (cp) => (cp.flowEstimate?.source === 'FLOW_SOURCE_UNSPECIFIED' ? null : cp.flowEstimate?.source), }, ]; const CORRIDORRISK = 'CorridorRisk.io'; const EIA_BASELINE = 'U.S. EIA 2023 chokepoint reference levels'; const PORTWATCH = 'IMF PortWatch'; const PORTWATCH_DISRUPTIONS = 'IMF PortWatch disruptions database'; export const WITHHELD_FIELDS = [ { api_field: 'transitSummary.incidentCount7d', reason: 'Raw third-party incident count.', upstream: CORRIDORRISK }, { api_field: 'transitSummary.disruptionPct', reason: 'Single third-party score, published as-is.', upstream: CORRIDORRISK }, { api_field: 'transitSummary.riskLevel', reason: 'Banding of a single third-party score.', upstream: CORRIDORRISK }, { api_field: 'flowEstimate.baselineMbd', reason: 'Third-party reference figure, not a WorldMonitor measurement.', upstream: EIA_BASELINE }, { api_field: 'flowEstimate.currentMbd', reason: 'Equals baselineMbd times flowRatio, so it reveals the withheld baseline.', upstream: EIA_BASELINE }, { api_field: 'flowEstimate.hazardAlertLevel', reason: 'Passthrough of a third-party hazard layer.', upstream: PORTWATCH_DISRUPTIONS }, { api_field: 'flowEstimate.hazardAlertName', reason: 'Passthrough of a third-party hazard layer.', upstream: PORTWATCH_DISRUPTIONS }, { api_field: 'transitSummary.history', reason: 'Raw third-party daily transit counts.', upstream: PORTWATCH }, { api_field: 'transitSummary.officialLatestTotal', reason: 'Raw third-party daily transit count, republished as-is.', upstream: PORTWATCH }, { api_field: 'transitSummary.officialAvg7d', reason: 'Seven-day mean of raw third-party daily counts.', upstream: PORTWATCH }, { api_field: 'transitSummary.baselineAvgDaily', reason: 'Twelve-month mean of raw third-party daily counts.', upstream: PORTWATCH }, { api_field: 'transitSummary.officialLatestDate', reason: 'Describes only the withheld official count.', upstream: PORTWATCH }, { api_field: 'transitSummary.baselineFrom', reason: 'Describes only the withheld baseline mean.', upstream: PORTWATCH }, { api_field: 'transitSummary.baselineTo', reason: 'Describes only the withheld baseline mean.', upstream: PORTWATCH }, { api_field: 'transitSummary.riskSummary', reason: 'Third-party prose. The API already blanks it.', upstream: CORRIDORRISK }, { api_field: 'transitSummary.riskReportAction', reason: 'Third-party prose. The API already blanks it.', upstream: CORRIDORRISK }, { api_field: 'description', reason: 'Editorial prose, not data.', upstream: 'WorldMonitor editorial' }, { api_field: 'affectedRoutes', reason: 'Static reference list, out of v1 scope.', upstream: 'WorldMonitor chokepoint registry' }, { api_field: 'directions', reason: 'Static reference list, out of v1 scope.', upstream: 'WorldMonitor chokepoint registry' }, { api_field: 'directionalDwt', reason: 'Deprecated per-direction tonnage, out of v1 scope.', upstream: PORTWATCH }, ]; // Every API field a row reads, by dotted path. Together with WITHHELD_FIELDS it // accounts for the whole response shape: a field the API adds later has to // land in one list or the other before the tests pass. export const CONSUMED_FIELDS = [ 'id', 'name', 'lat', 'lon', 'status', 'disruptionScore', 'warRiskTier', 'congestionLevel', 'activeWarnings', 'aisDisruptions', 'navigationalWarningsAvailable', 'aisSnapshotAvailable', 'transitSummary.dataAvailable', 'transitSummary.todayCountsAvailable', 'transitSummary.todayTotal', 'transitSummary.todayTanker', 'transitSummary.todayCargo', 'transitSummary.todayOther', 'transitSummary.wowChangePct', 'flowEstimate.flowRatio', 'flowEstimate.disrupted', 'flowEstimate.source', ]; function projectValue(field, cp) { const raw = field.from(cp); const value = raw === '' || raw === undefined ? null : raw; if (value === null) { if (field.nullable) return null; throw new Error(`chokepoint ${cp.id}: ${field.name} is missing`); } const typeOk = field.type === 'integer' ? Number.isInteger(value) : typeof value === field.type; const enumOk = !field.enum || field.enum.includes(value); const rangeOk = (field.minimum === undefined || value >= field.minimum) && (field.maximum === undefined || value <= field.maximum); if (!typeOk || !enumOk || !rangeOk) { throw new Error(`chokepoint ${cp.id}: ${field.name} has unexpected value ${JSON.stringify(value)}`); } return value; } const projectRow = (cp) => Object.fromEntries(ROW_FIELDS.map((field) => [field.name, projectValue(field, cp)])); // A snapshot date is a real UTC calendar day no later than the day generatedAt // falls on: a future date would become `latest` and pin latest.* there until // the calendar caught up. function assertSnapshotDate(snapshotDate, generatedAt) { const parsed = new Date(`${snapshotDate}T00:00:00.000Z`); const calendarDay = SNAPSHOT_DATE_RE.test(snapshotDate) && !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === snapshotDate; if (!calendarDay) { throw new Error(`snapshotDate must be a YYYY-MM-DD calendar date, got ${JSON.stringify(snapshotDate)}`); } const today = generatedAt.slice(0, 10); if (snapshotDate > today) { throw new Error(`snapshotDate ${snapshotDate} is after the generation day ${today}; refusing to pin latest to a future date`); } } export function buildSnapshot(apiResponse, { snapshotDate, generatedAt } = {}) { if (typeof generatedAt !== 'string' || Number.isNaN(Date.parse(generatedAt))) { throw new Error(`generatedAt must be an ISO 8601 timestamp, got ${JSON.stringify(generatedAt)}`); } if (typeof snapshotDate !== 'string') { throw new Error(`snapshotDate must be a YYYY-MM-DD calendar date, got ${JSON.stringify(snapshotDate)}`); } assertSnapshotDate(snapshotDate, new Date(generatedAt).toISOString()); const chokepoints = apiResponse?.chokepoints; if (!Array.isArray(chokepoints) || chokepoints.length === 0) { throw new Error('chokepoint status response has no chokepoints; refusing to publish an empty snapshot'); } if (typeof apiResponse.fetchedAt !== 'string') { throw new Error('chokepoint status response has no fetchedAt'); } return { schema_version: SCHEMA_VERSION, dataset: DATASET_ID, snapshot_date: snapshotDate, generated_at: generatedAt, source_url: `${DEFAULT_API_BASE}${RPC_PATH}`, source_fetched_at: apiResponse.fetchedAt, upstream_unavailable: apiResponse.upstreamUnavailable === true, methodology_url: `${DEFAULT_API_BASE}/docs/methodology/chokepoints`, readme_url: `${DEFAULT_API_BASE}/data/v1/chokepoints/README.md`, license: { ...LICENSE }, attribution: ATTRIBUTION, chokepoints: chokepoints.map(projectRow).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)), }; } function csvCell(value) { if (value === null) return ''; const text = String(value); return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; } export function toCsv(snapshot) { const header = ['snapshot_date', ...ROW_FIELDS.map((field) => field.name)]; const rows = snapshot.chokepoints.map((row) => [snapshot.snapshot_date, ...ROW_FIELDS.map((field) => row[field.name])]); return `${[header, ...rows].map((cells) => cells.map(csvCell).join(',')).join('\n')}\n`; } function fieldSchema(field) { return { type: field.nullable ? [field.type, 'null'] : field.type, ...(field.enum && { enum: field.nullable ? [...field.enum, null] : field.enum }), ...(field.minimum !== undefined && { minimum: field.minimum }), ...(field.maximum !== undefined && { maximum: field.maximum }), description: field.description, }; } export function buildJsonSchema() { const rowNames = ROW_FIELDS.map((field) => field.name); const properties = { schema_version: { type: 'string', const: SCHEMA_VERSION }, dataset: { type: 'string', const: DATASET_ID }, snapshot_date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'UTC calendar date the snapshot represents.' }, generated_at: { type: 'string', format: 'date-time', description: 'When this file was generated.' }, source_url: { type: 'string', description: 'Public API endpoint the snapshot was built from.' }, source_fetched_at: { type: 'string', description: 'fetchedAt reported by the API response.' }, upstream_unavailable: { type: 'boolean', description: 'True when the API reported that an upstream source was unavailable.' }, methodology_url: { type: 'string' }, readme_url: { type: 'string' }, license: { type: 'object', required: ['spdx', 'url'], additionalProperties: false, properties: { spdx: { type: 'string', const: LICENSE.spdx, description: 'SPDX identifier of the license the files are published under.' }, url: { type: 'string', const: LICENSE.url }, }, }, attribution: { type: 'string', const: ATTRIBUTION, description: 'The attribution line to reproduce when you redistribute.' }, chokepoints: { type: 'array', minItems: 1, items: { type: 'object', required: rowNames, additionalProperties: false, properties: Object.fromEntries(ROW_FIELDS.map((field) => [field.name, fieldSchema(field)])), }, }, }; return { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: `${DEFAULT_API_BASE}/data/v1/chokepoints/schema.json`, title: 'WorldMonitor chokepoint status snapshot', type: 'object', required: Object.keys(properties), properties, }; } const toJson = (value) => `${JSON.stringify(value, null, 2)}\n`; async function existingSnapshotDates(outDir) { try { return (await fs.readdir(outDir)).map((name) => DATED_JSON_RE.exec(name)?.[1]).filter(Boolean); } catch (err) { if (err.code === 'ENOENT') return []; throw err; } } // latest.* only moves forward: replaying an older capture backfills its dated // files without regressing what consumers of latest.json see. A dated snapshot // that already exists is replaced only while it is still the newest one, so a // same-day rerun can correct today but history behind it never changes. The // file writer and the Redis publisher both apply this one plan. export function planSnapshot(existingDates, snapshot) { const date = snapshot.snapshot_date; const dates = [...new Set([...existingDates, date])].sort(); const latest = dates.at(-1); if (date !== latest && existingDates.includes(date)) { throw new Error(`refusing to rewrite ${date}.json: a later snapshot (${latest}) already exists`); } return { date, latest, dates, json: toJson(snapshot), csv: toCsv(snapshot), index: toJson({ schema_version: SCHEMA_VERSION, dataset: DATASET_ID, latest, dates }), }; } async function planSnapshotFiles(outDir, snapshot) { const plan = planSnapshot(await existingSnapshotDates(outDir), snapshot); const files = [ [`${plan.date}.json`, plan.json], [`${plan.date}.csv`, plan.csv], ...(plan.date === plan.latest ? [['latest.json', plan.json], ['latest.csv', plan.csv]] : []), ['schema.json', toJson(buildJsonSchema())], ['index.json', plan.index], ]; return files.map(([name, contents]) => ({ path: path.join(outDir, name), contents })); } async function writeFileAtomic(filePath, contents) { const tmpPath = `${filePath}.tmp-${process.pid}`; try { await fs.writeFile(tmpPath, contents, { flag: 'wx' }); await fs.rename(tmpPath, filePath); } catch (err) { await fs.rm(tmpPath, { force: true }); throw err; } } export async function writeSnapshotFiles(outDir, snapshot) { await fs.mkdir(outDir, { recursive: true }); const files = await planSnapshotFiles(outDir, snapshot); for (const file of files) await writeFileAtomic(file.path, file.contents); return files.map((file) => file.path); } // Redis is the published store: api/open-data-chokepoints.js serves // /data/v1/chokepoints/* from these keys. A day key holds the exact JSON and // CSV bytes for that date. The index key holds the exact index.json bytes, and // its dates list is the archive manifest the publisher plans against. Neither // carries a TTL: the archive is permanent. export const REDIS_KEYS = Object.freeze({ index: 'open-data:chokepoints:v1:index', dayPrefix: 'open-data:chokepoints:v1:day:', seedMeta: 'seed-meta:open-data:chokepoints', // Durable "has ever published" marker: /api/health reads a missing seed-meta // as pending only while this is absent (#10284). activation: 'seed-activated:open-data:chokepoints', }); const SEED_META_TTL_SECONDS = 7 * 86_400; export function createUpstashClient({ url, token, fetchImpl = (...args) => globalThis.fetch(...args) }) { if (!url || !token) throw new Error('UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are required to publish'); const post = async (route, body) => { const response = await fetchImpl(`${url.replace(/\/+$/, '')}${route}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': DEFAULT_USER_AGENT }, body: JSON.stringify(body), signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), }); if (!response.ok) throw new Error(`Upstash ${route} returned HTTP ${response.status}`); return response.json(); }; const unwrap = (entry, label) => { if (!entry || typeof entry !== 'object' || 'error' in entry) { throw new Error(`Upstash ${label} failed: ${JSON.stringify(entry?.error ?? entry)}`); } return entry.result; }; return { async command(args) { return unwrap(await post('', args), args[0]); }, // Upstash /multi-exec runs the commands as one MULTI/EXEC transaction, so // a day key never lands without the index that lists it. async transaction(commands) { const replies = await post('/multi-exec', commands); if (!Array.isArray(replies) || replies.length !== commands.length) { throw new Error(`Upstash /multi-exec returned ${JSON.stringify(replies)}`); } return replies.map((reply, i) => unwrap(reply, commands[i][0])); }, }; } async function readIndexDates(redis) { const raw = await redis.command(['GET', REDIS_KEYS.index]); if (raw === null) return []; const dates = JSON.parse(raw)?.dates; if (!Array.isArray(dates) || !dates.every((date) => SNAPSHOT_DATE_RE.test(date))) { throw new Error(`${REDIS_KEYS.index} has no valid dates list; refusing to publish over it`); } return dates; } const dayBody = (json, csv) => JSON.stringify({ json, csv }); async function writeSeedMeta(redis, dates) { const meta = { fetchedAt: Date.now(), recordCount: dates.length, latest: dates.at(-1) }; await redis.command(['SET', REDIS_KEYS.seedMeta, JSON.stringify(meta), 'EX', String(SEED_META_TTL_SECONDS)]); await redis.command(['SET', REDIS_KEYS.activation, '1']); } export async function publishSnapshotToRedis(redis, snapshot) { const plan = planSnapshot(await readIndexDates(redis), snapshot); await redis.transaction([ ['SET', `${REDIS_KEYS.dayPrefix}${plan.date}`, dayBody(plan.json, plan.csv)], ['SET', REDIS_KEYS.index, plan.index], ]); await writeSeedMeta(redis, plan.dates); return plan; } // One-time move of a committed file archive into Redis. Dated files are copied // byte for byte, and a date Redis already holds is left as it is. export async function importSnapshotDirToRedis(redis, dir) { const fileDates = (await existingSnapshotDates(dir)).sort(); if (fileDates.length === 0) throw new Error(`${dir} holds no dated snapshot files`); const redisDates = await readIndexDates(redis); const commands = []; for (const date of fileDates) { const json = await fs.readFile(path.join(dir, `${date}.json`), 'utf8'); const csv = await fs.readFile(path.join(dir, `${date}.csv`), 'utf8'); if (JSON.parse(json).snapshot_date !== date) throw new Error(`${date}.json carries a different snapshot_date`); commands.push(['SET', `${REDIS_KEYS.dayPrefix}${date}`, dayBody(json, csv), 'NX']); } const dates = [...new Set([...redisDates, ...fileDates])].sort(); const index = toJson({ schema_version: SCHEMA_VERSION, dataset: DATASET_ID, latest: dates.at(-1), dates }); await redis.transaction([...commands, ['SET', REDIS_KEYS.index, index]]); await writeSeedMeta(redis, dates); return { dates, imported: fileDates.filter((date) => !redisDates.includes(date)) }; } async function requestJson(fetchImpl, url, init) { const response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }); if (!response.ok) { const err = new Error(`HTTP ${response.status} for ${url}`); err.status = response.status; err.nonRetryable = response.status >= 400 && response.status < 500 && response.status !== 429; throw err; } return response.json(); } export async function fetchChokepointStatus({ apiBase = DEFAULT_API_BASE, apiKey = '', userAgent = DEFAULT_USER_AGENT, fetchImpl = (...args) => globalThis.fetch(...args), } = {}) { const base = apiBase.replace(/\/+$/, ''); const headers = { 'User-Agent': userAgent, Accept: 'application/json', Origin: base, Referer: `${base}/` }; if (apiKey) { headers['X-WorldMonitor-Key'] = apiKey; } else { const session = await withRetry(() => requestJson(fetchImpl, `${base}/api/wm-session`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: '{}', }), ); const token = String(session?.token ?? '').trim(); if (!token) throw new Error('wm-session response did not include a token'); headers.Cookie = `wm-session=${token}`; } return withRetry(() => requestJson(fetchImpl, `${base}${RPC_PATH}`, { method: 'GET', headers })); } async function readSavedResponse(file) { const parsed = JSON.parse(await fs.readFile(file, 'utf8')); return parsed?.body ?? parsed; } async function saveResponse(file, apiBase, body) { await fs.mkdir(path.dirname(file), { recursive: true }); await writeFileAtomic(file, toJson({ capturedAt: new Date().toISOString(), url: `${apiBase}${RPC_PATH}`, body })); } async function main(argv) { const { values } = parseArgs({ args: argv, options: { out: { type: 'string' }, date: { type: 'string' }, input: { type: 'string' }, 'save-input': { type: 'string' }, 'dry-run': { type: 'boolean', default: false }, 'publish-redis': { type: 'boolean', default: false }, 'import-dir': { type: 'string' }, }, strict: true, }); if (values['dry-run'] && (values['publish-redis'] || values['import-dir'])) { throw new Error('--dry-run cannot be combined with --publish-redis or --import-dir; both write to Redis'); } const redis = () => createUpstashClient({ url: process.env.UPSTASH_REDIS_REST_URL, token: process.env.UPSTASH_REDIS_REST_TOKEN }); if (values['import-dir']) { const { dates, imported } = await importSnapshotDirToRedis(redis(), path.resolve(values['import-dir'])); console.log(`imported ${imported.length} dates (${imported.join(', ') || 'none'}); index now lists ${dates.length}, latest ${dates.at(-1)}`); return; } const outDir = values.out ? path.resolve(values.out) : path.join(REPO_ROOT, DEFAULT_OUT_DIR); const apiBase = process.env.API_BASE || DEFAULT_API_BASE; const response = values.input ? await readSavedResponse(values.input) : await fetchChokepointStatus({ apiBase, apiKey: process.env.WORLDMONITOR_API_KEY || '', userAgent: process.env.USER_AGENT || DEFAULT_USER_AGENT, }); if (values['save-input']) { const saved = path.resolve(values['save-input']); await saveResponse(saved, apiBase, response); console.log(`saved input ${saved}`); } const snapshot = buildSnapshot(response, { snapshotDate: values.date ?? new Date().toISOString().slice(0, 10), generatedAt: new Date().toISOString(), }); console.log( `chokepoints=${snapshot.chokepoints.length} snapshot_date=${snapshot.snapshot_date} source_fetched_at=${snapshot.source_fetched_at}`, ); if (values['publish-redis']) { const plan = await publishSnapshotToRedis(redis(), snapshot); console.log(`published ${plan.date} to Redis; index lists ${plan.dates.length} dates, latest ${plan.latest}`); return; } if (values['dry-run']) { for (const file of await planSnapshotFiles(outDir, snapshot)) console.log(`would write ${file.path}`); return; } for (const written of await writeSnapshotFiles(outDir, snapshot)) console.log(`wrote ${written}`); } if (isMainModule(import.meta.url, process.argv[1])) { main(process.argv.slice(2)).catch((err) => { console.error(err.message); process.exitCode = 1; }); }