better military icao detection, airlines and stock image indicator
This commit is contained in:
@@ -4,25 +4,29 @@ import { fileURLToPath } from 'url';
|
||||
import * as fs from 'node:fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { fromCsv } from '@ztimson/utils';
|
||||
import {getIcon} from './adsb-shapes.mjs';
|
||||
import { getIcon } from './adsb-shapes.mjs';
|
||||
import * as cheerio from 'cheerio';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA = resolve(DIR, '../data');
|
||||
const IMAGES_DIR = resolve(DATA, 'aircraft');
|
||||
const DB_PATH = resolve(DATA, 'aircraft.db');
|
||||
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
||||
const MAX_HISTORY = 500;
|
||||
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const DATA = resolve(DIR, '../data');
|
||||
const IMAGES_DIR = resolve(DATA, 'aircraft');
|
||||
const DB_PATH = resolve(DATA, 'aircraft.db');
|
||||
const CSV_CACHE = resolve(DATA, 'aircraft_db.csv');
|
||||
const AIRLINES_CACHE = resolve(DATA, 'airlines.dat');
|
||||
const MAX_HISTORY = 500;
|
||||
const HISTORY_TTL = 1000 * 60 * 60; // 1 hour
|
||||
|
||||
const ADSB_TTL = 1000;
|
||||
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
|
||||
const MIL_RANGES_URL = ':8080/db-3.14.1708/ranges.js';
|
||||
const CSV_URL = 'https://s3.opensky-network.org/data-samples/metadata/aircraft-database-complete-2024-06.csv';
|
||||
const AIRLINES_URL = 'https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat'
|
||||
|
||||
const MILITARY_OPERATORS = ['air force', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
|
||||
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
|
||||
const PASSENGER_OPERATORS = ['airlines', 'airways', 'air ', ' air', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
|
||||
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
||||
const MILITARY_OPERATORS = ['air force', 'airforce', 'army', 'navy', 'marine', 'coast guard', 'military', 'defence', 'defense', 'law', 'luftwaffe', 'RAF', 'USAF', 'USN', 'USMC'];
|
||||
const CARGO_OPERATORS = ['fedex', 'ups', 'dhl', 'cargo', 'freight', 'logistic', 'atlas air', 'kalitta', 'air freight'];
|
||||
const PASSENGER_OPERATOR_FALLBACKS = ['airlines', 'airways', 'jetblue', 'easyjet', 'ryanair', 'delta', 'united', 'american', 'avianca', 'southwest', 'lufthansa', 'emirates', 'british'];
|
||||
const MILITARY_CLASSES = ['H1M', 'H2M', 'L1M', 'L2M', 'L4M', 'A0'];
|
||||
|
||||
let passengerOperators = PASSENGER_OPERATOR_FALLBACKS;
|
||||
|
||||
let adsbCache = null;
|
||||
let adsbCacheTs = 0;
|
||||
@@ -31,10 +35,51 @@ const history = new Map();
|
||||
const milRanges = [];
|
||||
let db;
|
||||
|
||||
function normalizeAirlineName(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
async function syncAirlines() {
|
||||
try {
|
||||
if (!fs.existsSync(AIRLINES_CACHE)) {
|
||||
console.log('✈️ Downloading airline database');
|
||||
const res = await fetchWithTimeout(AIRLINES_URL, 10000);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
fs.writeFileSync(AIRLINES_CACHE, await res.text());
|
||||
}
|
||||
|
||||
const text = fs.readFileSync(AIRLINES_CACHE, 'utf8');
|
||||
const airlines = fromCsv(text, true);
|
||||
const names = new Set();
|
||||
for (const row of airlines) {
|
||||
if (row.active !== 'Y') continue;
|
||||
for (const value of [row.name, row.alias, row.callsign]) {
|
||||
const normalized = normalizeAirlineName(value);
|
||||
if (normalized.length >= 3) names.add(normalized);
|
||||
}
|
||||
}
|
||||
for (const value of PASSENGER_OPERATOR_FALLBACKS) names.add(normalizeAirlineName(value));
|
||||
passengerOperators = [...names].sort((a, b) => b.length - a.length);
|
||||
console.log(`✈️ Airline operators loaded: ${passengerOperators.length}`);
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Could not load airline database: ${e.message}`);
|
||||
passengerOperators = PASSENGER_OPERATOR_FALLBACKS.map(normalizeAirlineName);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - HISTORY_TTL;
|
||||
|
||||
for (const [key, trail] of history) {
|
||||
const last = trail.at(-1);
|
||||
|
||||
if (!last || last.ts < cutoff) history.delete(key);
|
||||
}
|
||||
}, 1000 * 60 * 5);
|
||||
@@ -48,16 +93,16 @@ function fetchWithTimeout(url, ms = 5000) {
|
||||
async function syncMilitaryRanges() {
|
||||
const { ADSB_URL } = cfg();
|
||||
try {
|
||||
const res = await fetch(ADSB_URL + MIL_RANGES_URL);
|
||||
const page = await fetch(ADSB_URL + ':8080').then(resp => resp.text());
|
||||
const db = /databaseFolder = "(db-.+?)"/.exec(page);
|
||||
const res = await fetch(ADSB_URL + `:8080/${db[1]}/ranges.js`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { military } = await res.json();
|
||||
|
||||
db.exec('DELETE FROM military_ranges');
|
||||
const insert = db.prepare('INSERT INTO military_ranges (start, end) VALUES (?, ?)');
|
||||
const insertMany = db.transaction(ranges => {
|
||||
for (const [s, e] of ranges) insert.run(s.toUpperCase(), e.toUpperCase());
|
||||
});
|
||||
|
||||
insertMany(military);
|
||||
milRanges.length = 0;
|
||||
milRanges.push(...military.map(([s, e]) => [s.toUpperCase(), e.toUpperCase()]));
|
||||
@@ -70,72 +115,81 @@ async function syncMilitaryRanges() {
|
||||
|
||||
export async function initAircraftDb() {
|
||||
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
|
||||
|
||||
if (!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
const missing = !fs.existsSync(DB_PATH);
|
||||
if (missing) console.log(`✈️ Building database`);
|
||||
|
||||
db = new Database(DB_PATH);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS aircraft (
|
||||
icao TEXT PRIMARY KEY,
|
||||
built TEXT,
|
||||
categoryDescription TEXT,
|
||||
country TEXT,
|
||||
engines TEXT,
|
||||
class TEXT,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
modes TEXT,
|
||||
operator TEXT,
|
||||
operatorCallsign TEXT,
|
||||
owner TEXT,
|
||||
registration TEXT,
|
||||
serialNumber TEXT,
|
||||
aircraft TEXT
|
||||
icao TEXT PRIMARY KEY,
|
||||
built TEXT,
|
||||
categoryDescription TEXT,
|
||||
country TEXT,
|
||||
engines TEXT,
|
||||
class TEXT,
|
||||
manufacturer TEXT,
|
||||
model TEXT,
|
||||
modes TEXT,
|
||||
operator TEXT,
|
||||
operatorCallsign TEXT,
|
||||
owner TEXT,
|
||||
registration TEXT,
|
||||
serialNumber TEXT,
|
||||
aircraft TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS military_ranges (
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS military_ranges (start TEXT NOT NULL, end TEXT NOT NULL);
|
||||
`);
|
||||
|
||||
await syncAirlines();
|
||||
await syncMilitaryRanges();
|
||||
|
||||
if (missing) {
|
||||
if(missing) {
|
||||
const res = await fetch(CSV_URL);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if(!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
fs.writeFileSync(CSV_CACHE, text);
|
||||
|
||||
const csv = fromCsv(text, true);
|
||||
const insert = db.prepare(`
|
||||
INSERT OR REPLACE INTO aircraft VALUES (
|
||||
@icao, @built, @categoryDescription, @country, @engines,
|
||||
@class, @manufacturer, @model, @modes, @operator,
|
||||
@operatorCallsign, @owner, @registration, @serialNumber, @aircraft
|
||||
@icao,
|
||||
@built,
|
||||
@categoryDescription,
|
||||
@country,
|
||||
@engines,
|
||||
@class,
|
||||
@manufacturer,
|
||||
@model,
|
||||
@modes,
|
||||
@operator,
|
||||
@operatorCallsign,
|
||||
@owner,
|
||||
@registration,
|
||||
@serialNumber,
|
||||
@aircraft
|
||||
)
|
||||
`);
|
||||
`);
|
||||
|
||||
const insertMany = db.transaction(rows => {
|
||||
for (const row of rows) insert.run(row);
|
||||
});
|
||||
|
||||
insertMany(csv.map(row => ({
|
||||
icao: (row.icao24 || '').toUpperCase(),
|
||||
built: row.built || null,
|
||||
icao: (row.icao24 || '').toUpperCase(),
|
||||
built: row.built || null,
|
||||
categoryDescription: row.categoryDescription || null,
|
||||
country: row.country || null,
|
||||
engines: row.engines || null,
|
||||
class: row.icaoAircraftClass || null,
|
||||
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
|
||||
model: row.model || null,
|
||||
modes: row.modes || null,
|
||||
operator: row.operator || null,
|
||||
operatorCallsign: row.operatorCallsign || null,
|
||||
owner: row.owner || null,
|
||||
registration: row.registration || null,
|
||||
serialNumber: row.serialNumber || null,
|
||||
aircraft: row.typecode || null,
|
||||
country: row.country || null,
|
||||
engines: row.engines || null,
|
||||
class: row.icaoAircraftClass || null,
|
||||
manufacturer: row.manufacturerName || row.manufacturerIcao || null,
|
||||
model: row.model || null,
|
||||
modes: row.modes || null,
|
||||
operator: row.operator || null,
|
||||
operatorCallsign: row.operatorCallsign || null,
|
||||
owner: row.owner || null,
|
||||
registration: row.registration || null,
|
||||
serialNumber: row.serialNumber || null,
|
||||
aircraft: row.typecode || null
|
||||
})));
|
||||
|
||||
fs.unlinkSync(CSV_CACHE);
|
||||
@@ -161,17 +215,26 @@ function wordMatch(text, keyword) {
|
||||
export function classifyAircraft(row) {
|
||||
if (!row) return 'unknown';
|
||||
|
||||
const matchesAny = (fields, list) => fields.some(f => list.some(k => f.toLowerCase().includes(k)));
|
||||
const matchesAny = (fields, list) => fields.some(f => {
|
||||
const value = normalizeAirlineName(f);
|
||||
return value && list.some(k => value.includes(normalizeAirlineName(k)));
|
||||
});
|
||||
|
||||
const operatorFields = [row.operator, row.operatorCallsign];
|
||||
const ownerFields = [row.owner];
|
||||
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
|
||||
const ownerFields = [row.owner];
|
||||
const allFields = [...operatorFields, ...ownerFields, row.categoryDescription];
|
||||
|
||||
if (isIcaoInMilitaryRange(row.icao) || MILITARY_CLASSES.includes(row.class) || matchesAny(allFields, MILITARY_OPERATORS)) return 'military';
|
||||
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) return 'cargo';
|
||||
if (matchesAny(operatorFields, PASSENGER_OPERATORS)) return 'passenger';
|
||||
if (isIcaoInMilitaryRange(row.icao) || MILITARY_CLASSES.includes(row.class) ||
|
||||
matchesAny(allFields, MILITARY_OPERATORS)) {
|
||||
return 'military';
|
||||
}
|
||||
|
||||
if (row.categoryDescription?.toLowerCase().includes('cargo') || matchesAny(operatorFields, CARGO_OPERATORS)) {
|
||||
return 'cargo';
|
||||
}
|
||||
|
||||
if (matchesAny(operatorFields, passengerOperators)) return 'passenger';
|
||||
if (row.owner && !row.operator) return 'private';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -182,9 +245,9 @@ async function fetchHexDb(icao) {
|
||||
return {
|
||||
registration: found.Registration || null,
|
||||
manufacturer: found.Manufacturer || null,
|
||||
aircraft: found.ICAOTypeCode || null,
|
||||
model: found.Type || null,
|
||||
operator: found.RegisteredOwners || null,
|
||||
aircraft: found.ICAOTypeCode || null,
|
||||
model: found.Type || null,
|
||||
operator: found.RegisteredOwners || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,118 +255,123 @@ async function scrapeHexDatabase(icao) {
|
||||
const url = `https://hexdatabase.com/h/${icao}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return null;
|
||||
|
||||
const html = await resp.text();
|
||||
const tableMatch = html.match(/<table[\s\S]*?<\/table>/i);
|
||||
if (!tableMatch) return null;
|
||||
|
||||
const $ = cheerio.load(tableMatch[0]);
|
||||
const row = $('tr').filter((_, el) => $(el).text().toLowerCase().includes(icao.toLowerCase())).first();
|
||||
if (!row.length) return null;
|
||||
|
||||
const cells = row.find('td');
|
||||
return {
|
||||
registration: $(cells[1]).text().trim() || null,
|
||||
aircraft: $(cells[2]).text().trim() || null,
|
||||
operator: $(cells[3]).text().trim() || null,
|
||||
serialNumber: $(cells[5]).text().trim() || null,
|
||||
aircraft: $(cells[2]).text().trim() || null,
|
||||
operator: $(cells[3]).text().trim() || null,
|
||||
serialNumber: $(cells[5]).text().trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
function backfillFromSimilar(aircraft) {
|
||||
if (!aircraft) return {};
|
||||
const similar = db.prepare(`
|
||||
SELECT manufacturer, model, engines, categoryDescription, class
|
||||
SELECT
|
||||
manufacturer,
|
||||
model,
|
||||
engines,
|
||||
categoryDescription,
|
||||
class
|
||||
FROM aircraft
|
||||
WHERE aircraft = ?
|
||||
AND (manufacturer IS NOT NULL OR model IS NOT NULL OR engines IS NOT NULL)
|
||||
LIMIT 1
|
||||
`).get(aircraft);
|
||||
AND (
|
||||
manufacturer IS NOT NULL
|
||||
OR model IS NOT NULL
|
||||
OR engines IS NOT NULL
|
||||
)
|
||||
LIMIT 1
|
||||
`).get(aircraft);
|
||||
if (!similar) return {};
|
||||
return {
|
||||
manufacturer: similar.manufacturer || null,
|
||||
model: similar.model || null,
|
||||
engines: similar.engines || null,
|
||||
manufacturer: similar.manufacturer || null,
|
||||
model: similar.model || null,
|
||||
engines: similar.engines || null,
|
||||
categoryDescription: similar.categoryDescription || null,
|
||||
class: similar.class || null,
|
||||
class: similar.class || null
|
||||
};
|
||||
}
|
||||
|
||||
export async function enrichAircraft(a) {
|
||||
if (!a.hex) return a;
|
||||
const icao = a.hex.toUpperCase();
|
||||
|
||||
// 1. Check own DB first
|
||||
const row = db.prepare('SELECT * FROM aircraft WHERE icao = ?').get(icao);
|
||||
if(row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
|
||||
|
||||
// 2. Race the two external sources
|
||||
if(noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||
if (row?.aircraft) return { ...a, ...row, type: classifyAircraft(row) };
|
||||
if (noRecord.includes(icao)) return { icao, ...a, type: 'unknown' };
|
||||
const hexDbPromise = fetchHexDb(icao);
|
||||
const scrapePromise = scrapeHexDatabase(icao);
|
||||
let found = await hexDbPromise.catch(() => {});
|
||||
if(!found) found = await scrapePromise.catch(() => {});
|
||||
if(!found) {
|
||||
if (!found) found = await scrapePromise.catch(() => {});
|
||||
if (!found) {
|
||||
noRecord.push(icao);
|
||||
return { icao, ...a, type: 'unknown' };
|
||||
}
|
||||
|
||||
// 3. Backfill manufacturer/model/engines from similar aircraft type in DB
|
||||
const merged = { ...row, ...found };
|
||||
if (merged.aircraft && (!merged.manufacturer || !merged.model)) {
|
||||
const similar = backfillFromSimilar(merged.aircraft);
|
||||
Object.assign(merged, similar);
|
||||
}
|
||||
|
||||
// 4. Save back to DB
|
||||
if (found) {
|
||||
if(found) {
|
||||
db.prepare(`
|
||||
INSERT INTO aircraft (icao, registration, manufacturer, aircraft, model, operator, country, serialNumber, engines, categoryDescription, class)
|
||||
VALUES (@icao, @registration, @manufacturer, @aircraft, @model, @operator, @country, @serialNumber, @engines, @categoryDescription, @class)
|
||||
ON CONFLICT(icao) DO UPDATE SET
|
||||
registration = COALESCE(excluded.registration, registration),
|
||||
manufacturer = COALESCE(excluded.manufacturer, manufacturer),
|
||||
aircraft = COALESCE(excluded.aircraft, aircraft),
|
||||
model = COALESCE(excluded.model, model),
|
||||
operator = COALESCE(excluded.operator, operator),
|
||||
country = COALESCE(excluded.country, country),
|
||||
serialNumber = COALESCE(excluded.serialNumber, serialNumber),
|
||||
engines = COALESCE(excluded.engines, engines),
|
||||
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
|
||||
class = COALESCE(excluded.class, class)
|
||||
`).run({
|
||||
ON CONFLICT(icao) DO UPDATE SET
|
||||
registration = COALESCE(excluded.registration, registration),
|
||||
manufacturer = COALESCE(excluded.manufacturer, manufacturer),
|
||||
aircraft = COALESCE(excluded.aircraft, aircraft),
|
||||
model = COALESCE(excluded.model, model),
|
||||
operator = COALESCE(excluded.operator, operator),
|
||||
country = COALESCE(excluded.country, country),
|
||||
serialNumber = COALESCE(excluded.serialNumber, serialNumber),
|
||||
engines = COALESCE(excluded.engines, engines),
|
||||
categoryDescription = COALESCE(excluded.categoryDescription, categoryDescription),
|
||||
class = COALESCE(excluded.class, class)
|
||||
`).run({
|
||||
icao,
|
||||
registration: merged.registration || null,
|
||||
manufacturer: merged.manufacturer || null,
|
||||
aircraft: merged.aircraft || null,
|
||||
model: merged.model || null,
|
||||
operator: merged.operator || null,
|
||||
country: merged.country || null,
|
||||
serialNumber: merged.serialNumber || null,
|
||||
engines: merged.engines || null,
|
||||
registration: merged.registration || null,
|
||||
manufacturer: merged.manufacturer || null,
|
||||
aircraft: merged.aircraft || null,
|
||||
model: merged.model || null,
|
||||
operator: merged.operator || null,
|
||||
country: merged.country || null,
|
||||
serialNumber: merged.serialNumber || null,
|
||||
engines: merged.engines || null,
|
||||
categoryDescription: merged.categoryDescription || null,
|
||||
class: merged.class || null,
|
||||
class: merged.class || null
|
||||
});
|
||||
}
|
||||
|
||||
return {icao, ...a, ...merged, type: classifyAircraft(merged) }
|
||||
return { icao, ...a, ...merged, type: classifyAircraft(merged) };
|
||||
}
|
||||
|
||||
export async function getADSB() {
|
||||
if (adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
|
||||
if(adsbCache && Date.now() - adsbCacheTs < ADSB_TTL) return adsbCache;
|
||||
|
||||
const { ADSB_URL } = cfg();
|
||||
if (!ADSB_URL) return [];
|
||||
if(!ADSB_URL) return [];
|
||||
|
||||
const r = await fetchWithTimeout(`${ADSB_URL}:8080/data/aircraft.json`);
|
||||
const j = await r.json();
|
||||
const aircraft = j.aircraft || [];
|
||||
|
||||
for (const a of aircraft) {
|
||||
a.type = 'unknown';
|
||||
if (!a.hex || !a.lat || !a.lon) continue;
|
||||
if(!a.hex || !a.lat || !a.lon) continue;
|
||||
const key = a.hex.toLowerCase();
|
||||
if (!history.has(key)) history.set(key, []);
|
||||
const trail = history.get(key);
|
||||
trail.push({ latitude: a.lat, longitude: a.lon, altitude: a.alt_baro || 0, ts: Date.now() });
|
||||
trail.push({
|
||||
latitude: a.lat,
|
||||
longitude: a.lon,
|
||||
altitude: a.alt_baro || 0,
|
||||
ts: Date.now()
|
||||
});
|
||||
if (trail.length > MAX_HISTORY) trail.shift();
|
||||
}
|
||||
|
||||
@@ -334,7 +402,7 @@ export async function getADSBImage(icao) {
|
||||
const builds = [
|
||||
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
|
||||
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
|
||||
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`,
|
||||
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.${v} (KHTML, like Gecko) Chrome/1${10 + ~~(Math.random() * 16)}.0.0.0 Safari/537.${v}`
|
||||
];
|
||||
return builds[~~(Math.random() * builds.length)];
|
||||
}
|
||||
@@ -344,6 +412,8 @@ export async function getADSBImage(icao) {
|
||||
const res = await fetch(`https://duckduckgo.com/?q=${encodeURIComponent(query)}`, {
|
||||
headers: { 'User-Agent': randomUA() }
|
||||
});
|
||||
|
||||
if(!res.ok) console.warn(res.status, res.statusText);
|
||||
const html = await res.text();
|
||||
const match = html.match(/vqd=['"]?([\d-]+)['"]?/);
|
||||
return match?.[1];
|
||||
@@ -354,8 +424,9 @@ export async function getADSBImage(icao) {
|
||||
|
||||
const url = `https://duckduckgo.com/i.js?q=${encodeURIComponent(query)}&o=json&vqd=${vqd}&f=,,,,,&p=1`;
|
||||
const data = await fetch(url, {
|
||||
headers: { 'User-Agent': randomUA(), 'Referer': 'https://duckduckgo.com/' }
|
||||
headers: {'User-Agent': randomUA(), 'Referer': 'https://duckduckgo.com/'}
|
||||
}).then(resp => resp.json());
|
||||
if(!data.ok) console.warn(data.status, data.statusText);
|
||||
|
||||
if (data?.results?.length) {
|
||||
for (const result of data.results) {
|
||||
@@ -363,9 +434,10 @@ export async function getADSBImage(icao) {
|
||||
try {
|
||||
const imgRes = await fetch(result.image);
|
||||
if (imgRes.ok) return imgRes.blob();
|
||||
} catch { }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -373,30 +445,41 @@ export async function getADSBImage(icao) {
|
||||
if (!modelType) return null;
|
||||
const filePath = resolve(IMAGES_DIR, 'generic', `${modelType}.jpg`);
|
||||
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
|
||||
|
||||
const blob = await duckduckgo(`${modelType} Aircraft`);
|
||||
if (!blob) return null;
|
||||
|
||||
fs.mkdirSync(dirname(filePath), { recursive: true });
|
||||
const buffer = Buffer.from(await blob.arrayBuffer());
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
return buffer;
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
const width = metadata.width || 1200;
|
||||
const height = metadata.height || 800;
|
||||
const fontSize = Math.max(48, Math.round(Math.min(width, height) * 0.16));
|
||||
const watermark = Buffer.from(`
|
||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
|
||||
font-family="Arial, Helvetica, sans-serif" font-size="${fontSize}px"
|
||||
font-weight="700" fill="white" fill-opacity="0.65"
|
||||
stroke="black" stroke-opacity="0.35" stroke-width="${Math.max(2, Math.round(fontSize * 0.04))}"
|
||||
transform="rotate(-20 ${width / 2} ${height / 2})">STOCK IMAGE</text>
|
||||
</svg>`);
|
||||
const watermarked = await sharp(buffer).composite([{ input: watermark }]).jpeg().toBuffer();
|
||||
fs.writeFileSync(filePath, watermarked);
|
||||
return watermarked;
|
||||
}
|
||||
|
||||
async function icaoImage(icao) {
|
||||
const filePath = resolve(IMAGES_DIR, `${icao}.jpg`);
|
||||
if (fs.existsSync(filePath)) return fs.readFileSync(filePath);
|
||||
|
||||
if(fs.existsSync(filePath)) return fs.readFileSync(filePath);
|
||||
const planeSpotters = await fetch(`https://api.planespotters.net/pub/photos/hex/${icao}`, {
|
||||
headers: { 'User-Agent': 'open-sight.net (zaktimson@gmail.com)' }
|
||||
}).then(resp => resp.ok ? resp.json() : null).catch(() => null);
|
||||
|
||||
const src = planeSpotters?.photos?.[0]?.thumbnail_large?.src || planeSpotters?.photos?.[0]?.thumbnail?.src;
|
||||
if (!src) return null;
|
||||
|
||||
const resp = await fetch(src, { headers: { 'Accept': '*/*', 'User-Agent': randomUA() } });
|
||||
if (!resp.ok) return null;
|
||||
if(!src) return null;
|
||||
const resp = await fetch(src, {
|
||||
headers: {'Accept': '*/*', 'User-Agent': randomUA()}
|
||||
});
|
||||
|
||||
if(!resp.ok) return null;
|
||||
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
const buffer = Buffer.from(await resp.arrayBuffer());
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
@@ -404,9 +487,9 @@ export async function getADSBImage(icao) {
|
||||
}
|
||||
|
||||
icao = icao.toLowerCase();
|
||||
if(!fs.existsSync(IMAGES_DIR)) fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
||||
const aircraft = await enrichAircraft({ hex: icao });
|
||||
const modelType = aircraft.aircraft || aircraft.model || aircraft.type;
|
||||
|
||||
const generic = genericImage(modelType);
|
||||
const specific = await icaoImage(icao);
|
||||
return specific || await generic;
|
||||
|
||||
@@ -33,20 +33,17 @@ export async function getSondes() {
|
||||
altitude: t.alt,
|
||||
heading: t.heading,
|
||||
speed: t.vel_h,
|
||||
vertical_speed: t.vel_v,
|
||||
sats: t.sats,
|
||||
timestamp: t.datetime ? Date.parse(t.datetime) : sonde.timestamp * 1000,
|
||||
datetime: t.datetime,
|
||||
climb: t.vel_v,
|
||||
gps: t.sats,
|
||||
timestamp: t.datetime ? Date.parse(t.datetime) : new Date(sonde.timestamp * 1000),
|
||||
frame: t.frame,
|
||||
battery: t.batt,
|
||||
temperature: t.temp,
|
||||
humidity: t.humidity,
|
||||
pressure: t.pressure,
|
||||
frequency: t.freq_float,
|
||||
frequency_hz: t.tx_frequency,
|
||||
snr: t.snr,
|
||||
ppm: t.ppm,
|
||||
aprsid: t.aprsid?.trim(),
|
||||
freqMHz: t.tx_frequency / 1000,
|
||||
snr: t.snr,
|
||||
rs41_mainboard: t.rs41_mainboard,
|
||||
rs41_mainboard_fw: t.rs41_mainboard_fw,
|
||||
version: t.version,
|
||||
|
||||
Reference in New Issue
Block a user