mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
refactor(aviation): consolidate intl+FAA+NOTAM+news seeds into seed-aviation.mjs (#3238)
* refactor(aviation): consolidate intl+FAA+NOTAM+news seeds into seed-aviation.mjs
seed-aviation.mjs was misnamed: it wrote to a dead Redis key while the
51-airport AviationStack loop + ICAO NOTAM loop lived hidden inside
ais-relay.cjs, duplicating the NOTAM write already done by
seed-airport-delays.mjs.
Make seed-aviation.mjs the single home for every aviation Redis key:
aviation:delays:intl:v3 (AviationStack 51 intl — primary)
aviation:delays:faa:v1 (FAA ASWS 30 US)
aviation:notam:closures:v2 (ICAO NOTAM 60 global)
aviation:news::24:v1 (9 RSS feeds prewarmer)
One unified AIRPORTS registry (~85 entries) replaces the three separate lists.
Notifications preserved via wm:events:queue LPUSH + SETNX dedup; prev-state
migrated from in-process Sets to Redis so short-lived cron runs don't spam
on every tick. ICAO quota-exhaustion backoff retained.
Contracts preserved byte-identically for consumers (AirportDelayAlert shape,
seed-meta:aviation:{intl,faa,notam} meta keys, runSeed envelope writes).
Impact: kills ~8,640/mo wasted AviationStack calls (dead-key writes), strips
~490 lines of hidden seed logic from ais-relay, eliminates duplicate NOTAM
writer. Net -243 lines across three files.
Railway steps after merge:
1. Ensure seed-aviation service env has AVIATIONSTACK_API + ICAO_API_KEY.
2. Delete/disable the seed-airport-delays Railway service.
3. ais-relay redeploys automatically; /aviationstack + /notam live proxies
for user-triggered flight lookups preserved.
* fix(aviation): preserve last-good intl snapshot on unhealthy/skipped fetch + restore NOTAM quota-exhaust handling
Review feedback on PR #3238:
(1) Intl unhealthy → was silently overwriting aviation:delays:intl:v3 with
an empty or partial snapshot because fetchAll() always returned
{ alerts } and zeroIsValid:true let runSeed publish. Now:
• seedIntlDelays() returns { alerts, healthy, skipped } unchanged
• fetchAll() refuses to publish when !healthy || skipped:
- extendExistingTtl([INTL_KEY, INTL_META_KEY], INTL_TTL)
- throws so runSeed enters its graceful catch path (which also
extends these TTLs — idempotent)
• Per-run cache (cachedRun) short-circuits subsequent withRetry(3)
invocations so the retries don't burn 3x NOTAM quota + 3x FAA/RSS
fetches when intl is sick.
(2) NOTAM quota exhausted — PR claimed "preserved" but only logged; the
NOTAM data key was drifting toward TTL expiry and seed-meta was going
stale, which would flip api/health.js maxStaleMin=240 red after 4h
despite the intended 24h backoff window. Now matches the pre-strip
ais-relay behavior byte-for-byte:
• extendExistingTtl([NOTAM_KEY], NOTAM_TTL)
• upstashSet(NOTAM_META_KEY, {fetchedAt: now, recordCount: 0,
quotaExhausted: true}, 604800)
Consumers keep serving the last known closure list; health stays green.
Also added extendExistingTtl fallbacks on FAA/NOTAM network-rejection paths
so transient network failures also don't drift to TTL expiry.
* refactor(aviation): move secondary writes + notifications into afterPublish
Review feedback on PR #3238: fetchAll() was impure — it wrote FAA / NOTAM /
news and dispatched notifications during runSeed's fetch phase, before the
canonical aviation:delays:intl:v3 publish ran. If that later publish failed,
consumers could see fresh FAA/NOTAM/news alongside a stale intl key, and
notifications could fire for a run whose primary key never published,
breaking the "single home / one cron tick" atomic contract.
Restructure:
• fetchAll() now pure — returns { intl, faa, notam, news + rejection refs }.
No Redis writes, no notifications.
• Intl gate stays: unhealthy / skipped → throw. runSeed's catch extends
TTL on INTL_KEY + seed-meta:aviation:intl and exits 0. afterPublish
never runs, so no side effects escape.
• publishTransform extracts { alerts } from the bundle for the canonical
envelope; declareRecords sees the transformed shape.
• afterPublish handles ALL secondary writes (FAA, NOTAM, news) and
notification dispatch. Runs only after a successful canonical publish.
• Per-run memo (cachedBundle) still short-circuits withRetry(3) retries
so NOTAM quota isn't burned 3x when intl is sick.
NOTAM quota-exhaustion + rejection TTL-extend branches preserved inside
afterPublish — same behavior, different location.
* refactor(aviation): decouple FAA/NOTAM/news side-cars from intl's runSeed gate
Review feedback on PR #3238: the previous refactor coupled all secondary
outputs to the AviationStack primary key. If AVIATIONSTACK_API was missing
or intl was systemically unhealthy, fetchAll() threw → runSeed skipped
afterPublish → FAA/NOTAM/news all went stale despite their own upstream
sources being fine. Before consolidation, FAA and NOTAM each ran their own
cron and could freshen independently. This restores that independence.
Structure:
• Three side-car runners: runFaaSideCar, runNotamSideCar, runNewsSideCar.
Each acquires its own Redis lock (aviation:faa / aviation:notam /
aviation:news — distinct from aviation:intl), fetches its source,
writes data-key + seed-meta on success, extends TTL on failure,
releases the lock. Completely independent of the AviationStack path.
• NOTAM side-car keeps the quota-exhausted + rejection handling and
dispatches notam_closure notifications inline.
• main() runs the three side-cars sequentially, then hands off to runSeed
for intl. runSeed still process.exit()s at the end so it remains the
last call.
• Intl's afterPublish now only dispatches aviation_closure notifications
(its single responsibility).
Removed: the per-run memo for fetchAll (no longer needed — withRetry now
only re-runs the intl fetch, not FAA/NOTAM/RSS).
Net behavior:
• AviationStack 500s / missing key → FAA, NOTAM, news still refresh
normally; only aviation:delays:intl:v3 extends TTL + preserves prior
snapshot.
• ICAO quota exhausted → NOTAM extends TTL + writes fresh meta (as before);
FAA/intl/news unaffected.
• FAA upstream failure → only FAA extends TTL; other sources unaffected.
* fix(aviation): correct Gaborone ICAO + populate FAA alert meta from registry
Greptile review on PR #3238:
P1: GABS is not the ICAO for Gaborone — the value was faithfully copied
from the pre-strip ais-relay NOTAM list which was wrong. Botswana's
ICAO prefix is FB; the correct code is FBSK. NOTAM queries for GABS
would silently exclude Gaborone from closure detection. (Pre-existing
bug in the repo; fixing while in this neighborhood.)
P2 (FAA alerts): Now that the unified AIRPORTS registry carries
icao/name/city/country for every FAA airport, use it. Previous code
returned icao:'', name:iata, city:'' — consumers saw bare IATA codes
for US-only alerts. Registry lookup via a new FAA_META map; lat/lon
stays 0,0 by design (FAA rows aren't rendered on the globe, so lat/lon
is intentionally absent from those registry rows).
P2 (NOTAM TTL on quota exhaustion): already fixed in commit ba7ed014e
(pre-decouple) — confirmed line 803 calls extendExistingTtl([NOTAM_KEY])
and line 805 writes fresh meta with quotaExhausted=true.
This commit is contained in:
@@ -2407,490 +2407,18 @@ async function startMarketDataSeedLoop() {
|
||||
// Aviation Seed — Railway fetches AviationStack → writes to Redis
|
||||
// so Vercel handler serves from cache (avoids 114 API calls per miss)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// AviationStack API key — used only by the /aviationstack live proxy below.
|
||||
// The aviation + NOTAM background seeds that used to live here were
|
||||
// consolidated into scripts/seed-aviation.mjs (standalone Railway cron).
|
||||
const AVIATIONSTACK_API_KEY = process.env.AVIATIONSTACK_API || '';
|
||||
const AVIATION_SEED_INTERVAL_MS = 30 * 60 * 1000; // 30min
|
||||
const AVIATION_SEED_TTL = 10800; // 3h — 6x interval; survives ~5 consecutive missed pings
|
||||
const AVIATION_RETRY_MS = 20 * 60 * 1000;
|
||||
const AVIATION_REDIS_KEY = 'aviation:delays:intl:v3';
|
||||
const AVIATION_BATCH_CONCURRENCY = 10;
|
||||
const AVIATION_MIN_FLIGHTS_FOR_CLOSURE = 10;
|
||||
const RESOLVED_STATUSES = new Set(['cancelled', 'landed', 'active', 'arrived', 'diverted']);
|
||||
|
||||
// Must match src/config/airports.ts AVIATIONSTACK_AIRPORTS — update both when changing
|
||||
const AVIATIONSTACK_AIRPORTS = [
|
||||
'YYZ', 'YVR', 'MEX', 'GRU', 'EZE', 'BOG', 'SCL',
|
||||
'LHR', 'CDG', 'FRA', 'AMS', 'MAD', 'FCO', 'MUC', 'BCN', 'ZRH', 'IST', 'VIE', 'CPH',
|
||||
'DUB', 'LIS', 'ATH', 'WAW',
|
||||
'HND', 'NRT', 'PEK', 'PVG', 'HKG', 'SIN', 'ICN', 'BKK', 'SYD', 'DEL', 'BOM', 'KUL',
|
||||
'CAN', 'TPE', 'MNL',
|
||||
'DXB', 'DOH', 'AUH', 'RUH', 'CAI', 'TLV', 'AMM', 'KWI', 'CMN',
|
||||
'JNB', 'NBO', 'LOS', 'ADD', 'CPT',
|
||||
];
|
||||
|
||||
// Airport metadata needed for alert construction (inlined from airports.ts)
|
||||
const AIRPORT_META = {
|
||||
YYZ: { icao: 'CYYZ', name: 'Toronto Pearson', city: 'Toronto', country: 'Canada', lat: 43.6777, lon: -79.6248, region: 'americas' },
|
||||
MEX: { icao: 'MMMX', name: 'Mexico City International', city: 'Mexico City', country: 'Mexico', lat: 19.4363, lon: -99.0721, region: 'americas' },
|
||||
GRU: { icao: 'SBGR', name: 'São Paulo–Guarulhos', city: 'São Paulo', country: 'Brazil', lat: -23.4356, lon: -46.4731, region: 'americas' },
|
||||
EZE: { icao: 'SAEZ', name: 'Ministro Pistarini', city: 'Buenos Aires', country: 'Argentina', lat: -34.8222, lon: -58.5358, region: 'americas' },
|
||||
BOG: { icao: 'SKBO', name: 'El Dorado International', city: 'Bogotá', country: 'Colombia', lat: 4.7016, lon: -74.1469, region: 'americas' },
|
||||
LHR: { icao: 'EGLL', name: 'London Heathrow', city: 'London', country: 'UK', lat: 51.4700, lon: -0.4543, region: 'europe' },
|
||||
CDG: { icao: 'LFPG', name: 'Paris Charles de Gaulle', city: 'Paris', country: 'France', lat: 49.0097, lon: 2.5479, region: 'europe' },
|
||||
FRA: { icao: 'EDDF', name: 'Frankfurt Airport', city: 'Frankfurt', country: 'Germany', lat: 50.0379, lon: 8.5622, region: 'europe' },
|
||||
AMS: { icao: 'EHAM', name: 'Amsterdam Schiphol', city: 'Amsterdam', country: 'Netherlands', lat: 52.3105, lon: 4.7683, region: 'europe' },
|
||||
MAD: { icao: 'LEMD', name: 'Adolfo Suárez Madrid–Barajas', city: 'Madrid', country: 'Spain', lat: 40.4983, lon: -3.5676, region: 'europe' },
|
||||
FCO: { icao: 'LIRF', name: 'Leonardo da Vinci–Fiumicino', city: 'Rome', country: 'Italy', lat: 41.8003, lon: 12.2389, region: 'europe' },
|
||||
MUC: { icao: 'EDDM', name: 'Munich Airport', city: 'Munich', country: 'Germany', lat: 48.3537, lon: 11.7750, region: 'europe' },
|
||||
BCN: { icao: 'LEBL', name: 'Barcelona–El Prat', city: 'Barcelona', country: 'Spain', lat: 41.2974, lon: 2.0833, region: 'europe' },
|
||||
ZRH: { icao: 'LSZH', name: 'Zurich Airport', city: 'Zurich', country: 'Switzerland', lat: 47.4647, lon: 8.5492, region: 'europe' },
|
||||
IST: { icao: 'LTFM', name: 'Istanbul Airport', city: 'Istanbul', country: 'Turkey', lat: 41.2753, lon: 28.7519, region: 'europe' },
|
||||
VIE: { icao: 'LOWW', name: 'Vienna International', city: 'Vienna', country: 'Austria', lat: 48.1103, lon: 16.5697, region: 'europe' },
|
||||
CPH: { icao: 'EKCH', name: 'Copenhagen Airport', city: 'Copenhagen', country: 'Denmark', lat: 55.6180, lon: 12.6508, region: 'europe' },
|
||||
HND: { icao: 'RJTT', name: 'Tokyo Haneda', city: 'Tokyo', country: 'Japan', lat: 35.5494, lon: 139.7798, region: 'apac' },
|
||||
NRT: { icao: 'RJAA', name: 'Narita International', city: 'Tokyo', country: 'Japan', lat: 35.7720, lon: 140.3929, region: 'apac' },
|
||||
PEK: { icao: 'ZBAA', name: 'Beijing Capital', city: 'Beijing', country: 'China', lat: 40.0799, lon: 116.6031, region: 'apac' },
|
||||
PVG: { icao: 'ZSPD', name: 'Shanghai Pudong', city: 'Shanghai', country: 'China', lat: 31.1443, lon: 121.8083, region: 'apac' },
|
||||
HKG: { icao: 'VHHH', name: 'Hong Kong International', city: 'Hong Kong', country: 'China', lat: 22.3080, lon: 113.9185, region: 'apac' },
|
||||
SIN: { icao: 'WSSS', name: 'Singapore Changi', city: 'Singapore', country: 'Singapore', lat: 1.3644, lon: 103.9915, region: 'apac' },
|
||||
ICN: { icao: 'RKSI', name: 'Incheon International', city: 'Seoul', country: 'South Korea', lat: 37.4602, lon: 126.4407, region: 'apac' },
|
||||
BKK: { icao: 'VTBS', name: 'Suvarnabhumi Airport', city: 'Bangkok', country: 'Thailand', lat: 13.6900, lon: 100.7501, region: 'apac' },
|
||||
SYD: { icao: 'YSSY', name: 'Sydney Kingsford Smith', city: 'Sydney', country: 'Australia', lat: -33.9461, lon: 151.1772, region: 'apac' },
|
||||
DEL: { icao: 'VIDP', name: 'Indira Gandhi International', city: 'Delhi', country: 'India', lat: 28.5562, lon: 77.1000, region: 'apac' },
|
||||
BOM: { icao: 'VABB', name: 'Chhatrapati Shivaji Maharaj', city: 'Mumbai', country: 'India', lat: 19.0896, lon: 72.8656, region: 'apac' },
|
||||
KUL: { icao: 'WMKK', name: 'Kuala Lumpur International', city: 'Kuala Lumpur', country: 'Malaysia', lat: 2.7456, lon: 101.7099, region: 'apac' },
|
||||
DXB: { icao: 'OMDB', name: 'Dubai International', city: 'Dubai', country: 'UAE', lat: 25.2532, lon: 55.3657, region: 'mena' },
|
||||
DOH: { icao: 'OTHH', name: 'Hamad International', city: 'Doha', country: 'Qatar', lat: 25.2731, lon: 51.6081, region: 'mena' },
|
||||
AUH: { icao: 'OMAA', name: 'Abu Dhabi International', city: 'Abu Dhabi', country: 'UAE', lat: 24.4330, lon: 54.6511, region: 'mena' },
|
||||
RUH: { icao: 'OERK', name: 'King Khalid International', city: 'Riyadh', country: 'Saudi Arabia', lat: 24.9576, lon: 46.6988, region: 'mena' },
|
||||
CAI: { icao: 'HECA', name: 'Cairo International', city: 'Cairo', country: 'Egypt', lat: 30.1219, lon: 31.4056, region: 'mena' },
|
||||
TLV: { icao: 'LLBG', name: 'Ben Gurion Airport', city: 'Tel Aviv', country: 'Israel', lat: 32.0055, lon: 34.8854, region: 'mena' },
|
||||
JNB: { icao: 'FAOR', name: 'O.R. Tambo International', city: 'Johannesburg', country: 'South Africa', lat: -26.1392, lon: 28.2460, region: 'africa' },
|
||||
NBO: { icao: 'HKJK', name: 'Jomo Kenyatta International', city: 'Nairobi', country: 'Kenya', lat: -1.3192, lon: 36.9278, region: 'africa' },
|
||||
LOS: { icao: 'DNMM', name: 'Murtala Muhammed International', city: 'Lagos', country: 'Nigeria', lat: 6.5774, lon: 3.3212, region: 'africa' },
|
||||
ADD: { icao: 'HAAB', name: 'Bole International', city: 'Addis Ababa', country: 'Ethiopia', lat: 8.9779, lon: 38.7993, region: 'africa' },
|
||||
CPT: { icao: 'FACT', name: 'Cape Town International', city: 'Cape Town', country: 'South Africa', lat: -33.9715, lon: 18.6021, region: 'africa' },
|
||||
// Added airports
|
||||
YVR: { icao: 'CYVR', name: 'Vancouver International', city: 'Vancouver', country: 'Canada', lat: 49.1947, lon: -123.1792, region: 'americas' },
|
||||
SCL: { icao: 'SCEL', name: 'Arturo Merino Benítez', city: 'Santiago', country: 'Chile', lat: -33.3930, lon: -70.7858, region: 'americas' },
|
||||
DUB: { icao: 'EIDW', name: 'Dublin Airport', city: 'Dublin', country: 'Ireland', lat: 53.4264, lon: -6.2499, region: 'europe' },
|
||||
LIS: { icao: 'LPPT', name: 'Humberto Delgado Airport', city: 'Lisbon', country: 'Portugal', lat: 38.7756, lon: -9.1354, region: 'europe' },
|
||||
ATH: { icao: 'LGAV', name: 'Athens International', city: 'Athens', country: 'Greece', lat: 37.9364, lon: 23.9445, region: 'europe' },
|
||||
WAW: { icao: 'EPWA', name: 'Warsaw Chopin Airport', city: 'Warsaw', country: 'Poland', lat: 52.1657, lon: 20.9671, region: 'europe' },
|
||||
CAN: { icao: 'ZGGG', name: 'Guangzhou Baiyun International', city: 'Guangzhou', country: 'China', lat: 23.3924, lon: 113.2988, region: 'apac' },
|
||||
TPE: { icao: 'RCTP', name: 'Taiwan Taoyuan International', city: 'Taipei', country: 'Taiwan', lat: 25.0797, lon: 121.2342, region: 'apac' },
|
||||
MNL: { icao: 'RPLL', name: 'Ninoy Aquino International', city: 'Manila', country: 'Philippines', lat: 14.5086, lon: 121.0197, region: 'apac' },
|
||||
AMM: { icao: 'OJAI', name: 'Queen Alia International', city: 'Amman', country: 'Jordan', lat: 31.7226, lon: 35.9932, region: 'mena' },
|
||||
KWI: { icao: 'OKBK', name: 'Kuwait International', city: 'Kuwait City', country: 'Kuwait', lat: 29.2266, lon: 47.9689, region: 'mena' },
|
||||
CMN: { icao: 'GMMN', name: 'Mohammed V International', city: 'Casablanca', country: 'Morocco', lat: 33.3675, lon: -7.5898, region: 'mena' },
|
||||
};
|
||||
|
||||
const REGION_MAP = {
|
||||
americas: 'AIRPORT_REGION_AMERICAS',
|
||||
europe: 'AIRPORT_REGION_EUROPE',
|
||||
apac: 'AIRPORT_REGION_APAC',
|
||||
mena: 'AIRPORT_REGION_MENA',
|
||||
africa: 'AIRPORT_REGION_AFRICA',
|
||||
};
|
||||
|
||||
const DELAY_TYPE_MAP = {
|
||||
ground_stop: 'FLIGHT_DELAY_TYPE_GROUND_STOP',
|
||||
ground_delay: 'FLIGHT_DELAY_TYPE_GROUND_DELAY',
|
||||
departure_delay: 'FLIGHT_DELAY_TYPE_DEPARTURE_DELAY',
|
||||
arrival_delay: 'FLIGHT_DELAY_TYPE_ARRIVAL_DELAY',
|
||||
general: 'FLIGHT_DELAY_TYPE_GENERAL',
|
||||
closure: 'FLIGHT_DELAY_TYPE_CLOSURE',
|
||||
};
|
||||
|
||||
const SEVERITY_MAP = {
|
||||
normal: 'FLIGHT_DELAY_SEVERITY_NORMAL',
|
||||
minor: 'FLIGHT_DELAY_SEVERITY_MINOR',
|
||||
moderate: 'FLIGHT_DELAY_SEVERITY_MODERATE',
|
||||
major: 'FLIGHT_DELAY_SEVERITY_MAJOR',
|
||||
severe: 'FLIGHT_DELAY_SEVERITY_SEVERE',
|
||||
};
|
||||
|
||||
function aviationDetermineSeverity(avgDelay, delayedPct) {
|
||||
if (avgDelay >= 60 || (delayedPct && delayedPct >= 60)) return 'severe';
|
||||
if (avgDelay >= 45 || (delayedPct && delayedPct >= 45)) return 'major';
|
||||
if (avgDelay >= 30 || (delayedPct && delayedPct >= 30)) return 'moderate';
|
||||
if (avgDelay >= 15 || (delayedPct && delayedPct >= 15)) return 'minor';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
function fetchAviationStackSingle(apiKey, iata) {
|
||||
return new Promise((resolve) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const url = `https://api.aviationstack.com/v1/flights?access_key=${apiKey}&dep_iata=${iata}&flight_date=${today}&limit=100`;
|
||||
const req = https.get(url, {
|
||||
headers: { 'User-Agent': CHROME_UA },
|
||||
timeout: 5000,
|
||||
family: 4,
|
||||
}, (resp) => {
|
||||
if (resp.statusCode !== 200) {
|
||||
resp.resume();
|
||||
logThrottled('warn', `aviation-http-${resp.statusCode}:${iata}`, `[Aviation] ${iata}: HTTP ${resp.statusCode}`);
|
||||
return resolve({ ok: false, alert: null });
|
||||
}
|
||||
let body = '';
|
||||
resp.on('data', (chunk) => { body += chunk; });
|
||||
resp.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
if (json.error) {
|
||||
logThrottled('warn', `aviation-api-err:${iata}`, `[Aviation] ${iata}: API error: ${json.error.message}`);
|
||||
return resolve({ ok: false, alert: null });
|
||||
}
|
||||
const flights = json?.data ?? [];
|
||||
const alert = aviationAggregateFlights(iata, flights);
|
||||
resolve({ ok: true, alert });
|
||||
} catch { resolve({ ok: false, alert: null }); }
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
logThrottled('warn', `aviation-err:${iata}`, `[Aviation] ${iata}: fetch error: ${err.message}`);
|
||||
resolve({ ok: false, alert: null });
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); resolve({ ok: false, alert: null }); });
|
||||
});
|
||||
}
|
||||
|
||||
function aviationAggregateFlights(iata, flights) {
|
||||
if (flights.length === 0) return null;
|
||||
const meta = AIRPORT_META[iata];
|
||||
if (!meta) return null;
|
||||
|
||||
let delayed = 0, cancelled = 0, totalDelay = 0, resolved = 0;
|
||||
for (const f of flights) {
|
||||
if (RESOLVED_STATUSES.has(f.flight_status || '')) resolved++;
|
||||
if (f.flight_status === 'cancelled') cancelled++;
|
||||
if (f.departure?.delay && f.departure.delay > 0) {
|
||||
delayed++;
|
||||
totalDelay += f.departure.delay;
|
||||
}
|
||||
}
|
||||
|
||||
const total = resolved >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE ? resolved : flights.length;
|
||||
const cancelledPct = (cancelled / total) * 100;
|
||||
const delayedPct = (delayed / total) * 100;
|
||||
const avgDelay = delayed > 0 ? Math.round(totalDelay / delayed) : 0;
|
||||
|
||||
let severity, delayType, reason;
|
||||
if (cancelledPct >= 80 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
|
||||
severity = 'severe'; delayType = 'closure';
|
||||
reason = 'Airport closure / airspace restrictions';
|
||||
} else if (cancelledPct >= 50 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
|
||||
severity = 'major'; delayType = 'ground_stop';
|
||||
reason = `${Math.round(cancelledPct)}% flights cancelled`;
|
||||
} else if (cancelledPct >= 20 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
|
||||
severity = 'moderate'; delayType = 'ground_delay';
|
||||
reason = `${Math.round(cancelledPct)}% flights cancelled`;
|
||||
} else if (cancelledPct >= 10 && total >= AVIATION_MIN_FLIGHTS_FOR_CLOSURE) {
|
||||
severity = 'minor'; delayType = 'general';
|
||||
reason = `${Math.round(cancelledPct)}% flights cancelled`;
|
||||
} else if (avgDelay > 0) {
|
||||
severity = aviationDetermineSeverity(avgDelay, delayedPct);
|
||||
delayType = avgDelay >= 60 ? 'ground_delay' : 'general';
|
||||
reason = `Avg ${avgDelay}min delay, ${Math.round(delayedPct)}% delayed`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (severity === 'normal') return null;
|
||||
|
||||
return {
|
||||
id: `avstack-${iata}`,
|
||||
iata,
|
||||
icao: meta.icao,
|
||||
name: meta.name,
|
||||
city: meta.city,
|
||||
country: meta.country,
|
||||
location: { latitude: meta.lat, longitude: meta.lon },
|
||||
region: REGION_MAP[meta.region] || 'AIRPORT_REGION_UNSPECIFIED',
|
||||
delayType: DELAY_TYPE_MAP[delayType] || 'FLIGHT_DELAY_TYPE_GENERAL',
|
||||
severity: SEVERITY_MAP[severity] || 'FLIGHT_DELAY_SEVERITY_NORMAL',
|
||||
avgDelayMinutes: avgDelay,
|
||||
delayedFlightsPct: Math.round(delayedPct),
|
||||
cancelledFlights: cancelled,
|
||||
totalFlights: total,
|
||||
reason,
|
||||
source: 'FLIGHT_DELAY_SOURCE_AVIATIONSTACK',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
let aviationSeedInFlight = false;
|
||||
let aviationRetryTimer = null;
|
||||
|
||||
async function seedAviationDelays() {
|
||||
if (!AVIATIONSTACK_API_KEY) {
|
||||
console.log('[Aviation] No AVIATIONSTACK_API key — skipping seed');
|
||||
return;
|
||||
}
|
||||
if (aviationSeedInFlight) return;
|
||||
aviationSeedInFlight = true;
|
||||
if (aviationRetryTimer) { clearTimeout(aviationRetryTimer); aviationRetryTimer = null; }
|
||||
|
||||
const t0 = Date.now();
|
||||
const alerts = [];
|
||||
let succeeded = 0, failed = 0;
|
||||
const deadline = Date.now() + 50_000;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < AVIATIONSTACK_AIRPORTS.length; i += AVIATION_BATCH_CONCURRENCY) {
|
||||
if (Date.now() >= deadline) {
|
||||
console.warn(`[Aviation] Deadline hit after ${succeeded + failed}/${AVIATIONSTACK_AIRPORTS.length} airports`);
|
||||
break;
|
||||
}
|
||||
const chunk = AVIATIONSTACK_AIRPORTS.slice(i, i + AVIATION_BATCH_CONCURRENCY);
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map((iata) => fetchAviationStackSingle(AVIATIONSTACK_API_KEY, iata))
|
||||
);
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
if (r.value.ok) { succeeded++; if (r.value.alert) alerts.push(r.value.alert); }
|
||||
else failed++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const healthy = AVIATIONSTACK_AIRPORTS.length < 5 || failed <= succeeded;
|
||||
if (!healthy) {
|
||||
console.warn(`[Aviation] Systemic failure: ${failed}/${failed + succeeded} airports failed — extending TTL, retrying in 20min`);
|
||||
try { await upstashExpire(AVIATION_REDIS_KEY, AVIATION_SEED_TTL); } catch {}
|
||||
aviationRetryTimer = setTimeout(() => { seedAviationDelays().catch(() => {}); }, AVIATION_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await envelopeWrite(AVIATION_REDIS_KEY, { alerts }, AVIATION_SEED_TTL, { recordCount: alerts.length, sourceVersion: 'aviationstack' });
|
||||
await upstashSet('seed-meta:aviation:intl', { fetchedAt: Date.now(), recordCount: alerts.length }, 604800);
|
||||
console.log(`[Aviation] Seeded ${alerts.length} alerts (${succeeded} ok, ${failed} failed, redis: ${ok ? 'OK' : 'FAIL'}) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||||
const severeAlerts = alerts.filter(a =>
|
||||
a.severity === 'FLIGHT_DELAY_SEVERITY_SEVERE' || a.severity === 'FLIGHT_DELAY_SEVERITY_MAJOR'
|
||||
);
|
||||
// Change detection: only notify for airports newly entering severe/major state.
|
||||
// aviationPrevAlertedSet persists across polls in-memory; dedupTtl (4h) guards restarts.
|
||||
const currentIatas = new Set(severeAlerts.map(a => a.iata).filter(Boolean));
|
||||
const newAlerts = severeAlerts.filter(a => a.iata && !aviationPrevAlertedSet.has(a.iata));
|
||||
aviationPrevAlertedSet.clear();
|
||||
currentIatas.forEach(iata => aviationPrevAlertedSet.add(iata));
|
||||
for (const a of newAlerts.slice(0, 3)) {
|
||||
publishNotificationEvent({
|
||||
eventType: 'aviation_closure',
|
||||
payload: { title: `${a.iata}${a.city ? ` (${a.city})` : ''}: ${a.reason || 'Airport disruption'}`, source: 'AviationStack' },
|
||||
severity: a.severity === 'FLIGHT_DELAY_SEVERITY_SEVERE' ? 'critical' : 'high',
|
||||
variant: undefined,
|
||||
dedupTtl: 14400, // 4h — well above the 30min poll interval
|
||||
}).catch(e => console.warn('[Notify] Aviation publish error:', e?.message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Aviation] Seed error:', e?.message || e, '— extending TTL, retrying in 20min');
|
||||
try { await upstashExpire(AVIATION_REDIS_KEY, AVIATION_SEED_TTL); } catch {}
|
||||
aviationRetryTimer = setTimeout(() => { seedAviationDelays().catch(() => {}); }, AVIATION_RETRY_MS);
|
||||
} finally {
|
||||
aviationSeedInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startAviationSeedLoop() {
|
||||
if (!UPSTASH_ENABLED) {
|
||||
console.log('[Aviation] Disabled (no Upstash Redis)');
|
||||
return;
|
||||
}
|
||||
if (!AVIATIONSTACK_API_KEY) {
|
||||
console.log('[Aviation] Disabled (no AVIATIONSTACK_API key)');
|
||||
return;
|
||||
}
|
||||
console.log(`[Aviation] Seed loop starting (interval ${AVIATION_SEED_INTERVAL_MS / 1000 / 60 / 60}h, airports: ${AVIATIONSTACK_AIRPORTS.length})`);
|
||||
seedAviationDelays().catch((e) => console.warn('[Aviation] Initial seed error:', e?.message || e));
|
||||
setInterval(() => {
|
||||
seedAviationDelays().catch((e) => console.warn('[Aviation] Seed error:', e?.message || e));
|
||||
}, AVIATION_SEED_INTERVAL_MS).unref?.();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// NOTAM Closures Seed — Railway fetches ICAO NOTAMs → writes to Redis
|
||||
// so Vercel handler and map layer serve from cache (ICAO API times out from edge)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
const NOTAM_SEED_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2h — reduced from 30min to stay within ICAO free-tier quota (~1000 calls/month)
|
||||
const NOTAM_SEED_TTL = 21600; // 6h — 3x interval
|
||||
const NOTAM_RETRY_MS = 20 * 60 * 1000;
|
||||
const NOTAM_QUOTA_BACKOFF_MS = 24 * 60 * 60 * 1000; // 24h backoff when ICAO quota is exhausted
|
||||
const NOTAM_REDIS_KEY = 'aviation:notam:closures:v2';
|
||||
const NOTAM_CLOSURE_QCODES = new Set(['FA', 'AH', 'AL', 'AW', 'AC', 'AM']);
|
||||
const notamPrevClosed = new Set();
|
||||
let notamStateLoaded = false; // true after first Redis load — prevents false positives on restart
|
||||
const aviationPrevAlertedSet = new Set(); // tracks IATA codes currently in severe/major state
|
||||
const cyberPrevAlertedIds = new Set(); // tracks indicators notified this session; cleared at 500 entries
|
||||
const ucdpPrevAlertedIds = new Set(); // tracks UCDP event IDs notified; cleared at 500 entries
|
||||
const NOTAM_MONITORED_ICAO = [
|
||||
// MENA
|
||||
'OEJN', 'OERK', 'OEMA', 'OEDF', 'OMDB', 'OMAA', 'OMSJ',
|
||||
'OTHH', 'OBBI', 'OOMS', 'OKBK', 'OLBA', 'OJAI', 'OSDI',
|
||||
'ORBI', 'OIIE', 'OISS', 'OIMM', 'OIKB', 'HECA', 'GMMN',
|
||||
'DTTA', 'DAAG', 'HLLT',
|
||||
// Europe
|
||||
'EGLL', 'LFPG', 'EDDF', 'EHAM', 'LEMD', 'LIRF', 'LTFM',
|
||||
'LSZH', 'LOWW', 'EKCH', 'ENGM', 'ESSA', 'EFHK', 'EPWA',
|
||||
// Americas
|
||||
'KJFK', 'KLAX', 'KORD', 'KATL', 'KDFW', 'KDEN', 'KSFO',
|
||||
'CYYZ', 'MMMX', 'SBGR', 'SCEL', 'SKBO',
|
||||
// APAC
|
||||
'RJTT', 'RKSI', 'VHHH', 'WSSS', 'VTBS', 'VIDP', 'YSSY',
|
||||
'ZBAA', 'ZPPP', 'WMKK',
|
||||
// Africa
|
||||
'FAOR', 'DNMM', 'HKJK', 'GABS',
|
||||
];
|
||||
|
||||
// Returns: Array of NOTAMs on success, null on quota exhaustion, [] on other errors
|
||||
function fetchIcaoNotams() {
|
||||
return new Promise((resolve) => {
|
||||
if (!ICAO_API_KEY) return resolve([]);
|
||||
const locations = NOTAM_MONITORED_ICAO.join(',');
|
||||
const apiUrl = `https://dataservices.icao.int/api/notams-realtime-list?api_key=${ICAO_API_KEY}&format=json&locations=${locations}`;
|
||||
const req = https.get(apiUrl, {
|
||||
headers: { 'User-Agent': CHROME_UA },
|
||||
timeout: 30000,
|
||||
}, (resp) => {
|
||||
const chunks = [];
|
||||
resp.on('data', (c) => chunks.push(c));
|
||||
resp.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString();
|
||||
// Detect quota exhaustion regardless of status code
|
||||
if (/reach call limit/i.test(body) || /quota.?exceed/i.test(body)) {
|
||||
console.warn('[NOTAM-Seed] ICAO quota exhausted ("Reach call limit") — backing off 24h');
|
||||
return resolve(null);
|
||||
}
|
||||
if (resp.statusCode !== 200) {
|
||||
console.warn(`[NOTAM-Seed] ICAO HTTP ${resp.statusCode}`);
|
||||
return resolve([]);
|
||||
}
|
||||
const ct = resp.headers['content-type'] || '';
|
||||
if (ct.includes('text/html')) {
|
||||
console.warn('[NOTAM-Seed] ICAO returned HTML (challenge page)');
|
||||
return resolve([]);
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
resolve(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
console.warn('[NOTAM-Seed] Invalid JSON from ICAO');
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => { console.warn(`[NOTAM-Seed] Fetch error: ${err.message}`); resolve([]); });
|
||||
req.on('timeout', () => { req.destroy(); console.warn('[NOTAM-Seed] Timeout (30s)'); resolve([]); });
|
||||
});
|
||||
}
|
||||
|
||||
let notamSeedInFlight = false;
|
||||
let notamRetryTimer = null;
|
||||
|
||||
async function seedNotamClosures() {
|
||||
if (!ICAO_API_KEY) {
|
||||
console.log('[NOTAM-Seed] No ICAO_API_KEY — skipping');
|
||||
return;
|
||||
}
|
||||
if (notamSeedInFlight) return;
|
||||
notamSeedInFlight = true;
|
||||
if (notamRetryTimer) { clearTimeout(notamRetryTimer); notamRetryTimer = null; }
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const notams = await fetchIcaoNotams();
|
||||
|
||||
// null = quota exhausted — touch seed-meta so health.js stays green, back off 24h
|
||||
if (notams === null) {
|
||||
try { await upstashExpire(NOTAM_REDIS_KEY, NOTAM_SEED_TTL); } catch {}
|
||||
try { await upstashSet('seed-meta:aviation:notam', { fetchedAt: Date.now(), recordCount: 0, quotaExhausted: true }, 604800); } catch {}
|
||||
console.log('[NOTAM-Seed] Quota exhausted — extended TTL, wrote seed-meta, backing off 24h');
|
||||
notamRetryTimer = setTimeout(() => { seedNotamClosures().catch(() => {}); }, NOTAM_QUOTA_BACKOFF_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
if (notams.length === 0) {
|
||||
try { await upstashExpire(NOTAM_REDIS_KEY, NOTAM_SEED_TTL); } catch {}
|
||||
console.log('[NOTAM-Seed] No NOTAMs received — refreshed data key TTL, retrying in 20min');
|
||||
notamRetryTimer = setTimeout(() => { seedNotamClosures().catch(() => {}); }, NOTAM_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const closedSet = new Set();
|
||||
const reasons = {};
|
||||
|
||||
for (const n of notams) {
|
||||
const icao = n.itema || n.location || '';
|
||||
if (!icao || !NOTAM_MONITORED_ICAO.includes(icao)) continue;
|
||||
if (n.endvalidity && n.endvalidity < now) continue;
|
||||
|
||||
const code23 = (n.code23 || '').toUpperCase();
|
||||
const code45 = (n.code45 || '').toUpperCase();
|
||||
const text = (n.iteme || '').toUpperCase();
|
||||
const isClosureCode = NOTAM_CLOSURE_QCODES.has(code23) &&
|
||||
(code45 === 'LC' || code45 === 'AS' || code45 === 'AU' || code45 === 'XX' || code45 === 'AW');
|
||||
const isClosureText = /\b(AD CLSD|AIRPORT CLOSED|AIRSPACE CLOSED|AD NOT AVBL|CLSD TO ALL)\b/.test(text);
|
||||
|
||||
if (isClosureCode || isClosureText) {
|
||||
closedSet.add(icao);
|
||||
reasons[icao] = n.iteme || 'Airport closure (NOTAM)';
|
||||
}
|
||||
}
|
||||
|
||||
const closedIcaos = [...closedSet];
|
||||
const payload = { closedIcaos, reasons };
|
||||
const ok = await envelopeWrite(NOTAM_REDIS_KEY, payload, NOTAM_SEED_TTL, { recordCount: closedIcaos.length, sourceVersion: 'icao-notam', zeroOk: true });
|
||||
await upstashSet('seed-meta:aviation:notam', { fetchedAt: Date.now(), recordCount: closedIcaos.length }, 604800);
|
||||
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
console.log(`[NOTAM-Seed] ${notams.length} raw NOTAMs, ${closedIcaos.length} closures (redis: ${ok ? 'OK' : 'FAIL'}) in ${elapsed}s`);
|
||||
// On first run after a restart, pre-populate notamPrevClosed from Redis so
|
||||
// airports that were already closed before the restart are not treated as new.
|
||||
if (!notamStateLoaded) {
|
||||
notamStateLoaded = true;
|
||||
try {
|
||||
const saved = await upstashGet('notam:prev-closed-state:v1');
|
||||
if (Array.isArray(saved)) saved.forEach(icao => notamPrevClosed.add(icao));
|
||||
} catch {}
|
||||
}
|
||||
const newClosures = closedIcaos.filter(icao => !notamPrevClosed.has(icao));
|
||||
notamPrevClosed.clear();
|
||||
closedIcaos.forEach(icao => notamPrevClosed.add(icao));
|
||||
// Persist current closed set so next restart doesn't re-fire existing closures.
|
||||
upstashSet('notam:prev-closed-state:v1', closedIcaos, NOTAM_SEED_TTL * 2).catch(() => {});
|
||||
for (const icao of newClosures.slice(0, 3)) {
|
||||
publishNotificationEvent({
|
||||
eventType: 'notam_closure',
|
||||
payload: { title: `NOTAM: ${icao} — ${reasons[icao] || 'Airport closure'}`, source: 'ICAO NOTAM' },
|
||||
severity: 'high',
|
||||
variant: undefined,
|
||||
dedupTtl: 21600, // 6h — well above the 2h poll interval; guards across restarts
|
||||
}).catch(e => console.warn('[Notify] NOTAM publish error:', e?.message));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[NOTAM-Seed] Seed error:', e?.message || e, '— extending TTL, retrying in 20min');
|
||||
try { await upstashExpire(NOTAM_REDIS_KEY, NOTAM_SEED_TTL); } catch {}
|
||||
notamRetryTimer = setTimeout(() => { seedNotamClosures().catch(() => {}); }, NOTAM_RETRY_MS);
|
||||
} finally {
|
||||
notamSeedInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startNotamSeedLoop() {
|
||||
if (!UPSTASH_ENABLED) {
|
||||
console.log('[NOTAM-Seed] Disabled (no Upstash Redis)');
|
||||
return;
|
||||
}
|
||||
if (!ICAO_API_KEY) {
|
||||
console.log('[NOTAM-Seed] Disabled (no ICAO_API_KEY)');
|
||||
return;
|
||||
}
|
||||
console.log(`[NOTAM-Seed] Seed loop starting (interval ${NOTAM_SEED_INTERVAL_MS / 1000 / 60}min, airports: ${NOTAM_MONITORED_ICAO.length})`);
|
||||
seedNotamClosures().catch((e) => console.warn('[NOTAM-Seed] Initial seed error:', e?.message || e));
|
||||
setInterval(() => {
|
||||
seedNotamClosures().catch((e) => console.warn('[NOTAM-Seed] Seed error:', e?.message || e));
|
||||
}, NOTAM_SEED_INTERVAL_MS).unref?.();
|
||||
}
|
||||
// In-process dedup sets for non-aviation seed notifications (cyber + UCDP).
|
||||
// These track IDs already queued within a seed process's lifetime so the
|
||||
// loops below don't re-notify on every poll. Cleared at 500 entries to
|
||||
// bound memory. Aviation + NOTAM moved their dedup state to Redis
|
||||
// (notifications:dedup:aviation:prev-alerted:v1 / notam:prev-closed-state:v1).
|
||||
const cyberPrevAlertedIds = new Set();
|
||||
const ucdpPrevAlertedIds = new Set();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Cyber Threat Intelligence Seed — Railway fetches IOC feeds → writes to Redis
|
||||
@@ -11002,8 +10530,9 @@ server.listen(PORT, () => {
|
||||
startOrefPollLoop();
|
||||
startUcdpSeedLoop();
|
||||
startMarketDataSeedLoop();
|
||||
startAviationSeedLoop();
|
||||
startNotamSeedLoop();
|
||||
// Aviation + NOTAM seeds — standalone Railway cron — scripts/seed-aviation.mjs
|
||||
// Writes aviation:delays:intl:v3, aviation:delays:faa:v1, aviation:notam:closures:v2,
|
||||
// aviation:news::24:v1 and publishes aviation_closure/notam_closure events.
|
||||
// Energy spine seed — standalone Railway cron (0 6 * * *) — seed-energy-spine.mjs
|
||||
// Assembles per-country canonical energy keys from 6 domain sources daily.
|
||||
// Cyber seed disabled — standalone cron seed-cyber-threats.mjs handles this
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadEnvFile, CHROME_UA, getRedisCredentials, acquireLockSafely, releaseLock, withRetry, writeFreshnessMetadata, logSeedResult, verifySeedKey, extendExistingTtl } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const FAA_CACHE_KEY = 'aviation:delays:faa:v1';
|
||||
const NOTAM_CACHE_KEY = 'aviation:notam:closures:v2';
|
||||
const CACHE_TTL = 7200;
|
||||
|
||||
const FAA_URL = 'https://nasstatus.faa.gov/api/airport-status-information';
|
||||
const ICAO_NOTAM_URL = 'https://dataservices.icao.int/api/notams-realtime-list';
|
||||
|
||||
const NOTAM_CLOSURE_QCODES = new Set(['FA', 'AH', 'AL', 'AW', 'AC', 'AM']);
|
||||
|
||||
const FAA_AIRPORTS = [
|
||||
'ATL', 'ORD', 'DFW', 'DEN', 'LAX', 'JFK', 'SFO', 'SEA', 'LAS', 'MCO',
|
||||
'EWR', 'CLT', 'PHX', 'IAH', 'MIA', 'BOS', 'MSP', 'DTW', 'FLL', 'PHL',
|
||||
'LGA', 'BWI', 'SLC', 'SAN', 'IAD', 'DCA', 'MDW', 'TPA', 'HNL', 'PDX',
|
||||
];
|
||||
|
||||
const MONITORED_AIRPORTS_ICAO = [
|
||||
// MENA
|
||||
'OEJN', 'OERK', 'OEMA', 'OEDF', 'OMDB', 'OMAA', 'OMSJ',
|
||||
'OTHH', 'OBBI', 'OOMS', 'OKBK', 'OLBA', 'OJAI', 'OSDI',
|
||||
'ORBI', 'OIIE', 'OISS', 'OIMM', 'OIKB', 'HECA', 'GMMN',
|
||||
'DTTA', 'DAAG', 'HLLT',
|
||||
// Europe
|
||||
'EGLL', 'LFPG', 'EDDF', 'EHAM', 'LEMD', 'LIRF', 'LTFM',
|
||||
'LSZH', 'LOWW', 'EKCH', 'ENGM', 'ESSA', 'EFHK', 'EPWA',
|
||||
// Americas
|
||||
'KJFK', 'KLAX', 'KORD', 'KATL', 'KDFW', 'KDEN', 'KSFO',
|
||||
'CYYZ', 'MMMX', 'SBGR', 'SCEL', 'SKBO',
|
||||
// APAC
|
||||
'RJTT', 'RKSI', 'VHHH', 'WSSS', 'VTBS', 'VIDP', 'YSSY',
|
||||
'ZBAA', 'ZPPP', 'WMKK',
|
||||
// Africa
|
||||
'FAOR', 'DNMM', 'HKJK', 'GABS',
|
||||
];
|
||||
|
||||
function parseDelayTypeFromReason(reason) {
|
||||
const r = reason.toLowerCase();
|
||||
if (r.includes('ground stop')) return 'ground_stop';
|
||||
if (r.includes('ground delay') || r.includes('gdp')) return 'ground_delay';
|
||||
if (r.includes('departure')) return 'departure_delay';
|
||||
if (r.includes('arrival')) return 'arrival_delay';
|
||||
if (r.includes('clos')) return 'ground_stop';
|
||||
return 'general';
|
||||
}
|
||||
|
||||
function parseFaaXml(text) {
|
||||
const delays = new Map();
|
||||
const parseTag = (xml, tag) => {
|
||||
const re = new RegExp(`<${tag}>(.*?)</${tag}>`, 'gs');
|
||||
const matches = [];
|
||||
let m;
|
||||
while ((m = re.exec(xml))) matches.push(m[1]);
|
||||
return matches;
|
||||
};
|
||||
const getVal = (block, tag) => {
|
||||
const m = block.match(new RegExp(`<${tag}>(.*?)</${tag}>`));
|
||||
return m ? m[1].trim() : '';
|
||||
};
|
||||
|
||||
for (const gd of parseTag(text, 'Ground_Delay')) {
|
||||
const arpt = getVal(gd, 'ARPT');
|
||||
if (arpt) {
|
||||
delays.set(arpt, {
|
||||
airport: arpt,
|
||||
reason: getVal(gd, 'Reason') || 'Ground delay',
|
||||
avgDelay: parseInt(getVal(gd, 'Avg') || '30', 10),
|
||||
type: 'ground_delay',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const gs of parseTag(text, 'Ground_Stop')) {
|
||||
const arpt = getVal(gs, 'ARPT');
|
||||
if (arpt) {
|
||||
delays.set(arpt, {
|
||||
airport: arpt,
|
||||
reason: getVal(gs, 'Reason') || 'Ground stop',
|
||||
avgDelay: 60,
|
||||
type: 'ground_stop',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const d of parseTag(text, 'Delay')) {
|
||||
const arpt = getVal(d, 'ARPT');
|
||||
if (arpt) {
|
||||
const existing = delays.get(arpt);
|
||||
if (!existing || existing.type !== 'ground_stop') {
|
||||
const min = parseInt(getVal(d, 'Min') || '15', 10);
|
||||
const max = parseInt(getVal(d, 'Max') || '30', 10);
|
||||
delays.set(arpt, {
|
||||
airport: arpt,
|
||||
reason: getVal(d, 'Reason') || 'Delays',
|
||||
avgDelay: Math.round((min + max) / 2),
|
||||
type: parseDelayTypeFromReason(getVal(d, 'Reason') || ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ac of parseTag(text, 'Airport')) {
|
||||
const arpt = getVal(ac, 'ARPT');
|
||||
if (arpt && FAA_AIRPORTS.includes(arpt)) {
|
||||
delays.set(arpt, {
|
||||
airport: arpt,
|
||||
reason: 'Airport closure',
|
||||
avgDelay: 120,
|
||||
type: 'ground_stop',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return delays;
|
||||
}
|
||||
|
||||
function determineSeverity(avgDelay) {
|
||||
if (avgDelay >= 90) return 'severe';
|
||||
if (avgDelay >= 60) return 'major';
|
||||
if (avgDelay >= 30) return 'moderate';
|
||||
if (avgDelay >= 15) return 'minor';
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
async function redisSet(url, token, key, value, ttl) {
|
||||
const payload = JSON.stringify(value);
|
||||
const cmd = ttl ? ['SET', key, payload, 'EX', ttl] : ['SET', key, payload];
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cmd),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Redis SET ${key} failed: HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
async function seedFaaDelays() {
|
||||
console.log('[FAA] Fetching airport status...');
|
||||
const resp = await fetch(FAA_URL, {
|
||||
headers: { Accept: 'application/xml', 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`FAA HTTP ${resp.status}`);
|
||||
}
|
||||
|
||||
const xml = await resp.text();
|
||||
const faaDelays = parseFaaXml(xml);
|
||||
const alerts = [];
|
||||
|
||||
for (const iata of FAA_AIRPORTS) {
|
||||
const delay = faaDelays.get(iata);
|
||||
if (delay) {
|
||||
alerts.push({
|
||||
id: `faa-${iata}`,
|
||||
iata,
|
||||
icao: '',
|
||||
name: iata,
|
||||
city: '',
|
||||
country: 'USA',
|
||||
location: { latitude: 0, longitude: 0 },
|
||||
region: 'AIRPORT_REGION_AMERICAS',
|
||||
delayType: `FLIGHT_DELAY_TYPE_${delay.type.toUpperCase()}`,
|
||||
severity: `FLIGHT_DELAY_SEVERITY_${determineSeverity(delay.avgDelay).toUpperCase()}`,
|
||||
avgDelayMinutes: delay.avgDelay,
|
||||
delayedFlightsPct: 0,
|
||||
cancelledFlights: 0,
|
||||
totalFlights: 0,
|
||||
reason: delay.reason,
|
||||
source: 'FLIGHT_DELAY_SOURCE_FAA',
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[FAA] ${alerts.length} alerts found`);
|
||||
return { alerts };
|
||||
}
|
||||
|
||||
async function seedNotamClosures() {
|
||||
const apiKey = process.env.ICAO_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.log('[NOTAM] No ICAO_API_KEY — skipping');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[NOTAM] Fetching closures for ${MONITORED_AIRPORTS_ICAO.length} monitored airports...`);
|
||||
const locations = MONITORED_AIRPORTS_ICAO.join(',');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
let notams = [];
|
||||
try {
|
||||
// ICAO API only supports key via query param (no header auth)
|
||||
const url = `${ICAO_NOTAM_URL}?api_key=${apiKey}&format=json&locations=${locations}`;
|
||||
const resp = await fetch(url, {
|
||||
headers: { 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(`[NOTAM] HTTP ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
if (contentType.includes('text/html')) {
|
||||
console.warn('[NOTAM] Got HTML instead of JSON');
|
||||
return null;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (Array.isArray(data)) notams = data;
|
||||
} catch (err) {
|
||||
console.warn(`[NOTAM] Fetch error: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[NOTAM] ${notams.length} raw NOTAMs received`);
|
||||
|
||||
const closedSet = new Set();
|
||||
const reasons = {};
|
||||
|
||||
for (const n of notams) {
|
||||
const icao = n.itema || n.location || '';
|
||||
if (!icao || !MONITORED_AIRPORTS_ICAO.includes(icao)) continue;
|
||||
if (n.endvalidity && n.endvalidity < now) continue;
|
||||
|
||||
const code23 = (n.code23 || '').toUpperCase();
|
||||
const code45 = (n.code45 || '').toUpperCase();
|
||||
const text = (n.iteme || '').toUpperCase();
|
||||
const isClosureCode = NOTAM_CLOSURE_QCODES.has(code23) &&
|
||||
(code45 === 'LC' || code45 === 'AS' || code45 === 'AU' || code45 === 'XX' || code45 === 'AW');
|
||||
const isClosureText = /\b(AD CLSD|AIRPORT CLOSED|AIRSPACE CLOSED|AD NOT AVBL|CLSD TO ALL)\b/.test(text);
|
||||
|
||||
if (isClosureCode || isClosureText) {
|
||||
closedSet.add(icao);
|
||||
reasons[icao] = n.iteme || 'Airport closure (NOTAM)';
|
||||
}
|
||||
}
|
||||
|
||||
const closedIcaos = [...closedSet];
|
||||
|
||||
if (closedIcaos.length > 0) {
|
||||
console.log(`[NOTAM] Closures: ${closedIcaos.join(', ')}`);
|
||||
} else {
|
||||
console.log('[NOTAM] No closures found');
|
||||
}
|
||||
|
||||
return { closedIcaos, reasons };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const startMs = Date.now();
|
||||
const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const { url, token } = getRedisCredentials();
|
||||
|
||||
console.log('=== aviation:delays Seed ===');
|
||||
|
||||
const lockResult = await acquireLockSafely('aviation:delays', runId, 120_000, { label: 'aviation:delays' });
|
||||
if (lockResult.skipped) {
|
||||
process.exit(0);
|
||||
}
|
||||
if (!lockResult.locked) {
|
||||
console.log(' SKIPPED: another seed run in progress');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let faaData, notamData;
|
||||
try {
|
||||
faaData = await withRetry(seedFaaDelays);
|
||||
notamData = await seedNotamClosures();
|
||||
} catch (err) {
|
||||
await releaseLock('aviation:delays', runId);
|
||||
console.error(` FETCH FAILED: ${err.message || err}`);
|
||||
await extendExistingTtl([FAA_CACHE_KEY, NOTAM_CACHE_KEY, 'seed-meta:aviation:faa', 'seed-meta:aviation:notam'], CACHE_TTL);
|
||||
console.log(`\n=== Failed gracefully (${Math.round(Date.now() - startMs)}ms) ===`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
await redisSet(url, token, FAA_CACHE_KEY, faaData, CACHE_TTL);
|
||||
console.log(` ${FAA_CACHE_KEY}: written`);
|
||||
await writeFreshnessMetadata('aviation', 'faa', faaData.alerts.length, 'faa-asws');
|
||||
|
||||
const verified1 = await verifySeedKey(FAA_CACHE_KEY);
|
||||
console.log(` FAA verified: ${verified1 ? 'yes' : 'NO'}`);
|
||||
|
||||
let notamCount = 0;
|
||||
if (notamData) {
|
||||
await redisSet(url, token, NOTAM_CACHE_KEY, notamData, CACHE_TTL);
|
||||
console.log(` ${NOTAM_CACHE_KEY}: written`);
|
||||
notamCount = notamData.closedIcaos.length;
|
||||
await writeFreshnessMetadata('aviation', 'notam', notamCount, 'icao-notam');
|
||||
|
||||
const verified2 = await verifySeedKey(NOTAM_CACHE_KEY);
|
||||
console.log(` NOTAM verified: ${verified2 ? 'yes' : 'NO'}`);
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startMs;
|
||||
logSeedResult('aviation', faaData.alerts.length + notamCount, durationMs);
|
||||
console.log(`\n=== Done (${Math.round(durationMs)}ms) ===`);
|
||||
} finally {
|
||||
await releaseLock('aviation:delays', runId);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`PUBLISH FAILED: ${err.message || err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user