mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
feat(flight-live): follow booked flights live from keyless ADS-B feeds
A new flight-live domain polls the open feeds adsb.lol and adsb.fi (no key, no cost; adsb.fi only on self-hosted installs, its terms are non-commercial) for every booked flight leg in its watch window, derives phase, take-off, landing, delay and an arrival estimate, and pushes changes as flight:live. GET /api/trips/:tripId/flights/live, the MCP tool get_trip_flight_status, notifications for take-off, landing and delays, and the admin switch flight_live_enabled (FLIGHT_LIVE_ENABLED wins) come with it.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
// Build server/assets/airlines-icao.json, the IATA airline code -> ICAO callsign prefix table the
|
||||
// live flight status uses to turn a booked flight number ("LH 716") into the callsign an aircraft
|
||||
// transmits ("DLH716").
|
||||
//
|
||||
// Source: VRS standing data (github.com/vradarserver/standing-data, CC0): airlines/schema-01 for
|
||||
// the codes and the file list of routes/schema-01 for which airline still flies.
|
||||
//
|
||||
// Only airlines with both codes are kept. IATA reuses its two-character codes, so one IATA code
|
||||
// often names several ICAO airlines (LH: Lufthansa and Lufthansa Cargo; U2: easyJet and United
|
||||
// Feeder Service). The airline list carries no "active" flag, so the rule is: the ICAO airline
|
||||
// with the most route data in the standing data wins (the summed size of its routes files), since
|
||||
// routes are only recorded for callsigns seen flying. A shared code where no candidate has any
|
||||
// route data is left out rather than guessed; a booking with it is then simply not tracked.
|
||||
import fs from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = path.join(__dirname, '..', 'assets', 'airlines-icao.json');
|
||||
const AIRLINES = 'https://raw.githubusercontent.com/vradarserver/standing-data/main/airlines/schema-01/airlines.csv';
|
||||
const TREE = 'https://api.github.com/repos/vradarserver/standing-data/git/trees/main?recursive=1';
|
||||
|
||||
function fetchText(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, { headers: { 'User-Agent': 'trek-build-airlines', Accept: '*/*' } }, (res) => {
|
||||
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => resolve(data));
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** One CSV line into its fields: optional double quotes, "" as an escaped quote. */
|
||||
function parseLine(line) {
|
||||
const fields = [];
|
||||
let cur = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"' && line[i + 1] === '"') {
|
||||
cur += '"';
|
||||
i++;
|
||||
} else if (ch === '"') inQuotes = false;
|
||||
else cur += ch;
|
||||
} else if (ch === '"') inQuotes = true;
|
||||
else if (ch === ',') {
|
||||
fields.push(cur);
|
||||
cur = '';
|
||||
} else cur += ch;
|
||||
}
|
||||
fields.push(cur);
|
||||
return fields;
|
||||
}
|
||||
|
||||
const csv = (await fetchText(AIRLINES)).replace(/^/, '');
|
||||
const tree = JSON.parse(await fetchText(TREE));
|
||||
if (tree.truncated) throw new Error('The standing-data tree came back truncated; the route sizes would be incomplete');
|
||||
|
||||
// Bytes of route data per ICAO code: routes/schema-01/<letter>/<CODE>-all.csv or <CODE>-<digit>.csv.
|
||||
const routeBytes = new Map();
|
||||
for (const entry of tree.tree) {
|
||||
const match = /^routes\/schema-01\/[A-Z0-9]\/([A-Z]{3})-(?:all|\d)\.csv$/.exec(entry.path);
|
||||
if (!match) continue;
|
||||
routeBytes.set(match[1], (routeBytes.get(match[1]) ?? 0) + (entry.size ?? 0));
|
||||
}
|
||||
|
||||
const rows = csv.split(/\r?\n/);
|
||||
const header = parseLine(rows[0]);
|
||||
const ICAO = header.indexOf('ICAO');
|
||||
const IATA = header.indexOf('IATA');
|
||||
const candidates = new Map();
|
||||
for (const line of rows.slice(1)) {
|
||||
if (!line.trim()) continue;
|
||||
const fields = parseLine(line);
|
||||
const icao = fields[ICAO]?.trim().toUpperCase();
|
||||
const iata = fields[IATA]?.trim().toUpperCase();
|
||||
if (!icao || !iata || !/^[A-Z]{3}$/.test(icao) || !/^(?=.*[A-Z])[A-Z0-9]{2}$/.test(iata)) continue;
|
||||
const list = candidates.get(iata) ?? [];
|
||||
if (!list.includes(icao)) list.push(icao);
|
||||
candidates.set(iata, list);
|
||||
}
|
||||
|
||||
const table = {};
|
||||
let shared = 0;
|
||||
let dropped = 0;
|
||||
for (const iata of [...candidates.keys()].sort()) {
|
||||
const list = candidates.get(iata);
|
||||
if (list.length === 1) {
|
||||
table[iata] = list[0];
|
||||
continue;
|
||||
}
|
||||
shared++;
|
||||
const ranked = list
|
||||
.map((icao) => ({ icao, bytes: routeBytes.get(icao) ?? 0 }))
|
||||
.sort((a, b) => b.bytes - a.bytes || a.icao.localeCompare(b.icao));
|
||||
if (ranked[0].bytes === 0) {
|
||||
dropped++;
|
||||
continue;
|
||||
}
|
||||
table[iata] = ranked[0].icao;
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUT, JSON.stringify(table));
|
||||
console.log(
|
||||
`Wrote ${Object.keys(table).length} airline codes to ${OUT}; ${shared} shared IATA codes resolved by route data, ${dropped} left out without any`,
|
||||
);
|
||||
@@ -40,6 +40,7 @@
|
||||
"DocumentSyncItems": "doc-sync",
|
||||
"FerrataTiles": "ferratas",
|
||||
"FileLinks": "files",
|
||||
"FlightLiveStates": "flight-live",
|
||||
"GoogleApiUsage": "google-quota",
|
||||
"GooglePlacePhotoMeta": "place-photos",
|
||||
"HiddenCountries": "atlas",
|
||||
|
||||
Reference in New Issue
Block a user