Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
39ab7c0
fix: source getAirports() from the airports JSON feed
JeanExtreme002 Aug 12, 2026
d2e629e
fix: address review findings on the airports feed migration
JeanExtreme002 Aug 12, 2026
c4965ed
fix: keep numeric coercion strict and identical across both ports
JeanExtreme002 Aug 12, 2026
16470a2
fix: reject overflowing exponents and parse raw-byte feed bodies
JeanExtreme002 Aug 12, 2026
762baa0
test: hold both ports to one shared table of numeric coercion cases
JeanExtreme002 Aug 12, 2026
9c1b261
test: trim the coercion table back into each port
JeanExtreme002 Aug 12, 2026
47ae32f
fix: stop _to_number from raising on unbounded JSON integers
JeanExtreme002 Aug 12, 2026
a1dc3b9
fix: keep airport text fields as strings in both ports
JeanExtreme002 Aug 12, 2026
85c3839
test: pin the Countries enum against the feed's country names
JeanExtreme002 Aug 13, 2026
9f62dad
fix: close the remaining port divergences in coordinate handling
JeanExtreme002 Aug 13, 2026
90a9ff7
docs: cut the comment volume across the PR
JeanExtreme002 Aug 13, 2026
1a157c8
fix: drop non-object feed rows in the Node port too
JeanExtreme002 Aug 13, 2026
0593a1f
fix: accept any iterable of countries and stop slugifying unused rows
JeanExtreme002 Aug 13, 2026
38104c3
fix: make the "any iterable" contract true and unwrap Countries in th…
JeanExtreme002 Aug 13, 2026
a357b0f
fix: unwrap only real enum members in country_to_slug
JeanExtreme002 Aug 13, 2026
af2b525
ci: stop live FR24 tests from blocking pull requests
JeanExtreme002 Aug 13, 2026
92a4b33
Revert "ci: stop live FR24 tests from blocking pull requests" (workfl…
JeanExtreme002 Aug 13, 2026
053c9e8
chore: release 1.5.3
JeanExtreme002 Aug 13, 2026
a52c7dd
fix: stop the decode handler from raising and skip empty flag lookups
JeanExtreme002 Aug 13, 2026
4bf126a
fix: delete punctuation before hyphenating slugs and widen the getAir…
JeanExtreme002 Aug 13, 2026
fbfde59
Revert the slug punctuation change: it broke a working flag lookup
JeanExtreme002 Aug 13, 2026
7ea46e9
docs: name every case where _to_number's type differs from Node
JeanExtreme002 Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 30 additions & 15 deletions nodejs/FlightRadarAPI/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const Flight = require("./entities/flight");
const FlightTrackerConfig = require("./flightTrackerConfig");
const { AirportNotFoundError, LoginError } = require("./errors");
const { isNumeric, radians, rad2deg } = require("./util");
const { parseAirlinesHtml, parseAirportsHtml } = require("./parsers");
const { parseAirlinesHtml, parseAirportsJson, countryToSlug } = require("./parsers");


/**
Expand Down Expand Up @@ -198,23 +198,34 @@ class FlightRadar24API {
}

/**
* Return a list with all airports for specified countries.
* Return a list with all airports, optionally narrowed to some countries.
*
* @param {Array<string>} countries - Array of country names from Countries enum
* @param {Iterable<string>|string} [countries] - Country names from the Countries enum,
* as any iterable or a single value; every country when omitted
* @return {Promise<Array<Airport>>}
*/
async getAirports(countries) {
const airports = [];
// Use stateless requests for the fan-out so per-country `Set-Cookie`
// responses do not race onto the shared session jar.
await mapConcurrent(countries, this.maxWorkers, async (countryName) => {
const countryHref = Core.airportsDataUrl + "/" + countryName;
const { content } = await this.__client.requestStandalone(
countryHref, { headers: Core.htmlHeaders, timeout: this.timeout },
);
airports.push(...parseAirportsHtml(content, countryHref));
});
return airports;
// Any iterable, matching the Python port. Array.from would turn a
// non-iterable into [], answering a mistake with an empty result where
// Python raises.
let wanted = null;

if (typeof countries === "string") {
wanted = [countries];
}
else if (countries != null) {
if (typeof countries[Symbol.iterator] !== "function") {
throw new TypeError("getAirports expects a country name or an iterable of country names.");
}
wanted = Array.from(countries);
}

if (wanted && wanted.length === 0) return [];

const { content } = await this.__client.request(
Core.airportsJsonUrl, { headers: Core.jsonHeaders, timeout: this.timeout },
);
return parseAirportsJson(content, wanted);
}

/**
Expand Down Expand Up @@ -315,7 +326,11 @@ class FlightRadar24API {
* @return {Promise<[object, string] | null>}
*/
async getCountryFlag(country) {
const slug = country.toLowerCase().replaceAll(" ", "-");
// Same slugifier as the feed, which spells some names "Myanmar (Burma)".
const slug = countryToSlug(country);

if (!slug) return null;

const flagUrl = Core.countryFlagUrl(slug);

const headers = { ...Core.imageHeaders };
Expand Down
2 changes: 2 additions & 0 deletions nodejs/FlightRadarAPI/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const Core = {
apiAirportDataUrl: `${API_FR24_BASE}/airport.json`,
airportDataUrl: (code) => `${FR24_BASE}/airports/traffic-stats/?airport=${code}`,
airportsDataUrl: `${FR24_BASE}/data/airports`,
// Every airport in one payload; the per-country pages above are client-rendered.
airportsJsonUrl: `${FR24_BASE}/_json/airports.php`,

airlinesDataUrl: `${FR24_BASE}/data/airlines`,

Expand Down
13 changes: 7 additions & 6 deletions nodejs/FlightRadarAPI/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class APIClient {
request(url: string, options?: object): Promise<{content: any; statusCode: number; cookies: Record<string, string>}>;
/**
* Make a stateless request that bypasses the shared cookie jar. Safe to
* call from concurrent fan-outs (e.g. `getAirports`).
* call from concurrent fan-outs.
*/
requestStandalone(url: string, options?: object): Promise<{content: any; statusCode: number; cookies: Record<string, string>}>;
getCookie(name: string): string | undefined;
Expand Down Expand Up @@ -114,11 +114,12 @@ export class FlightRadar24API {
getAirportDisruptions(): Promise<object>;

/**
* Return a list with all airports for specified countries.
* Return a list with all airports, optionally narrowed to some countries.
*
* @param {string[]} countries - Array of country names
* @param {Iterable<string>|string} [countries] - Country names, as any iterable
* or a single value; every country when omitted
*/
getAirports(countries: string[]): Promise<Airport[]>;
getAirports(countries?: Iterable<string> | string): Promise<Airport[]>;

/**
* Return the bookmarks from the FlightRadar24 account.
Expand Down Expand Up @@ -311,8 +312,8 @@ export class Entity {
* Airport representation.
*/
export class Airport extends Entity {
latitude: number;
longitude: number;
latitude: number | null;
longitude: number | null;
altitude: number | null;
name: string;
icao: string;
Expand Down
200 changes: 137 additions & 63 deletions nodejs/FlightRadarAPI/parsers.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,89 +71,163 @@ function parseAirlinesHtml(html) {
}

/**
* Parse the airports listing HTML page for a country into a list of Airport instances.
* Slugify a country name the way FR24 spells it in its data page URLs, so feed
* rows can be matched against the Countries enum.
*
* @param {string|Buffer} html
* @param {string} countryHref - Full URL used to fetch the page (used to derive the display name)
* @return {Array<Airport>}
* @param {string} country - Country name as spelled by FR24 (e.g. "United States")
* @return {string} URL-friendly form matching the Countries enum (e.g. "united-states")
*/
function parseAirportsHtml(html, countryHref) {
const root = parse(typeof html === "string" ? html : html.toString());
const tbody = root.querySelector("tbody");
function countryToSlug(country) {
// Diacritics stripped so a future "Curaçao" still matches "curacao".
// Punctuation becomes a hyphen rather than being deleted: FR24's own assets
// are named that way, e.g. flags-small/cote-d-ivoire.svg.
return String(country ?? "")
.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}

if (!tbody) {
console.warn(`parseAirportsHtml: no <tbody> for ${countryHref} — FR24 page layout may have changed.`);
return [];
}
// ASCII decimals only, so both ports accept and reject the same strings.
const NUMERIC_PATTERN = /^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$/;

const countryDisplayName = countryHref.split("/").pop().replace(/-/g, " ")
.split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
// trim() and str.strip() disagree on U+FEFF and U+001C-U+001F: pick one set.
const SURROUNDING_SPACE = /^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g;

const airports = [];
/**
* Coerce a numeric feed field into a number, or null when it is unusable.
*
* An unusable coordinate must not become 0: that would place the airport in the
* Gulf of Guinea instead of marking its position as unknown.
*
* @param {*} value
* @return {number|null}
*/
function toNumber(value) {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
// Not stringified: String([43]) would pass as 43.
if (typeof value !== "string") return null;

for (const tr of tbody.querySelectorAll("tr")) {
const aElement = tr.querySelector("a[data-iata][data-lat][data-lon]");
const text = value.replace(SURROUNDING_SPACE, "");

if (!aElement) continue;
return NUMERIC_PATTERN.test(text) ? toNumber(Number(text)) : null;
}

/**
* Keep a text field as a string, or "" when the feed sends anything else.
*
* Airport declares these as strings, and callers treat them as such —
* `getCountryFlag(airport.country)` would happily slugify an object into
* "object-object" and request that flag.
*
* @param {*} value
* @return {string}
*/
function toStringField(value) {
return typeof value === "string" ? value : "";
}

/**
* Decode a response body into text, or null when it arrived already parsed.
*
* `request()` returns an ArrayBuffer whenever the response carries an
* unexpected content-type, so a perfectly good JSON body can land here as raw
* bytes rather than as an object.
*
* @param {*} payload
* @return {string|null}
*/
function toText(payload) {
if (typeof payload === "string") return payload;
if (Buffer.isBuffer(payload)) return payload.toString();
if (payload instanceof ArrayBuffer) return Buffer.from(payload).toString();
if (ArrayBuffer.isView(payload)) return Buffer.from(payload.buffer, payload.byteOffset, payload.byteLength).toString();
return null;
}

let icao = "";
let iata = aElement.getAttribute("data-iata") || "";
const latitude = aElement.getAttribute("data-lat") || "";
const longitude = aElement.getAttribute("data-lon") || "";
let namePart = aElement.text.trim();
/**
* Parse the airports JSON feed into a list of Airport instances.
*
* @param {object|string|Buffer} payload - Body of Core.airportsJsonUrl
* @param {Array<string>} [countries] - Country slugs from the Countries enum; every airport is kept when omitted
* @return {Array<Airport>}
*/
function parseAirportsJson(payload, countries = null) {
let data = payload;
const text = toText(payload);

const smallElement = aElement.querySelector("small");
if (text !== null) {
try {
data = JSON.parse(text);
}
catch {
console.warn("parseAirportsJson: response is not valid JSON — FR24 feed may have changed.");
return [];
}
}

if (smallElement) {
const smallText = smallElement.text.trim();
const codesText = smallText.replace(/^\(/, "").replace(/\)$/, "").trim();
namePart = namePart.replace(smallText, "").replace(/\(\)/, "").trim();
const rows = data && Array.isArray(data["rows"]) ? data["rows"] : null;

if (codesText.includes("/")) {
const codes = codesText.split("/");
const code1 = codes[0].trim();
const code2 = codes[1].trim();
if (!rows) {
console.warn("parseAirportsJson: no \"rows\" array in response — FR24 feed may have changed.");
return [];
}

if (code1.length === 3 && code2.length === 4) {
iata = code1;
icao = code2;
}
else if (code1.length === 4 && code2.length === 3) {
iata = code2;
icao = code1;
}
}
else if (codesText.length === 3) {
iata = codesText;
}
else if (codesText.length === 4) {
icao = codesText;
}
const wanted = countries ? new Set(countries.map(countryToSlug)) : null;
const matched = new Set();
const unpositioned = [];
const airports = [];

for (const row of rows) {
// Arrays are objects in JS; Python's isinstance(row, dict) drops them.
if (!row || typeof row !== "object" || Array.isArray(row)) continue;

// Slugified only when filtering: it is otherwise unused, and this loop
// runs over every airport in the feed.
if (wanted) {
const slug = countryToSlug(row["country"]);

if (!wanted.has(slug)) continue;
matched.add(slug);
}

let latNum = latitude ? parseFloat(latitude) : null;
let lonNum = longitude ? parseFloat(longitude) : null;
if (Number.isNaN(latNum) || Number.isNaN(lonNum)) {
console.warn(
`parseAirportsHtml: invalid coordinates for airport "${namePart}" ` +
`(lat=${latitude}, lon=${longitude}) — skipping position.`,
);
latNum = null;
lonNum = null;
let latitude = toNumber(row["lat"]);
let longitude = toNumber(row["lon"]);

// One bad coordinate drops both: half a position reads as located.
if (latitude === null || longitude === null) {
latitude = null;
longitude = null;
unpositioned.push(toStringField(row["name"]));
}

airports.push(new Airport({
"name": namePart,
"icao": icao,
"iata": iata,
"lat": latNum,
"lon": lonNum,
"alt": null,
"country": countryDisplayName,
"name": toStringField(row["name"]),
"icao": toStringField(row["icao"]),
"iata": toStringField(row["iata"]),
"lat": latitude,
"lon": longitude,
"alt": toNumber(row["alt"]),
"country": toStringField(row["country"]),
}));
}

// One line, not one per row: the feed carries every airport.
if (unpositioned.length > 0) {
const sample = unpositioned.slice(0, 3).map((name) => `"${name}"`).join(", ");
console.warn(
`parseAirportsJson: ${unpositioned.length} airport(s) had unusable coordinates ` +
`and carry no position (e.g. ${sample}).`,
);
}

if (wanted) {
const missing = [...wanted].filter((slug) => !matched.has(slug));

if (missing.length > 0) {
console.warn(`parseAirportsJson: no airports found for ${missing.join(", ")} — check the Countries enum.`);
}
}

return airports;
}

module.exports = { parseAirlinesHtml, parseAirportsHtml };
module.exports = { parseAirlinesHtml, parseAirportsJson, countryToSlug };
6 changes: 3 additions & 3 deletions nodejs/FlightRadarAPI/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -364,9 +364,9 @@ class APIClient {
/**
* Make a stateless request that does not touch the shared cookie jar.
*
* Safe to call from concurrent fan-outs (e.g. `getAirports` issuing one
* request per country). The TLS dispatcher is still reused so the
* impersonation profile stays consistent with the session.
* Safe to call from concurrent fan-outs, where per-response `Set-Cookie`
* headers would otherwise race onto the shared jar. The TLS dispatcher is
* still reused so the impersonation profile stays consistent with the session.
*
* @param {string} url
* @param {object} [options={}]
Expand Down
5 changes: 4 additions & 1 deletion nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ const frApi = new FlightRadar24API();
let flights = await frApi.getFlights(...); // Returns a list of Flight objects
```

**Getting airports list (requires country selection):**
**Getting airports list:**
```javascript
// Get airports from specific countries
let airports = await frApi.getAirports([Countries.BRAZIL, Countries.UNITED_STATES]); // Returns a list of Airport objects

// Omit the countries to get every airport
let allAirports = await frApi.getAirports();
```

**Getting airlines list:**
Expand Down
4 changes: 2 additions & 2 deletions nodejs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion nodejs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "flightradarapi",
"version": "1.5.2",
"version": "1.5.3",
"description": "SDK for FlightRadar24",
"main": "./FlightRadarAPI/index.js",
"types": "./FlightRadarAPI/index.d.ts",
Expand Down
Loading
Loading