Files
worldmonitor/scripts/seed-fire-detections.mjs
Elie Habib 9211339d1c fix(seeds): prevent API quota burn and respect rate limits (#1167)
* fix(cyber): prevent AbuseIPDB quota burn when Redis rate check fails

The catch block in fetchAbuseIpDb() was falling through to the API call
when the Redis rate-limit check failed (e.g. Redis down, first run with
no key). With a 10-minute cron interval, this could exhaust the 100
calls/day free-plan limit in under 17 hours.

Now returns early with { ok: false, threats: [] } so the other 4 IOC
sources still seed normally while AbuseIPDB is safely skipped.

* fix(seeds): respect API rate limits and log fetch failures

1. seed-fire-detections.mjs: increase delay from 200ms to 6s between
   FIRMS API calls. Free tier allows 10 req/min; 27 calls at 200ms
   exceeded this and caused silent failures.

2. ais-relay.cjs (positive events): increase GDELT delay from 500ms to
   5.5s to respect the documented 1 req/5s rate limit.

3. ais-relay.cjs (cyber fetchers): replace 5 silent `catch { return [] }`
   blocks with `console.warn` logging so failures are visible in Railway
   logs. Dead code today (cyber loop disabled) but sets the right example
   for contributors.

* fix(seeds): extend FIRMS lock TTL and restore AbuseIPDB resilience

P1: seed-fire-detections.mjs — the 6s FIRMS pacing makes the job take
~162s minimum, exceeding the default 120s lock TTL. Extend lockTtlMs
to 300s (5 min) to prevent overlapping cron invocations.

P2: seed-cyber-threats.mjs — revert the early return on Redis rate-check
failure. A transient Redis blip should not permanently disable AbuseIPDB
for that run. Instead, log a warning and proceed with caution. The 2h
rate-limit interval + 10-min cron means at most 1 extra call per Redis
outage window, well within the 100/day budget.

* fix(wildfire): extend lock TTL to 10 min for worst-case FIRMS timeouts

27 calls × (6s pacing + 30s per-request timeout) = 972s worst case.
300s lock was still too short under partial upstream slowness.
2026-03-07 10:51:45 +04:00

129 lines
4.3 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { loadEnvFile, maskToken, runSeed, CHROME_UA, sleep } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'wildfire:fires:v1';
const FIRMS_SOURCES = ['VIIRS_SNPP_NRT', 'VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT'];
const MONITORED_REGIONS = {
'Ukraine': '22,44,40,53',
'Russia': '20,50,180,82',
'Iran': '44,25,63,40',
'Israel/Gaza': '34,29,36,34',
'Syria': '35,32,42,37',
'Taiwan': '119,21,123,26',
'North Korea': '124,37,131,43',
'Saudi Arabia': '34,16,56,32',
'Turkey': '26,36,45,42',
};
function mapConfidence(c) {
switch ((c || '').toLowerCase()) {
case 'h': return 'FIRE_CONFIDENCE_HIGH';
case 'n': return 'FIRE_CONFIDENCE_NOMINAL';
case 'l': return 'FIRE_CONFIDENCE_LOW';
default: return 'FIRE_CONFIDENCE_UNSPECIFIED';
}
}
function parseCSV(csv) {
const lines = csv.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map(h => h.trim());
const results = [];
for (let i = 1; i < lines.length; i++) {
const vals = lines[i].split(',').map(v => v.trim());
if (vals.length < headers.length) continue;
const row = {};
headers.forEach((h, idx) => { row[h] = vals[idx]; });
results.push(row);
}
return results;
}
function parseDetectedAt(acqDate, acqTime) {
const padded = (acqTime || '').padStart(4, '0');
const hours = padded.slice(0, 2);
const minutes = padded.slice(2);
return new Date(`${acqDate}T${hours}:${minutes}:00Z`).getTime();
}
async function fetchRegionSource(apiKey, regionName, bbox, source) {
const url = `https://firms.modaps.eosdis.nasa.gov/api/area/csv/${apiKey}/${source}/${bbox}/1`;
const res = await fetch(url, {
headers: { Accept: 'text/csv', 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`FIRMS ${res.status} for ${regionName}/${source}`);
const csv = await res.text();
return parseCSV(csv);
}
async function fetchAllRegions(apiKey) {
const entries = Object.entries(MONITORED_REGIONS);
const seen = new Set();
const fireDetections = [];
let fulfilled = 0;
let failed = 0;
for (const source of FIRMS_SOURCES) {
for (const [regionName, bbox] of entries) {
try {
const rows = await fetchRegionSource(apiKey, regionName, bbox, source);
fulfilled++;
for (const row of rows) {
const id = `${row.latitude ?? ''}-${row.longitude ?? ''}-${row.acq_date ?? ''}-${row.acq_time ?? ''}`;
if (seen.has(id)) continue;
seen.add(id);
const detectedAt = parseDetectedAt(row.acq_date || '', row.acq_time || '');
fireDetections.push({
id,
location: {
latitude: parseFloat(row.latitude ?? '0') || 0,
longitude: parseFloat(row.longitude ?? '0') || 0,
},
brightness: parseFloat(row.bright_ti4 ?? '0') || 0,
frp: parseFloat(row.frp ?? '0') || 0,
confidence: mapConfidence(row.confidence || ''),
satellite: row.satellite || '',
detectedAt,
region: regionName,
dayNight: row.daynight || '',
});
}
} catch (err) {
failed++;
console.error(` [FIRMS] ${source}/${regionName}: ${err.message || err}`);
}
await sleep(6_000); // FIRMS free tier: 10 req/min — 6s between calls stays safely under limit
}
console.log(` ${source}: ${fireDetections.length} total (${fulfilled} ok, ${failed} failed)`);
}
return { fireDetections, pagination: undefined };
}
async function main() {
const apiKey = process.env.NASA_FIRMS_API_KEY || process.env.FIRMS_API_KEY || '';
if (!apiKey) {
console.log('NASA_FIRMS_API_KEY not set — skipping fire detections seed');
process.exit(0);
}
console.log(` FIRMS key: ${maskToken(apiKey)}`);
await runSeed('wildfire', 'fires', CANONICAL_KEY, () => fetchAllRegions(apiKey), {
validateFn: (data) => Array.isArray(data?.fireDetections) && data.fireDetections.length > 0,
ttlSeconds: 7200,
lockTtlMs: 600_000, // 10 min — 27 calls × (6s pace + up to 30s timeout) can exceed 5 min under partial slowness
sourceVersion: FIRMS_SOURCES.join('+'),
});
}
main().catch(err => {
console.error('FATAL:', err.message || err);
process.exit(1);
});