#!/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;
});
}