From 39ab7c0872ab88d73f67ebe3dfb1d2b9420498e1 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 18:11:03 -0300 Subject: [PATCH 01/22] fix: source getAirports() from the airports JSON feed FR24 rebuilt /data/airports/ as a client-rendered Inertia.js page. The airport rows are gone from the HTML, so `parseAirportsHtml` found no , warned, and returned an empty list -- `getAirports()` had been answering 0 airports since some point between 2026-08-08 and 2026-08-12. The per-country pages cannot be scraped back into shape either: for large countries they only carry a `states` array linking to per-state pages, and the new payload dropped latitude and longitude entirely. Switch both ports to https://www.flightradar24.com/_json/airports.php, which returns every airport in a single response with the exact fields the Airport entity needs (name, iata, icao, lat, lon, country, alt). The country filter now runs in memory: slugifying the feed's country names matches all 228 values of the Countries enum, so the public signature is unchanged. `countries` became optional -- omitting it returns every airport, which the per-country scrape could not do. Brazil + United States now yields 2032 airports (was 0), keeping the existing >= 1800 integration threshold. One request replaces one per country, so the fan-out and its max_workers/mapConcurrent use are gone from this path. Also stop warning when a "gzip" Content-Encoding covers a body without the gzip magic number: curl_cffi already decompressed it, and this feed would otherwise log that on every Python call. Offline fixtures move from airports_brazil.html to airports.json, captured from the live feed plus a synthetic broken-coordinates row that keeps the "invalid coords must not become 0,0" regression covered. --- nodejs/FlightRadarAPI/api.js | 21 +-- nodejs/FlightRadarAPI/core.js | 3 + nodejs/FlightRadarAPI/index.d.ts | 8 +- nodejs/FlightRadarAPI/parsers.js | 141 ++++++++++++--------- nodejs/tests/fixtures/airports.json | 68 ++++++++++ nodejs/tests/fixtures/airports_brazil.html | 34 ----- nodejs/tests/testParsersOffline.js | 65 +++++++--- python/FlightRadarAPI/api.py | 22 ++-- python/FlightRadarAPI/core.py | 4 + python/FlightRadarAPI/parsers.py | 135 ++++++++++++-------- python/FlightRadarAPI/request.py | 7 +- python/tests/fixtures/airports.json | 68 ++++++++++ python/tests/fixtures/airports_brazil.html | 34 ----- python/tests/test_parsers_offline.py | 58 ++++++--- 14 files changed, 415 insertions(+), 253 deletions(-) create mode 100644 nodejs/tests/fixtures/airports.json delete mode 100644 nodejs/tests/fixtures/airports_brazil.html create mode 100644 python/tests/fixtures/airports.json delete mode 100644 python/tests/fixtures/airports_brazil.html diff --git a/nodejs/FlightRadarAPI/api.js b/nodejs/FlightRadarAPI/api.js index 19e92a6..f0a9eee 100644 --- a/nodejs/FlightRadarAPI/api.js +++ b/nodejs/FlightRadarAPI/api.js @@ -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 } = require("./parsers"); /** @@ -198,23 +198,16 @@ class FlightRadar24API { } /** - * Return a list with all airports for specified countries. + * Return a list with all airports, optionally narrowed to some countries. * - * @param {Array} countries - Array of country names from Countries enum + * @param {Array} [countries] - Array of country names from Countries enum; every country when omitted * @return {Promise>} */ 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; + const { content } = await this.__client.request( + Core.airportsJsonUrl, { headers: Core.jsonHeaders, timeout: this.timeout }, + ); + return parseAirportsJson(content, countries ?? null); } /** diff --git a/nodejs/FlightRadarAPI/core.js b/nodejs/FlightRadarAPI/core.js index cc8707d..f9f75cd 100644 --- a/nodejs/FlightRadarAPI/core.js +++ b/nodejs/FlightRadarAPI/core.js @@ -47,6 +47,9 @@ 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 and no longer carry a scrapable list. + airportsJsonUrl: `${FR24_BASE}/_json/airports.php`, airlinesDataUrl: `${FR24_BASE}/data/airlines`, diff --git a/nodejs/FlightRadarAPI/index.d.ts b/nodejs/FlightRadarAPI/index.d.ts index 2be3c99..20fe2b8 100644 --- a/nodejs/FlightRadarAPI/index.d.ts +++ b/nodejs/FlightRadarAPI/index.d.ts @@ -28,7 +28,7 @@ export class APIClient { request(url: string, options?: object): Promise<{content: any; statusCode: number; cookies: Record}>; /** * 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}>; getCookie(name: string): string | undefined; @@ -114,11 +114,11 @@ export class FlightRadar24API { getAirportDisruptions(): Promise; /** - * 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 {string[]} [countries] - Array of country names; every country when omitted */ - getAirports(countries: string[]): Promise; + getAirports(countries?: string[]): Promise; /** * Return the bookmarks from the FlightRadar24 account. diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index e7856a9..1322258 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -71,89 +71,106 @@ 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} + * @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"); - - if (!tbody) { - console.warn(`parseAirportsHtml: no for ${countryHref} — FR24 page layout may have changed.`); - return []; - } +function countryToSlug(country) { + // The feed is ASCII today ("Curacao"); stripping diacritics keeps the match + // working if that ever changes. + return String(country ?? "") + .normalize("NFKD").replace(/[\u0300-\u036f]/g, "") + .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); +} - const countryDisplayName = countryHref.split("/").pop().replace(/-/g, " ") - .split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); +/** + * Coerce a feed coordinate into a number, or null when it is unusable. + * + * Invalid coordinates must not become 0.0: that would place the airport in the + * Gulf of Guinea instead of marking its position as unknown. + * + * @param {*} value + * @return {number|null} + */ +function toCoordinate(value) { + if (value === null || value === undefined || value === "") return null; + const number = typeof value === "number" ? value : parseFloat(value); + return Number.isFinite(number) ? number : null; +} - const airports = []; +/** + * Parse the airports JSON feed into a list of Airport instances. + * + * @param {object|string|Buffer} payload - Body of Core.airportsJsonUrl + * @param {Array} [countries] - Country slugs from the Countries enum; every airport is kept when omitted + * @return {Array} + */ +function parseAirportsJson(payload, countries = null) { + let data = payload; - for (const tr of tbody.querySelectorAll("tr")) { - const aElement = tr.querySelector("a[data-iata][data-lat][data-lon]"); + if (typeof data === "string" || Buffer.isBuffer(data)) { + try { + data = JSON.parse(data.toString()); + } + catch { + console.warn("parseAirportsJson: response is not valid JSON — FR24 feed may have changed."); + return []; + } + } - if (!aElement) continue; + const rows = data && Array.isArray(data["rows"]) ? data["rows"] : 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(); + if (!rows) { + console.warn("parseAirportsJson: no \"rows\" array in response — FR24 feed may have changed."); + return []; + } - const smallElement = aElement.querySelector("small"); + const wanted = countries ? new Set(countries.map(countryToSlug)) : null; + const matched = new Set(); + const airports = []; - if (smallElement) { - const smallText = smallElement.text.trim(); - const codesText = smallText.replace(/^\(/, "").replace(/\)$/, "").trim(); - namePart = namePart.replace(smallText, "").replace(/\(\)/, "").trim(); + for (const row of rows) { + if (!row || typeof row !== "object") continue; - if (codesText.includes("/")) { - const codes = codesText.split("/"); - const code1 = codes[0].trim(); - const code2 = codes[1].trim(); + const slug = countryToSlug(row["country"]); - 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; - } + if (wanted) { + 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)) { + const latitude = toCoordinate(row["lat"]); + const longitude = toCoordinate(row["lon"]); + + if (latitude === null || longitude === null) { console.warn( - `parseAirportsHtml: invalid coordinates for airport "${namePart}" ` + - `(lat=${latitude}, lon=${longitude}) — skipping position.`, + `parseAirportsJson: invalid coordinates for airport "${row["name"] ?? ""}" ` + + `(lat=${row["lat"]}, lon=${row["lon"]}) — skipping position.`, ); - latNum = null; - lonNum = null; } airports.push(new Airport({ - "name": namePart, - "icao": icao, - "iata": iata, - "lat": latNum, - "lon": lonNum, - "alt": null, - "country": countryDisplayName, + "name": row["name"] ?? "", + "icao": row["icao"] ?? "", + "iata": row["iata"] ?? "", + "lat": latitude, + "lon": longitude, + "alt": toCoordinate(row["alt"]), + "country": row["country"] ?? "", })); } + 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 }; diff --git a/nodejs/tests/fixtures/airports.json b/nodejs/tests/fixtures/airports.json new file mode 100644 index 0000000..68359f7 --- /dev/null +++ b/nodejs/tests/fixtures/airports.json @@ -0,0 +1,68 @@ +{ + "version": "1786010717", + "rows": [ + { + "name": "Sao Paulo Guarulhos International Airport", + "iata": "GRU", + "icao": "SBGR", + "lat": -23.429991, + "lon": -46.4674, + "country": "Brazil", + "alt": 2436 + }, + { + "name": "Rio de Janeiro Galeao International Airport", + "iata": "GIG", + "icao": "SBGL", + "lat": -22.805696, + "lon": -43.25523, + "country": "Brazil", + "alt": 22 + }, + { + "name": "Sao Paulo Congonhas Airport", + "iata": "CGH", + "icao": "SBSP", + "lat": -23.627348, + "lon": -46.655987, + "country": "Brazil", + "alt": 2614 + }, + { + "name": "Los Angeles International Airport", + "iata": "LAX", + "icao": "KLAX", + "lat": 33.94252, + "lon": -118.406998, + "country": "United States", + "alt": 125 + }, + { + "name": "New York John F. Kennedy International Airport", + "iata": "JFK", + "icao": "KJFK", + "lat": 40.639751, + "lon": -73.7789, + "country": "United States", + "alt": 14 + }, + { + "name": "A Coruna Airport", + "iata": "LCG", + "icao": "LECO", + "lat": 43.302059, + "lon": -8.37725, + "country": "Spain", + "alt": 326 + }, + { + "name": "Broken Coords Airport", + "iata": "BAD", + "icao": "SBAD", + "lat": "n/a", + "lon": "", + "country": "Brazil", + "alt": 0 + } + ] +} diff --git a/nodejs/tests/fixtures/airports_brazil.html b/nodejs/tests/fixtures/airports_brazil.html deleted file mode 100644 index dc62403..0000000 --- a/nodejs/tests/fixtures/airports_brazil.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - -
- São Paulo / Guarulhos (GRU/SBGR) -
- Rio de Janeiro / Galeão (GIG/SBGL) -
- São Paulo / Congonhas (CGH) -
- Bad Coords Airport (BAD/SBBD) -
placeholder
- diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index 2e71b15..58a8577 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -1,17 +1,17 @@ /** - * Offline parser tests against bundled HTML fixtures. + * Offline parser tests against bundled fixtures. * * These tests run with no network access and exist specifically so that PRs * can be gated on the parser logic without depending on FR24 being reachable - * or its HTML layout being stable. When FR24 changes the page structure, - * update the fixtures (re-saving a real page is fine) — the assertions here - * guard the parser's invariants, not byte-for-byte equality with production. + * or its payloads being stable. When FR24 changes a page or feed, update the + * fixtures (re-saving a real response is fine) — the assertions here guard the + * parser's invariants, not byte-for-byte equality with production. */ const fs = require("fs"); const path = require("path"); const expect = require("chai").expect; -const { parseAirlinesHtml, parseAirportsHtml } = require("../FlightRadarAPI/parsers"); +const { parseAirlinesHtml, parseAirportsJson, countryToSlug } = require("../FlightRadarAPI/parsers"); const FIXTURES = path.join(__dirname, "fixtures"); const load = (name) => fs.readFileSync(path.join(FIXTURES, name), "utf-8"); @@ -63,10 +63,10 @@ describe("parseAirlinesHtml (offline)", function() { }); -describe("parseAirportsHtml (offline)", function() { +describe("parseAirportsJson (offline)", function() { let airports; before(function() { - airports = parseAirportsHtml(load("airports_brazil.html"), "/data/airports/brazil"); + airports = parseAirportsJson(load("airports.json")); }); it("extracts iata, icao, name, country and position", function() { @@ -75,13 +75,30 @@ describe("parseAirportsHtml (offline)", function() { expect(byIata["GIG"]).to.exist; expect(byIata["GRU"].icao).to.equal("SBGR"); expect(byIata["GRU"].country).to.equal("Brazil"); - expect(byIata["GRU"].latitude).to.be.closeTo(-23.4356, 1e-6); - expect(byIata["GRU"].longitude).to.be.closeTo(-46.4731, 1e-6); + expect(byIata["GRU"].latitude).to.be.closeTo(-23.429991, 1e-6); + expect(byIata["GRU"].longitude).to.be.closeTo(-46.4674, 1e-6); + expect(byIata["GRU"].altitude).to.equal(2436); }); - it("handles airports whose small tag has only one code", function() { - const cgh = airports.find((a) => a.iata === "CGH"); - expect(cgh.icao).to.equal(""); + it("keeps every country when no filter is given", function() { + const countries = new Set(airports.map((a) => a.country)); + expect(countries).to.include("Brazil"); + expect(countries).to.include("United States"); + expect(countries).to.include("Spain"); + }); + + it("filters by the slugs from the Countries enum", function() { + const filtered = parseAirportsJson(load("airports.json"), ["united-states"]); + expect(filtered.length).to.be.above(0); + for (const airport of filtered) { + expect(airport.country).to.equal("United States"); + } + }); + + it("accepts several countries at once", function() { + const filtered = parseAirportsJson(load("airports.json"), ["brazil", "spain"]); + const countries = new Set(filtered.map((a) => a.country)); + expect([...countries].sort()).to.deep.equal(["Brazil", "Spain"]); }); it("invalid coordinates become null (regression test for 0,0 fallback)", function() { @@ -90,14 +107,24 @@ describe("parseAirportsHtml (offline)", function() { expect(bad.longitude).to.equal(null); }); - it("derives the display country from the URL slug", function() { - const usAirports = parseAirportsHtml(load("airports_brazil.html"), "/data/airports/united-states"); - for (const a of usAirports) { - expect(a.country).to.equal("United States"); - } + it("returns an empty list for an unknown country", function() { + expect(parseAirportsJson(load("airports.json"), ["atlantis"])).to.deep.equal([]); + }); + + it("returns an empty list when the feed has no rows", function() { + expect(parseAirportsJson("")).to.deep.equal([]); + expect(parseAirportsJson("{}")).to.deep.equal([]); + expect(parseAirportsJson("not json")).to.deep.equal([]); }); +}); + - it("returns an empty list for empty input", function() { - expect(parseAirportsHtml("", "/data/airports/brazil")).to.deep.equal([]); +describe("countryToSlug (offline)", function() { + it("matches the spelling used by the Countries enum", function() { + expect(countryToSlug("United States")).to.equal("united-states"); + expect(countryToSlug("Democratic Republic Of The Congo")).to.equal("democratic-republic-of-the-congo"); + expect(countryToSlug("Curacao")).to.equal("curacao"); + expect(countryToSlug("Curaçao")).to.equal("curacao"); + expect(countryToSlug("")).to.equal(""); }); }); diff --git a/python/FlightRadarAPI/api.py b/python/FlightRadarAPI/api.py index a097462..46c7300 100644 --- a/python/FlightRadarAPI/api.py +++ b/python/FlightRadarAPI/api.py @@ -11,7 +11,7 @@ from .entities.flight import Flight from .errors import AirportNotFoundError, LoginError from .flight_tracker_config import FlightTrackerConfig -from .parsers import parse_airlines_html, parse_airports_html +from .parsers import parse_airlines_html, parse_airports_json from .request import APIClient, RetryPolicy # Some FR24 live-feed backends answer 200 with a well-formed envelope but no @@ -187,21 +187,17 @@ def get_airport_disruptions(self) -> Dict: ) return response.get_json_content() - def get_airports(self, countries: List[Countries]) -> List[Airport]: + def get_airports(self, countries: Optional[List[Countries]] = None) -> List[Airport]: """ - Return a list with all airports for specified countries. + Return a list with all airports, optionally narrowed to some countries. - :param countries: List of country names from Countries enum. + :param countries: List of country names from Countries enum. Every country when omitted. """ - def _fetch(country): - href = Core.airports_data_url + "/" + country.value - response = self.__client.request_standalone(href, headers=Core.html_headers, timeout=self.timeout) - return parse_airports_html(response.get_bytes_content(), href) - - with ThreadPoolExecutor(max_workers=self.max_workers) as executor: - results = executor.map(_fetch, countries) - - return [airport for result in results for airport in result] + response = self.__client.request( + Core.airports_json_url, headers=Core.json_headers, timeout=self.timeout, + ) + slugs = [country.value for country in countries] if countries is not None else None + return parse_airports_json(response.get_json_content(), slugs) def get_bookmarks(self) -> Dict: """ diff --git a/python/FlightRadarAPI/core.py b/python/FlightRadarAPI/core.py index 79053d3..e8a76e0 100644 --- a/python/FlightRadarAPI/core.py +++ b/python/FlightRadarAPI/core.py @@ -32,6 +32,10 @@ class Core: airport_data_url = flightradar_base_url + "/airports/traffic-stats/?airport={}" airports_data_url = flightradar_base_url + "/data/airports" + # Every airport in one payload. The per-country pages above are + # client-rendered and no longer carry a scrapable list. + airports_json_url = flightradar_base_url + "/_json/airports.php" + # Airlines data URL. airlines_data_url = flightradar_base_url + "/data/airlines" diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 813d65f..005e813 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- +import json import logging -from typing import Dict, List, Optional +import math +import re +import unicodedata +from typing import Dict, List, Optional, Union from bs4 import BeautifulSoup @@ -70,74 +74,99 @@ def parse_airlines_html(html: bytes) -> List[Dict]: return airlines -def parse_airports_html(html: bytes, country_href: str) -> List[Airport]: +def country_to_slug(country: Optional[str]) -> str: """ - 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 ("United States" -> "united-states"). """ - soup = BeautifulSoup(html, "html.parser") - tbody = soup.find("tbody") + # The feed is ASCII today ("Curacao"); stripping diacritics keeps the match + # working if that ever changes. + decomposed = unicodedata.normalize("NFKD", str(country or "")) + ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char)) + return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-") - if not tbody: - _logger.warning( - "parse_airports_html: no for %s — FR24 page layout may have changed.", - country_href, - ) - return [] - - country_name = country_href.split("/")[-1].replace("-", " ").title() - airports = [] - for tr in tbody.find_all("tr"): - a_elements = tr.find_all("a", attrs={"data-iata": True, "data-lat": True, "data-lon": True}) - - if not a_elements: - continue +def _to_coordinate(value: object) -> Optional[float]: + """ + Coerce a feed coordinate into a float, or None when it is unusable. - a_element = a_elements[0] + Invalid coordinates must not become 0.0: that would place the airport in the + Gulf of Guinea instead of marking its position as unknown. + """ + if value is None or value == "": + return None - icao = "" - iata = str(a_element.get("data-iata", "")).strip() - latitude = str(a_element.get("data-lat", "")).strip() - longitude = str(a_element.get("data-lon", "")).strip() - name_part = a_element.get_text(strip=True) + try: + number = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None - small_element = a_element.find("small") + return number if math.isfinite(number) else None - if small_element: - codes_text = small_element.get_text(strip=True).lstrip("(").rstrip(")").strip() - name_part = name_part.replace(small_element.get_text(strip=True), "").replace("()", "").strip() - if "/" in codes_text: - code1, code2 = (s.strip() for s in codes_text.split("/", maxsplit=1)) - if len(code1) == 3 and len(code2) == 4: - iata, icao = code1, code2 - elif len(code1) == 4 and len(code2) == 3: - iata, icao = code2, code1 - elif len(codes_text) == 3: - iata = codes_text - elif len(codes_text) == 4: - icao = codes_text +def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[List[str]] = None) -> List[Airport]: + """ + Parse the airports JSON feed into a list of Airport instances. - lat_float: Optional[float] - lon_float: Optional[float] + :param payload: Body of Core.airports_json_url. + :param countries: Country slugs from the Countries enum. Every airport is kept when omitted. + """ + if isinstance(payload, (bytes, str)): try: - lat_float = float(latitude) if latitude else None - lon_float = float(longitude) if longitude else None + data = json.loads(payload) except ValueError: + _logger.warning("parse_airports_json: response is not valid JSON — FR24 feed may have changed.") + return [] + else: + data = payload + + rows = data.get("rows") if isinstance(data, dict) else None + + if not isinstance(rows, list): + _logger.warning('parse_airports_json: no "rows" array in response — FR24 feed may have changed.') + return [] + + wanted = {country_to_slug(country) for country in countries} if countries is not None else None + matched = set() + airports = [] + + for row in rows: + if not isinstance(row, dict): + continue + + slug = country_to_slug(row.get("country")) + + if wanted is not None: + if slug not in wanted: + continue + matched.add(slug) + + latitude = _to_coordinate(row.get("lat")) + longitude = _to_coordinate(row.get("lon")) + + if latitude is None or longitude is None: _logger.warning( - "parse_airports_html: invalid coordinates for airport %r (lat=%r, lon=%r) — skipping position.", - name_part, latitude, longitude, + "parse_airports_json: invalid coordinates for airport %r (lat=%r, lon=%r) — skipping position.", + row.get("name", ""), row.get("lat"), row.get("lon"), ) - lat_float, lon_float = None, None airports.append(Airport(basic_info={ - "name": name_part, - "icao": icao, - "iata": iata, - "lat": lat_float, - "lon": lon_float, - "alt": None, - "country": country_name, + "name": row.get("name", ""), + "icao": row.get("icao", ""), + "iata": row.get("iata", ""), + "lat": latitude, + "lon": longitude, + "alt": _to_coordinate(row.get("alt")), + "country": row.get("country", ""), })) + if wanted is not None: + missing = sorted(wanted - matched) + + if missing: + _logger.warning( + "parse_airports_json: no airports found for %s — check the Countries enum.", + ", ".join(missing), + ) + return airports diff --git a/python/FlightRadarAPI/request.py b/python/FlightRadarAPI/request.py index 9f2cff7..92a9665 100644 --- a/python/FlightRadarAPI/request.py +++ b/python/FlightRadarAPI/request.py @@ -233,7 +233,12 @@ def get_content(self) -> Union[Dict, bytes]: try: content = decode(content) except Exception as err: - _logger.warning( + # No gzip magic number under a "gzip" header means curl_cffi already + # decompressed the body — routine, so keep it out of the warning log. + transport_decoded = content_encoding == "gzip" and not content.startswith(b"\x1f\x8b") + + _logger.log( + logging.DEBUG if transport_decoded else logging.WARNING, "APIRequest.get_content: failed to decode Content-Encoding=%r for %s (%s). " "Assuming the transport already decompressed and returning raw bytes.", content_encoding, self.url, err, diff --git a/python/tests/fixtures/airports.json b/python/tests/fixtures/airports.json new file mode 100644 index 0000000..68359f7 --- /dev/null +++ b/python/tests/fixtures/airports.json @@ -0,0 +1,68 @@ +{ + "version": "1786010717", + "rows": [ + { + "name": "Sao Paulo Guarulhos International Airport", + "iata": "GRU", + "icao": "SBGR", + "lat": -23.429991, + "lon": -46.4674, + "country": "Brazil", + "alt": 2436 + }, + { + "name": "Rio de Janeiro Galeao International Airport", + "iata": "GIG", + "icao": "SBGL", + "lat": -22.805696, + "lon": -43.25523, + "country": "Brazil", + "alt": 22 + }, + { + "name": "Sao Paulo Congonhas Airport", + "iata": "CGH", + "icao": "SBSP", + "lat": -23.627348, + "lon": -46.655987, + "country": "Brazil", + "alt": 2614 + }, + { + "name": "Los Angeles International Airport", + "iata": "LAX", + "icao": "KLAX", + "lat": 33.94252, + "lon": -118.406998, + "country": "United States", + "alt": 125 + }, + { + "name": "New York John F. Kennedy International Airport", + "iata": "JFK", + "icao": "KJFK", + "lat": 40.639751, + "lon": -73.7789, + "country": "United States", + "alt": 14 + }, + { + "name": "A Coruna Airport", + "iata": "LCG", + "icao": "LECO", + "lat": 43.302059, + "lon": -8.37725, + "country": "Spain", + "alt": 326 + }, + { + "name": "Broken Coords Airport", + "iata": "BAD", + "icao": "SBAD", + "lat": "n/a", + "lon": "", + "country": "Brazil", + "alt": 0 + } + ] +} diff --git a/python/tests/fixtures/airports_brazil.html b/python/tests/fixtures/airports_brazil.html deleted file mode 100644 index dc62403..0000000 --- a/python/tests/fixtures/airports_brazil.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - -
- São Paulo / Guarulhos (GRU/SBGR) -
- Rio de Janeiro / Galeão (GIG/SBGL) -
- São Paulo / Congonhas (CGH) -
- Bad Coords Airport (BAD/SBBD) -
placeholder
- diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index 77c3b22..00e08dc 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -10,7 +10,7 @@ import os -from FlightRadarAPI.parsers import parse_airlines_html, parse_airports_html +from FlightRadarAPI.parsers import country_to_slug, parse_airlines_html, parse_airports_json FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") @@ -63,42 +63,62 @@ def test_parse_airlines_html_empty_input_returns_empty_list(): assert parse_airlines_html(b"") == [] assert parse_airlines_html(b"

no tbody here

") == [] +# --- parse_airports_json --- -# --- parse_airports_html --- -def test_parse_airports_html_extracts_basic_fields(): - airports = parse_airports_html(_load("airports_brazil.html"), "/data/airports/brazil") +def test_parse_airports_json_extracts_basic_fields(): + airports = parse_airports_json(_load("airports.json")) by_iata = {a.iata: a for a in airports} assert "GRU" in by_iata and "GIG" in by_iata gru = by_iata["GRU"] assert gru.icao == "SBGR" assert gru.country == "Brazil" - assert abs(gru.latitude - (-23.4356)) < 1e-6 - assert abs(gru.longitude - (-46.4731)) < 1e-6 + assert abs(gru.latitude - (-23.429991)) < 1e-6 + assert abs(gru.longitude - (-46.4674)) < 1e-6 + assert gru.altitude == 2436 -def test_parse_airports_html_handles_iata_only_small_tag(): - airports = parse_airports_html(_load("airports_brazil.html"), "/data/airports/brazil") - cgh = next(a for a in airports if a.iata == "CGH") - assert cgh.icao == "" +def test_parse_airports_json_keeps_every_country_without_filter(): + countries = {a.country for a in parse_airports_json(_load("airports.json"))} + assert {"Brazil", "United States", "Spain"} <= countries -def test_parse_airports_html_invalid_coordinates_become_none(): +def test_parse_airports_json_filters_by_country_slug(): + airports = parse_airports_json(_load("airports.json"), ["united-states"]) + assert airports + assert all(a.country == "United States" for a in airports) + + +def test_parse_airports_json_accepts_several_countries(): + airports = parse_airports_json(_load("airports.json"), ["brazil", "spain"]) + assert {a.country for a in airports} == {"Brazil", "Spain"} + + +def test_parse_airports_json_invalid_coordinates_become_none(): """Regression: invalid coords used to be silently coerced to (0.0, 0.0) placing the airport in the Gulf of Guinea. They must be None now.""" - airports = parse_airports_html(_load("airports_brazil.html"), "/data/airports/brazil") + airports = parse_airports_json(_load("airports.json")) bad = next(a for a in airports if a.iata == "BAD") assert bad.latitude is None assert bad.longitude is None -def test_parse_airports_html_empty_input_returns_empty_list(): - assert parse_airports_html(b"", "/data/airports/brazil") == [] +def test_parse_airports_json_unknown_country_returns_empty_list(): + assert parse_airports_json(_load("airports.json"), ["atlantis"]) == [] -def test_parse_airports_html_derives_country_from_href(): - airports = parse_airports_html( - _load("airports_brazil.html"), "/data/airports/united-states", - ) - assert all(a.country == "United States" for a in airports) +def test_parse_airports_json_invalid_payload_returns_empty_list(): + assert parse_airports_json(b"") == [] + assert parse_airports_json(b"{}") == [] + assert parse_airports_json(b"not json") == [] + + +# --- country_to_slug --- + +def test_country_to_slug_matches_countries_enum_spelling(): + assert country_to_slug("United States") == "united-states" + assert country_to_slug("Democratic Republic Of The Congo") == "democratic-republic-of-the-congo" + assert country_to_slug("Curacao") == "curacao" + assert country_to_slug("Curaçao") == "curacao" + assert country_to_slug(None) == "" From d2e629eb67e7855ef786436ffb0e75cd02f15c31 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 18:49:34 -0300 Subject: [PATCH 02/22] fix: address review findings on the airports feed migration The country field now carries FR24's own spelling, which uses parentheses for four countries ("Myanmar (Burma)", "Cocos (Keeling) Islands", "Falkland Islands (Malvinas)", "Timor-Leste (East Timor)"). Feeding that straight back into getCountryFlag() -- the obvious way to chain the two calls -- built `myanmar-(burma)` and returned null. Both ports now slugify the flag URL with countryToSlug, so either spelling resolves. get_airports() in the Python port called get_json_content(), which raises when FR24 answers 200 with a non-JSON body. That made the parser's "warn and return []" guard unreachable exactly when it was needed, and diverged from Node. It now passes get_content() through instead. Altitude disagreed between the ports: reusing the coordinate helper turned 2436 into 2436.0 in Python. The helper is now _to_number/toNumber and preserves whole numbers. Node also switches parseFloat -> Number, so a value like "43.30 N" is rejected in both ports rather than silently read as 43.3 in one of them -- the feed already sends strings in that position ("alt": "-1" on 11 rows). Remaining cleanups: brotli responses no longer log the "already decompressed" warning that only gzip was exempt from; getAirports([]) returns without a request; Python accepts plain slug strings alongside Countries members, matching Node; and the stale requestStandalone comment about a per-country fan-out is gone. Tests: the flag chaining is covered end to end in both ports (verified to fail before the fix), plus offline coverage for the parenthesised slugs, int-preserving altitude, strict numeric rejection, and the unfiltered path. --- nodejs/FlightRadarAPI/api.js | 10 +++++--- nodejs/FlightRadarAPI/parsers.js | 18 +++++++------- nodejs/FlightRadarAPI/request.js | 6 ++--- nodejs/tests/testApi.js | 14 +++++++++++ nodejs/tests/testParsersOffline.js | 21 +++++++++++++++++ python/FlightRadarAPI/api.py | 28 ++++++++++++++++------ python/FlightRadarAPI/parsers.py | 27 ++++++++++++++------- python/FlightRadarAPI/request.py | 9 ++++--- python/tests/test_api.py | 16 +++++++++++++ python/tests/test_parsers_offline.py | 35 +++++++++++++++++++++++++--- 10 files changed, 149 insertions(+), 35 deletions(-) diff --git a/nodejs/FlightRadarAPI/api.js b/nodejs/FlightRadarAPI/api.js index f0a9eee..d68530e 100644 --- a/nodejs/FlightRadarAPI/api.js +++ b/nodejs/FlightRadarAPI/api.js @@ -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, parseAirportsJson } = require("./parsers"); +const { parseAirlinesHtml, parseAirportsJson, countryToSlug } = require("./parsers"); /** @@ -204,6 +204,8 @@ class FlightRadar24API { * @return {Promise>} */ async getAirports(countries) { + if (countries && countries.length === 0) return []; + const { content } = await this.__client.request( Core.airportsJsonUrl, { headers: Core.jsonHeaders, timeout: this.timeout }, ); @@ -308,8 +310,10 @@ class FlightRadar24API { * @return {Promise<[object, string] | null>} */ async getCountryFlag(country) { - const slug = country.toLowerCase().replaceAll(" ", "-"); - const flagUrl = Core.countryFlagUrl(slug); + // Slugified the same way as the airports feed, so the `country` of an + // Airport can be handed straight back here: FR24 spells some names with + // parentheses ("Myanmar (Burma)") that must not reach the URL. + const flagUrl = Core.countryFlagUrl(countryToSlug(country)); const headers = { ...Core.imageHeaders }; delete headers["origin"]; diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index 1322258..a7f0372 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -86,17 +86,19 @@ function countryToSlug(country) { } /** - * Coerce a feed coordinate into a number, or null when it is unusable. + * Coerce a numeric feed field into a number, or null when it is unusable. * - * Invalid coordinates must not become 0.0: that would place the airport in the - * Gulf of Guinea instead of marking its position as unknown. + * An unusable coordinate must not become 0: that would place the airport in the + * Gulf of Guinea instead of marking its position as unknown. `Number` rather + * than `parseFloat` keeps this as strict as the Python port, which uses `float`: + * `parseFloat` would turn "43.30 N" into 43.3 where `float` rejects it. * * @param {*} value * @return {number|null} */ -function toCoordinate(value) { +function toNumber(value) { if (value === null || value === undefined || value === "") return null; - const number = typeof value === "number" ? value : parseFloat(value); + const number = typeof value === "number" ? value : Number(String(value).trim()); return Number.isFinite(number) ? number : null; } @@ -141,8 +143,8 @@ function parseAirportsJson(payload, countries = null) { matched.add(slug); } - const latitude = toCoordinate(row["lat"]); - const longitude = toCoordinate(row["lon"]); + const latitude = toNumber(row["lat"]); + const longitude = toNumber(row["lon"]); if (latitude === null || longitude === null) { console.warn( @@ -157,7 +159,7 @@ function parseAirportsJson(payload, countries = null) { "iata": row["iata"] ?? "", "lat": latitude, "lon": longitude, - "alt": toCoordinate(row["alt"]), + "alt": toNumber(row["alt"]), "country": row["country"] ?? "", })); } diff --git a/nodejs/FlightRadarAPI/request.js b/nodejs/FlightRadarAPI/request.js index 4e9c505..94a07d9 100644 --- a/nodejs/FlightRadarAPI/request.js +++ b/nodejs/FlightRadarAPI/request.js @@ -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={}] diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index 0767c8b..f5863cc 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -50,6 +50,20 @@ describe("Testing FlightRadarAPI version " + version, function() { const results = await frApi.getAirports(countries); expect(results.length).to.be.above(expected - 1); }); + + it("Expected every airport when no country is given.", async function() { + const results = await frApi.getAirports(); + expect(results.length).to.be.above(expected); + expect(new Set(results.map((airport) => airport.country)).size).to.be.above(countries.length); + }); + + it("Expected the country of an airport to resolve to a flag.", async function() { + // Regression: FR24 spells some countries with parentheses, which used + // to produce an invalid flag URL when chaining the two calls. + const results = await frApi.getAirports([Countries.MYANMAR_BURMA]); + expect(results.length).to.be.above(0); + expect(await frApi.getCountryFlag(results[0].country)).to.not.equal(null); + }); }); describe("Getting Zones", function() { diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index 58a8577..198e81b 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -107,6 +107,18 @@ describe("parseAirportsJson (offline)", function() { expect(bad.longitude).to.equal(null); }); + it("rejects numeric-looking junk instead of truncating it", function() { + const payload = JSON.stringify({ rows: [{ + name: "Junk Airport", iata: "JNK", icao: "JJNK", + lat: "43.30 N", lon: -8.37725, country: "Spain", alt: "-1", + }] }); + const [airport] = parseAirportsJson(payload); + + expect(airport.latitude).to.equal(null); + expect(airport.longitude).to.be.closeTo(-8.37725, 1e-6); + expect(airport.altitude).to.equal(-1); + }); + it("returns an empty list for an unknown country", function() { expect(parseAirportsJson(load("airports.json"), ["atlantis"])).to.deep.equal([]); }); @@ -127,4 +139,13 @@ describe("countryToSlug (offline)", function() { expect(countryToSlug("Curaçao")).to.equal("curacao"); expect(countryToSlug("")).to.equal(""); }); + + it("strips the parentheses FR24 uses, keeping country flag URLs valid", function() { + // Regression: `getCountryFlag(airport.country)` 404'd for these while the + // slug was built with a plain space-to-hyphen replacement. + expect(countryToSlug("Myanmar (Burma)")).to.equal("myanmar-burma"); + expect(countryToSlug("Cocos (Keeling) Islands")).to.equal("cocos-keeling-islands"); + expect(countryToSlug("Falkland Islands (Malvinas)")).to.equal("falkland-islands-malvinas"); + expect(countryToSlug("Timor-Leste (East Timor)")).to.equal("timor-leste-east-timor"); + }); }); diff --git a/python/FlightRadarAPI/api.py b/python/FlightRadarAPI/api.py index 46c7300..1254726 100644 --- a/python/FlightRadarAPI/api.py +++ b/python/FlightRadarAPI/api.py @@ -11,7 +11,7 @@ from .entities.flight import Flight from .errors import AirportNotFoundError, LoginError from .flight_tracker_config import FlightTrackerConfig -from .parsers import parse_airlines_html, parse_airports_json +from .parsers import country_to_slug, parse_airlines_html, parse_airports_json from .request import APIClient, RetryPolicy # Some FR24 live-feed backends answer 200 with a well-formed envelope but no @@ -187,17 +187,29 @@ def get_airport_disruptions(self) -> Dict: ) return response.get_json_content() - def get_airports(self, countries: Optional[List[Countries]] = None) -> List[Airport]: + def get_airports(self, countries: Optional[List[Union[Countries, str]]] = None) -> List[Airport]: """ Return a list with all airports, optionally narrowed to some countries. - :param countries: List of country names from Countries enum. Every country when omitted. + :param countries: Country names from the Countries enum, or their slug strings. + Every country when omitted. """ + if countries is not None and len(countries) == 0: + return [] + response = self.__client.request( Core.airports_json_url, headers=Core.json_headers, timeout=self.timeout, ) - slugs = [country.value for country in countries] if countries is not None else None - return parse_airports_json(response.get_json_content(), slugs) + slugs: Optional[List[str]] = None + + if countries is not None: + # Accepts Countries members and plain slug strings alike. + slugs = [str(getattr(country, "value", country)) for country in countries] + + # get_content() rather than get_json_content(): a non-JSON body (an + # interstitial served as text/html) then reaches the parser, which warns + # and returns [] instead of raising, matching the Node port. + return parse_airports_json(response.get_content(), slugs) def get_bookmarks(self) -> Dict: """ @@ -288,8 +300,10 @@ def get_country_flag(self, country: str) -> Optional[Tuple[bytes, str]]: :param country: Country name """ - slug = country.lower().replace(" ", "-") - flag_url = Core.country_flag_url.format(slug) + # Slugified the same way as the airports feed, so the `country` of an + # Airport can be handed straight back here: FR24 spells some names with + # parentheses ("Myanmar (Burma)") that must not reach the URL. + flag_url = Core.country_flag_url.format(country_to_slug(country)) headers = Core.image_headers.copy() headers.pop("origin", None) # Does not work for this request. diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 005e813..8fdf828 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -86,16 +86,27 @@ def country_to_slug(country: Optional[str]) -> str: return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-") -def _to_coordinate(value: object) -> Optional[float]: +def _to_number(value: object) -> Optional[Union[int, float]]: """ - Coerce a feed coordinate into a float, or None when it is unusable. + Coerce a numeric feed field into a number, or None when it is unusable. - Invalid coordinates must not become 0.0: that would place the airport in the - Gulf of Guinea instead of marking its position as unknown. + An unusable coordinate must not become 0.0: that would place the airport in + the Gulf of Guinea instead of marking its position as unknown. Whole numbers + stay ints so that altitudes read the same in both ports -- the feed sends + `alt` as an int for most rows and as a string ("-1") for the rest. """ - if value is None or value == "": + if value is None or isinstance(value, bool) or value == "": return None + if isinstance(value, int): + return value + + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + pass + try: number = float(value) # type: ignore[arg-type] except (TypeError, ValueError): @@ -141,8 +152,8 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li continue matched.add(slug) - latitude = _to_coordinate(row.get("lat")) - longitude = _to_coordinate(row.get("lon")) + latitude = _to_number(row.get("lat")) + longitude = _to_number(row.get("lon")) if latitude is None or longitude is None: _logger.warning( @@ -156,7 +167,7 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li "iata": row.get("iata", ""), "lat": latitude, "lon": longitude, - "alt": _to_coordinate(row.get("alt")), + "alt": _to_number(row.get("alt")), "country": row.get("country", ""), })) diff --git a/python/FlightRadarAPI/request.py b/python/FlightRadarAPI/request.py index 92a9665..57684bb 100644 --- a/python/FlightRadarAPI/request.py +++ b/python/FlightRadarAPI/request.py @@ -233,9 +233,12 @@ def get_content(self) -> Union[Dict, bytes]: try: content = decode(content) except Exception as err: - # No gzip magic number under a "gzip" header means curl_cffi already - # decompressed the body — routine, so keep it out of the warning log. - transport_decoded = content_encoding == "gzip" and not content.startswith(b"\x1f\x8b") + # curl_cffi decompresses gzip and br transparently while leaving the + # header in place, so failing here is routine rather than actionable. + # A body that does carry the gzip magic number is genuinely corrupt + # and still warns; brotli has no magic number to check. + corrupt_gzip = content_encoding == "gzip" and content.startswith(b"\x1f\x8b") + transport_decoded = content_encoding in ("gzip", "br") and not corrupt_gzip _logger.log( logging.DEBUG if transport_decoded else logging.WARNING, diff --git a/python/tests/test_api.py b/python/tests/test_api.py index 5c8e0a4..e7e93cb 100644 --- a/python/tests/test_api.py +++ b/python/tests/test_api.py @@ -53,6 +53,22 @@ def test_get_airports(expect=1800, countries=[Countries.BRAZIL, Countries.UNITED assert len(results) >= expect +@repeat_test(**repeat_test_config) +def test_get_airports_without_countries(expect=1800): + results = fr_api.get_airports() + assert len(results) > expect + assert len({airport.country for airport in results}) > 2 + + +@repeat_test(**repeat_test_config) +def test_get_airports_country_resolves_to_flag(): + """Regression: FR24 spells some countries with parentheses, which used to + produce an invalid flag URL when chaining the two calls.""" + results = fr_api.get_airports(countries=[Countries.MYANMAR_BURMA]) + assert len(results) > 0 + assert fr_api.get_country_flag(results[0].country) is not None + + @repeat_test(**repeat_test_config) def test_get_zones(expect=5): results = fr_api.get_zones() diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index 00e08dc..774cbce 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- -"""Offline parser tests against bundled HTML fixtures. +"""Offline parser tests against bundled fixtures. These tests run with no network access and exist specifically so that PRs can be gated on the parser logic without depending on FR24 being reachable -or its HTML layout being stable. When FR24 changes the page structure, -update the fixtures (re-saving a real page is fine) — the assertions here +or its payloads being stable. When FR24 changes a page or feed, +update the fixtures (re-saving a real response is fine) — the assertions here guard the parser's invariants, not byte-for-byte equality with production. """ +import json import os from FlightRadarAPI.parsers import country_to_slug, parse_airlines_html, parse_airports_json @@ -104,6 +105,25 @@ def test_parse_airports_json_invalid_coordinates_become_none(): assert bad.longitude is None +def test_parse_airports_json_rejects_numeric_looking_junk(): + payload = json.dumps({"rows": [{ + "name": "Junk Airport", "iata": "JNK", "icao": "JJNK", + "lat": "43.30 N", "lon": -8.37725, "country": "Spain", "alt": "-1", + }]}) + airport = parse_airports_json(payload)[0] + + assert airport.latitude is None + assert abs(airport.longitude - (-8.37725)) < 1e-6 + assert airport.altitude == -1 + assert isinstance(airport.altitude, int) + + +def test_parse_airports_json_keeps_altitude_as_int(): + """Both ports must agree on altitude: 2436, never 2436.0.""" + gru = next(a for a in parse_airports_json(_load("airports.json")) if a.iata == "GRU") + assert isinstance(gru.altitude, int) + + def test_parse_airports_json_unknown_country_returns_empty_list(): assert parse_airports_json(_load("airports.json"), ["atlantis"]) == [] @@ -122,3 +142,12 @@ def test_country_to_slug_matches_countries_enum_spelling(): assert country_to_slug("Curacao") == "curacao" assert country_to_slug("Curaçao") == "curacao" assert country_to_slug(None) == "" + + +def test_country_to_slug_strips_parentheses_for_flag_urls(): + """Regression: get_country_flag(airport.country) 404'd for these while the + slug was built with a plain space-to-hyphen replacement.""" + assert country_to_slug("Myanmar (Burma)") == "myanmar-burma" + assert country_to_slug("Cocos (Keeling) Islands") == "cocos-keeling-islands" + assert country_to_slug("Falkland Islands (Malvinas)") == "falkland-islands-malvinas" + assert country_to_slug("Timor-Leste (East Timor)") == "timor-leste-east-timor" From c4965ed945a4d29ec9c61626ee419a08f3950f38 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 19:16:57 -0300 Subject: [PATCH 03/22] fix: keep numeric coercion strict and identical across both ports The previous commit swapped parseFloat for Number to reject "43.30 N", but Number("") is 0, and the empty-string guard ran against the raw value -- so a whitespace-only or array coordinate became 0 in Node while Python returned None. That is the Gulf of Guinea bug the helper exists to prevent, reintroduced in the port it was meant to align. Both ports now gate on the same decimal pattern before converting, so they accept and reject exactly the same inputs: " ", "abc", "0x10", "1_000", "inf", [] and {} are all None/null on both sides, while "1e3", "+5", ".5" and "-23.4" parse on both. Bare Number would have taken the hex, and bare float would have taken "1_000" and "inf". Also from the review: - getAirports accepts a single country as well as a list, in both ports. It used to raise "countries.map is not a function" in Node and silently return [] in Python. - index.d.ts declared latitude and longitude as `number` while the parser can return null for either -- altitude on the next line was already honest about it. - The two integration tests added for the flag chaining collapse into one, halving the 860 KB feed downloads per run, and the tricky country is now discovered from the response instead of hard-coding MYANMAR_BURMA, so the guard survives FR24 renaming it. - The offline fixture gains a real "Myanmar (Burma)" row carrying `alt` as a string, which is what the feed does on 11 rows, so the parenthesised spelling and string altitude are covered without network access. --- nodejs/FlightRadarAPI/api.js | 9 +++++--- nodejs/FlightRadarAPI/index.d.ts | 10 +++++---- nodejs/FlightRadarAPI/parsers.js | 18 ++++++++++------ nodejs/tests/fixtures/airports.json | 9 ++++++++ nodejs/tests/testApi.js | 14 ++++++------- nodejs/tests/testParsersOffline.js | 31 ++++++++++++++++++++++++++++ python/FlightRadarAPI/api.py | 16 ++++++++------ python/FlightRadarAPI/parsers.py | 24 ++++++++++++--------- python/tests/fixtures/airports.json | 9 ++++++++ python/tests/test_api.py | 15 +++++++------- python/tests/test_parsers_offline.py | 29 ++++++++++++++++++++++++++ 11 files changed, 139 insertions(+), 45 deletions(-) diff --git a/nodejs/FlightRadarAPI/api.js b/nodejs/FlightRadarAPI/api.js index d68530e..d256454 100644 --- a/nodejs/FlightRadarAPI/api.js +++ b/nodejs/FlightRadarAPI/api.js @@ -200,16 +200,19 @@ class FlightRadar24API { /** * Return a list with all airports, optionally narrowed to some countries. * - * @param {Array} [countries] - Array of country names from Countries enum; every country when omitted + * @param {Array|string} [countries] - Country names from the Countries enum, + * as an array or a single value; every country when omitted * @return {Promise>} */ async getAirports(countries) { - if (countries && countries.length === 0) return []; + const wanted = typeof countries === "string" ? [countries] : countries; + + if (wanted && wanted.length === 0) return []; const { content } = await this.__client.request( Core.airportsJsonUrl, { headers: Core.jsonHeaders, timeout: this.timeout }, ); - return parseAirportsJson(content, countries ?? null); + return parseAirportsJson(content, wanted ?? null); } /** diff --git a/nodejs/FlightRadarAPI/index.d.ts b/nodejs/FlightRadarAPI/index.d.ts index 20fe2b8..00869c6 100644 --- a/nodejs/FlightRadarAPI/index.d.ts +++ b/nodejs/FlightRadarAPI/index.d.ts @@ -116,9 +116,10 @@ export class FlightRadar24API { /** * Return a list with all airports, optionally narrowed to some countries. * - * @param {string[]} [countries] - Array of country names; every country when omitted + * @param {string[]|string} [countries] - Country names, as an array or a single + * value; every country when omitted */ - getAirports(countries?: string[]): Promise; + getAirports(countries?: string[] | string): Promise; /** * Return the bookmarks from the FlightRadar24 account. @@ -311,8 +312,9 @@ export class Entity { * Airport representation. */ export class Airport extends Entity { - latitude: number; - longitude: number; + // null when FR24 reports no usable coordinates for the airport. + latitude: number | null; + longitude: number | null; altitude: number | null; name: string; icao: string; diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index a7f0372..b227957 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -85,21 +85,27 @@ function countryToSlug(country) { .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); } +// Decimal numbers only, so both ports accept and reject exactly the same +// strings. `Number` on its own would take "0x10" and "" (as 0) where Python's +// `float` refuses them, and `parseFloat` would read "43.30 N" as 43.3. +const NUMERIC_PATTERN = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/; + /** * 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. `Number` rather - * than `parseFloat` keeps this as strict as the Python port, which uses `float`: - * `parseFloat` would turn "43.30 N" into 43.3 where `float` rejects it. + * Gulf of Guinea instead of marking its position as unknown. * * @param {*} value * @return {number|null} */ function toNumber(value) { - if (value === null || value === undefined || value === "") return null; - const number = typeof value === "number" ? value : Number(String(value).trim()); - return Number.isFinite(number) ? number : null; + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (value === null || value === undefined) return null; + + const text = String(value).trim(); + + return NUMERIC_PATTERN.test(text) ? toNumber(Number(text)) : null; } /** diff --git a/nodejs/tests/fixtures/airports.json b/nodejs/tests/fixtures/airports.json index 68359f7..924f9db 100644 --- a/nodejs/tests/fixtures/airports.json +++ b/nodejs/tests/fixtures/airports.json @@ -55,6 +55,15 @@ "country": "Spain", "alt": 326 }, + { + "name": "Ann Airport", + "iata": "VBA", + "icao": "VYAN", + "lat": 19.770643, + "lon": 94.026405, + "country": "Myanmar (Burma)", + "alt": "43" + }, { "name": "Broken Coords Airport", "iata": "BAD", diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index f5863cc..4be7742 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -51,18 +51,16 @@ describe("Testing FlightRadarAPI version " + version, function() { expect(results.length).to.be.above(expected - 1); }); - it("Expected every airport when no country is given.", async function() { + it("Expected every airport when no country is given, each with a flag.", async function() { const results = await frApi.getAirports(); expect(results.length).to.be.above(expected); expect(new Set(results.map((airport) => airport.country)).size).to.be.above(countries.length); - }); - it("Expected the country of an airport to resolve to a flag.", async function() { - // Regression: FR24 spells some countries with parentheses, which used - // to produce an invalid flag URL when chaining the two calls. - const results = await frApi.getAirports([Countries.MYANMAR_BURMA]); - expect(results.length).to.be.above(0); - expect(await frApi.getCountryFlag(results[0].country)).to.not.equal(null); + // Regression: FR24 spells some countries with characters that used to + // reach the flag URL verbatim ("Myanmar (Burma)"). Picked from the + // response rather than hard-coded, so the guard survives a rename. + const tricky = results.find((airport) => /[^a-z ]/i.test(airport.country)) ?? results[0]; + expect(await frApi.getCountryFlag(tricky.country), tricky.country).to.not.equal(null); }); }); diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index 198e81b..0c3b34f 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -107,6 +107,21 @@ describe("parseAirportsJson (offline)", function() { expect(bad.longitude).to.equal(null); }); + it("keeps FR24's own country spelling and still slugifies it for URLs", function() { + const ann = airports.find((airport) => airport.iata === "VBA"); + + expect(ann.country).to.equal("Myanmar (Burma)"); + expect(countryToSlug(ann.country)).to.equal("myanmar-burma"); + // Feed sends `alt` as a string on a few rows; it must read as a number. + expect(ann.altitude).to.equal(43); + }); + + it("matches a filter written with either spelling of the country", function() { + for (const slug of ["myanmar-burma", "Myanmar (Burma)"]) { + expect(parseAirportsJson(load("airports.json"), [slug]).length).to.equal(1); + } + }); + it("rejects numeric-looking junk instead of truncating it", function() { const payload = JSON.stringify({ rows: [{ name: "Junk Airport", iata: "JNK", icao: "JJNK", @@ -119,6 +134,22 @@ describe("parseAirportsJson (offline)", function() { expect(airport.altitude).to.equal(-1); }); + it("never turns an unusable coordinate into 0", function() { + // 0 would put the airport in the Gulf of Guinea. Kept in step with the + // Python port, whose `float` rejects each of these too. + for (const value of [" ", " ", [], "abc", "0x10", "1_000", "inf", true, {}]) { + const payload = JSON.stringify({ rows: [{ + name: "X", iata: "XXX", icao: "XXXX", country: "Spain", + lat: value, lon: value, alt: value, + }] }); + const [airport] = parseAirportsJson(payload); + + expect(airport.latitude, `lat for ${JSON.stringify(value)}`).to.equal(null); + expect(airport.longitude, `lon for ${JSON.stringify(value)}`).to.equal(null); + expect(airport.altitude, `alt for ${JSON.stringify(value)}`).to.equal(null); + } + }); + it("returns an empty list for an unknown country", function() { expect(parseAirportsJson(load("airports.json"), ["atlantis"])).to.deep.equal([]); }); diff --git a/python/FlightRadarAPI/api.py b/python/FlightRadarAPI/api.py index 1254726..40842c9 100644 --- a/python/FlightRadarAPI/api.py +++ b/python/FlightRadarAPI/api.py @@ -187,14 +187,18 @@ def get_airport_disruptions(self) -> Dict: ) return response.get_json_content() - def get_airports(self, countries: Optional[List[Union[Countries, str]]] = None) -> List[Airport]: + def get_airports( + self, countries: Optional[Union[List[Union[Countries, str]], Countries, str]] = None, + ) -> List[Airport]: """ Return a list with all airports, optionally narrowed to some countries. - :param countries: Country names from the Countries enum, or their slug strings. - Every country when omitted. + :param countries: Country names from the Countries enum, or their slug strings, + as a list or a single value. Every country when omitted. """ - if countries is not None and len(countries) == 0: + wanted = [countries] if isinstance(countries, (Countries, str)) else countries + + if wanted is not None and len(wanted) == 0: return [] response = self.__client.request( @@ -202,9 +206,9 @@ def get_airports(self, countries: Optional[List[Union[Countries, str]]] = None) ) slugs: Optional[List[str]] = None - if countries is not None: + if wanted is not None: # Accepts Countries members and plain slug strings alike. - slugs = [str(getattr(country, "value", country)) for country in countries] + slugs = [str(getattr(country, "value", country)) for country in wanted] # get_content() rather than get_json_content(): a non-JSON body (an # interstitial served as text/html) then reaches the parser, which warns diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 8fdf828..85a430d 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -13,6 +13,8 @@ _logger = logging.getLogger(__name__) +NUMERIC_PATTERN = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$") + def parse_airlines_html(html: bytes) -> List[Dict]: """ @@ -95,24 +97,26 @@ def _to_number(value: object) -> Optional[Union[int, float]]: stay ints so that altitudes read the same in both ports -- the feed sends `alt` as an int for most rows and as a string ("-1") for the rest. """ - if value is None or isinstance(value, bool) or value == "": + if value is None or isinstance(value, bool): return None if isinstance(value, int): return value - if isinstance(value, str): - try: - return int(value.strip()) - except ValueError: - pass + if isinstance(value, float): + return value if math.isfinite(value) else None - try: - number = float(value) # type: ignore[arg-type] - except (TypeError, ValueError): + text = str(value).strip() + + # Decimal numbers only, so both ports accept and reject exactly the same + # strings: bare `float` would also take "1_000" and "inf". + if not NUMERIC_PATTERN.match(text): return None - return number if math.isfinite(number) else None + try: + return int(text) + except ValueError: + return float(text) def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[List[str]] = None) -> List[Airport]: diff --git a/python/tests/fixtures/airports.json b/python/tests/fixtures/airports.json index 68359f7..924f9db 100644 --- a/python/tests/fixtures/airports.json +++ b/python/tests/fixtures/airports.json @@ -55,6 +55,15 @@ "country": "Spain", "alt": 326 }, + { + "name": "Ann Airport", + "iata": "VBA", + "icao": "VYAN", + "lat": 19.770643, + "lon": 94.026405, + "country": "Myanmar (Burma)", + "alt": "43" + }, { "name": "Broken Coords Airport", "iata": "BAD", diff --git a/python/tests/test_api.py b/python/tests/test_api.py index e7e93cb..81c7c29 100644 --- a/python/tests/test_api.py +++ b/python/tests/test_api.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +import re + import pytest from FlightRadarAPI import Entity, Flight @@ -59,14 +61,11 @@ def test_get_airports_without_countries(expect=1800): assert len(results) > expect assert len({airport.country for airport in results}) > 2 - -@repeat_test(**repeat_test_config) -def test_get_airports_country_resolves_to_flag(): - """Regression: FR24 spells some countries with parentheses, which used to - produce an invalid flag URL when chaining the two calls.""" - results = fr_api.get_airports(countries=[Countries.MYANMAR_BURMA]) - assert len(results) > 0 - assert fr_api.get_country_flag(results[0].country) is not None + # Regression: FR24 spells some countries with characters that used to reach + # the flag URL verbatim ("Myanmar (Burma)"). Picked from the response rather + # than hard-coded, so the guard survives a rename. + tricky = next((a for a in results if re.search(r"[^a-zA-Z ]", a.country)), results[0]) + assert fr_api.get_country_flag(tricky.country) is not None, tricky.country @repeat_test(**repeat_test_config) diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index 774cbce..8107c95 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -118,6 +118,35 @@ def test_parse_airports_json_rejects_numeric_looking_junk(): assert isinstance(airport.altitude, int) +def test_parse_airports_json_never_turns_bad_coordinate_into_zero(): + """0.0 would put the airport in the Gulf of Guinea. Kept in step with the + Node port, whose numeric pattern rejects each of these too.""" + for value in [" ", " ", [], "abc", "0x10", "1_000", "inf", True, {}]: + payload = json.dumps({"rows": [{ + "name": "X", "iata": "XXX", "icao": "XXXX", "country": "Spain", + "lat": value, "lon": value, "alt": value, + }]}) + airport = parse_airports_json(payload)[0] + + assert airport.latitude is None, value + assert airport.longitude is None, value + assert airport.altitude is None, value + + +def test_parse_airports_json_country_spelling_and_slug_round_trip(): + ann = next(a for a in parse_airports_json(_load("airports.json")) if a.iata == "VBA") + + assert ann.country == "Myanmar (Burma)" + assert country_to_slug(ann.country) == "myanmar-burma" + # Feed sends `alt` as a string on a few rows; it must read as a number. + assert ann.altitude == 43 + + +def test_parse_airports_json_filter_accepts_either_country_spelling(): + for slug in ["myanmar-burma", "Myanmar (Burma)"]: + assert len(parse_airports_json(_load("airports.json"), [slug])) == 1 + + def test_parse_airports_json_keeps_altitude_as_int(): """Both ports must agree on altitude: 2436, never 2436.0.""" gru = next(a for a in parse_airports_json(_load("airports.json")) if a.iata == "GRU") From 16470a2d8e60491af564bb65b3d020f9fb03029a Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 19:23:09 -0300 Subject: [PATCH 04/22] fix: reject overflowing exponents and parse raw-byte feed bodies Two holes left by the previous commit, both found by review: The rewrite of _to_number moved the math.isfinite() check onto the pre-parsed float branch only, so the string path let "1e999" through as inf -- a latitude that poisons distance math and makes json.dumps() emit invalid JSON. Node already returned null for it. The string path is finiteness-checked again. Both ports also disagreed in the other direction: str([43]) is "[43]" in Python but String([43]) is "43" in JavaScript, so a one-element array read as 43 in Node. Neither port stringifies non-strings now. parseAirportsJson only unwrapped strings and Buffers, but request() hands back an ArrayBuffer whenever the response carries an unexpected content-type -- so a valid JSON feed served as, say, octet-stream fell through to "no rows array" and returned [], the exact silent-empty result this PR set out to remove. ArrayBuffers and typed arrays are now decoded like Buffers. Python needed no equivalent: its client returns bytes, which json.loads already accepts. --- nodejs/FlightRadarAPI/parsers.js | 29 ++++++++++++++++++++++++---- nodejs/tests/testParsersOffline.js | 12 +++++++++++- python/FlightRadarAPI/parsers.py | 11 +++++++++-- python/tests/test_parsers_offline.py | 2 +- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index b227957..cdddc47 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -101,13 +101,33 @@ const NUMERIC_PATTERN = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/; */ function toNumber(value) { if (typeof value === "number") return Number.isFinite(value) ? value : null; - if (value === null || value === undefined) return null; + // Only strings are worth parsing. Stringifying anything else would let + // `[43]` through as 43, which Python's `float` rejects. + if (typeof value !== "string") return null; - const text = String(value).trim(); + const text = value.trim(); return NUMERIC_PATTERN.test(text) ? toNumber(Number(text)) : null; } +/** + * 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; +} + /** * Parse the airports JSON feed into a list of Airport instances. * @@ -117,10 +137,11 @@ function toNumber(value) { */ function parseAirportsJson(payload, countries = null) { let data = payload; + const text = toText(payload); - if (typeof data === "string" || Buffer.isBuffer(data)) { + if (text !== null) { try { - data = JSON.parse(data.toString()); + data = JSON.parse(text); } catch { console.warn("parseAirportsJson: response is not valid JSON — FR24 feed may have changed."); diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index 0c3b34f..99e0294 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -137,7 +137,7 @@ describe("parseAirportsJson (offline)", function() { it("never turns an unusable coordinate into 0", function() { // 0 would put the airport in the Gulf of Guinea. Kept in step with the // Python port, whose `float` rejects each of these too. - for (const value of [" ", " ", [], "abc", "0x10", "1_000", "inf", true, {}]) { + for (const value of [" ", " ", [], [43], "abc", "0x10", "1_000", "inf", "1e999", "-1e999", true, {}]) { const payload = JSON.stringify({ rows: [{ name: "X", iata: "XXX", icao: "XXXX", country: "Spain", lat: value, lon: value, alt: value, @@ -154,6 +154,16 @@ describe("parseAirportsJson (offline)", function() { expect(parseAirportsJson(load("airports.json"), ["atlantis"])).to.deep.equal([]); }); + it("parses a body that arrived as raw bytes", function() { + // request() returns an ArrayBuffer when the response carries an + // unexpected content-type; the JSON inside is still good. + const json = load("airports.json"); + + for (const payload of [Buffer.from(json), new TextEncoder().encode(json), new TextEncoder().encode(json).buffer]) { + expect(parseAirportsJson(payload).length, payload.constructor.name).to.equal(airports.length); + } + }); + it("returns an empty list when the feed has no rows", function() { expect(parseAirportsJson("")).to.deep.equal([]); expect(parseAirportsJson("{}")).to.deep.equal([]); diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 85a430d..f0bf413 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -106,7 +106,12 @@ def _to_number(value: object) -> Optional[Union[int, float]]: if isinstance(value, float): return value if math.isfinite(value) else None - text = str(value).strip() + # Only strings are worth parsing; str() on anything else would invent a + # number, e.g. str([43]) == "43". + if not isinstance(value, str): + return None + + text = value.strip() # Decimal numbers only, so both ports accept and reject exactly the same # strings: bare `float` would also take "1_000" and "inf". @@ -116,7 +121,9 @@ def _to_number(value: object) -> Optional[Union[int, float]]: try: return int(text) except ValueError: - return float(text) + # The pattern still admits overflowing exponents such as "1e999". + number = float(text) + return number if math.isfinite(number) else None def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[List[str]] = None) -> List[Airport]: diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index 8107c95..ddccb54 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -121,7 +121,7 @@ def test_parse_airports_json_rejects_numeric_looking_junk(): def test_parse_airports_json_never_turns_bad_coordinate_into_zero(): """0.0 would put the airport in the Gulf of Guinea. Kept in step with the Node port, whose numeric pattern rejects each of these too.""" - for value in [" ", " ", [], "abc", "0x10", "1_000", "inf", True, {}]: + for value in [" ", " ", [], [43], "abc", "0x10", "1_000", "inf", "1e999", "-1e999", True, {}]: payload = json.dumps({"rows": [{ "name": "X", "iata": "XXX", "icao": "XXXX", "country": "Spain", "lat": value, "lon": value, "alt": value, From 762baa00989ca7046690e9c02f6728c766130424 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 19:35:28 -0300 Subject: [PATCH 05/22] test: hold both ports to one shared table of numeric coercion cases Three review rounds in a row found a bug in this one helper, and two of them were caused by the previous round's fix. The cause is structural: the two ports are kept in agreement by hand, so a case fixed in one language stays broken in the other until someone thinks to check. testdata/numeric-coercion.json now holds the cases, and both offline suites iterate it. Adding a case there is enforced in both languages at once -- verified by flipping one expectation and watching Node and Python fail together. Neither port ships its tests (nodejs/.npmignore, the pyproject build exclude), so a path above the package roots costs nothing. The table immediately caught what round four was about: Python read "1" * 400 as a 400-digit int where Node returned null, because int() ran before any magnitude check and Python ints have no ceiling to overflow. _to_number now parses as a float first, rejects non-finite results, and only then recovers int-ness -- capped at Number.MAX_SAFE_INTEGER, past which JavaScript stops being exact and the two would drift apart regardless. Also fills the gaps around the API surface this PR widened: tsd covers the no-argument and single-country forms of getAirports, and both READMEs show that omitting the countries returns every airport. --- nodejs/README.md | 5 +- nodejs/tests/index.test-d.ts | 2 + nodejs/tests/testParsersOffline.js | 39 ++++--- python/FlightRadarAPI/parsers.py | 21 +++- python/README.md | 1 + python/tests/test_parsers_offline.py | 31 +++--- testdata/numeric-coercion.json | 147 +++++++++++++++++++++++++++ 7 files changed, 213 insertions(+), 33 deletions(-) create mode 100644 testdata/numeric-coercion.json diff --git a/nodejs/README.md b/nodejs/README.md index 55f9c4e..3abf5b9 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -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:** diff --git a/nodejs/tests/index.test-d.ts b/nodejs/tests/index.test-d.ts index c5aaaa1..e03c827 100644 --- a/nodejs/tests/index.test-d.ts +++ b/nodejs/tests/index.test-d.ts @@ -26,6 +26,8 @@ expectType>(api.getAirportDetails("ATL", 10, 1)); // getAirports expectType>(api.getAirports(["Brazil"])); +expectType>(api.getAirports("Brazil")); +expectType>(api.getAirports()); // getAirlines expectType>>(api.getAirlines()); diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index 99e0294..f0a2e6b 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -16,6 +16,12 @@ const { parseAirlinesHtml, parseAirportsJson, countryToSlug } = require("../Flig const FIXTURES = path.join(__dirname, "fixtures"); const load = (name) => fs.readFileSync(path.join(FIXTURES, name), "utf-8"); +// Coercion cases live outside the port so both languages are held to the same +// table; see the `comment` field in the file itself. +const SHARED_NUMERIC_CASES = JSON.parse( + fs.readFileSync(path.join(__dirname, "..", "..", "testdata", "numeric-coercion.json"), "utf-8"), +).cases; + describe("parseAirlinesHtml (offline)", function() { let airlines; @@ -134,22 +140,6 @@ describe("parseAirportsJson (offline)", function() { expect(airport.altitude).to.equal(-1); }); - it("never turns an unusable coordinate into 0", function() { - // 0 would put the airport in the Gulf of Guinea. Kept in step with the - // Python port, whose `float` rejects each of these too. - for (const value of [" ", " ", [], [43], "abc", "0x10", "1_000", "inf", "1e999", "-1e999", true, {}]) { - const payload = JSON.stringify({ rows: [{ - name: "X", iata: "XXX", icao: "XXXX", country: "Spain", - lat: value, lon: value, alt: value, - }] }); - const [airport] = parseAirportsJson(payload); - - expect(airport.latitude, `lat for ${JSON.stringify(value)}`).to.equal(null); - expect(airport.longitude, `lon for ${JSON.stringify(value)}`).to.equal(null); - expect(airport.altitude, `alt for ${JSON.stringify(value)}`).to.equal(null); - } - }); - it("returns an empty list for an unknown country", function() { expect(parseAirportsJson(load("airports.json"), ["atlantis"])).to.deep.equal([]); }); @@ -172,6 +162,23 @@ describe("parseAirportsJson (offline)", function() { }); +describe("parseAirportsJson numeric coercion (shared cases)", function() { + for (const { label, input, expected } of SHARED_NUMERIC_CASES) { + it(`reads ${label} as ${JSON.stringify(expected)}`, function() { + const payload = JSON.stringify({ rows: [{ + name: "X", iata: "XXX", icao: "XXXX", country: "Spain", + lat: input, lon: input, alt: input, + }] }); + const [airport] = parseAirportsJson(payload); + + expect(airport.latitude, "latitude").to.equal(expected); + expect(airport.longitude, "longitude").to.equal(expected); + expect(airport.altitude, "altitude").to.equal(expected); + }); + } +}); + + describe("countryToSlug (offline)", function() { it("matches the spelling used by the Countries enum", function() { expect(countryToSlug("United States")).to.equal("united-states"); diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index f0bf413..6709d15 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -15,6 +15,9 @@ NUMERIC_PATTERN = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$") +# Number.MAX_SAFE_INTEGER, the largest integer JavaScript holds exactly. +MAX_EXACT_INTEGER = 2 ** 53 - 1 + def parse_airlines_html(html: bytes) -> List[Dict]: """ @@ -118,12 +121,22 @@ def _to_number(value: object) -> Optional[Union[int, float]]: if not NUMERIC_PATTERN.match(text): return None + # Float first: the pattern still admits values that overflow a double, both + # as exponents ("1e999") and as plain digits ("1" * 400). Python ints have no + # such ceiling, so checking them after int() would let those through. + number = float(text) + + if not math.isfinite(number): + return None + try: - return int(text) + exact = int(text) except ValueError: - # The pattern still admits overflowing exponents such as "1e999". - number = float(text) - return number if math.isfinite(number) else None + return number + + # JavaScript cannot represent integers past 2**53 - 1 exactly, so past that + # both ports report the same double instead of drifting apart. + return exact if abs(exact) <= MAX_EXACT_INTEGER else number def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[List[str]] = None) -> List[Airport]: diff --git a/python/README.md b/python/README.md index a14a185..5c87ffa 100644 --- a/python/README.md +++ b/python/README.md @@ -32,6 +32,7 @@ flights = fr_api.get_flights(...) # Returns a list of Flight objects **Getting airports list:** ```py airports = fr_api.get_airports(...) # Returns a list of Airport objects +all_airports = fr_api.get_airports() # Omit the countries to get every airport ``` **Getting airlines list:** ```py diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index ddccb54..d902723 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -11,10 +11,19 @@ import json import os +import pytest + from FlightRadarAPI.parsers import country_to_slug, parse_airlines_html, parse_airports_json FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") +# Coercion cases live outside the port so both languages are held to the same +# table; see the `comment` field in the file itself. +_SHARED = os.path.join(os.path.dirname(__file__), "..", "..", "testdata", "numeric-coercion.json") + +with open(_SHARED, "rb") as _f: + SHARED_NUMERIC_CASES = json.load(_f)["cases"] + def _load(name: str) -> bytes: with open(os.path.join(FIXTURES, name), "rb") as f: @@ -118,19 +127,17 @@ def test_parse_airports_json_rejects_numeric_looking_junk(): assert isinstance(airport.altitude, int) -def test_parse_airports_json_never_turns_bad_coordinate_into_zero(): - """0.0 would put the airport in the Gulf of Guinea. Kept in step with the - Node port, whose numeric pattern rejects each of these too.""" - for value in [" ", " ", [], [43], "abc", "0x10", "1_000", "inf", "1e999", "-1e999", True, {}]: - payload = json.dumps({"rows": [{ - "name": "X", "iata": "XXX", "icao": "XXXX", "country": "Spain", - "lat": value, "lon": value, "alt": value, - }]}) - airport = parse_airports_json(payload)[0] +@pytest.mark.parametrize("case", SHARED_NUMERIC_CASES, ids=lambda case: case["label"]) +def test_parse_airports_json_numeric_coercion(case): + payload = json.dumps({"rows": [{ + "name": "X", "iata": "XXX", "icao": "XXXX", "country": "Spain", + "lat": case["input"], "lon": case["input"], "alt": case["input"], + }]}) + airport = parse_airports_json(payload)[0] - assert airport.latitude is None, value - assert airport.longitude is None, value - assert airport.altitude is None, value + assert airport.latitude == case["expected"] + assert airport.longitude == case["expected"] + assert airport.altitude == case["expected"] def test_parse_airports_json_country_spelling_and_slug_round_trip(): diff --git a/testdata/numeric-coercion.json b/testdata/numeric-coercion.json new file mode 100644 index 0000000..fc11f57 --- /dev/null +++ b/testdata/numeric-coercion.json @@ -0,0 +1,147 @@ +{ + "comment": "Shared by nodejs/tests/testParsersOffline.js and python/tests/test_parsers_offline.py. Every case must coerce identically in both ports, so add cases here instead of to one language. expected null means the value is unusable and must not become a number: 0 in particular would place the airport in the Gulf of Guinea.", + "cases": [ + { + "label": "empty string", + "input": "", + "expected": null + }, + { + "label": "single space", + "input": " ", + "expected": null + }, + { + "label": "only spaces", + "input": " ", + "expected": null + }, + { + "label": "json null", + "input": null, + "expected": null + }, + { + "label": "boolean true", + "input": true, + "expected": null + }, + { + "label": "boolean false", + "input": false, + "expected": null + }, + { + "label": "empty array", + "input": [], + "expected": null + }, + { + "label": "array holding a number", + "input": [ + 43 + ], + "expected": null + }, + { + "label": "empty object", + "input": {}, + "expected": null + }, + { + "label": "letters", + "input": "abc", + "expected": null + }, + { + "label": "coordinate with a hemisphere suffix", + "input": "43.30 N", + "expected": null + }, + { + "label": "hexadecimal", + "input": "0x10", + "expected": null + }, + { + "label": "digits grouped with underscores", + "input": "1_000", + "expected": null + }, + { + "label": "the word inf", + "input": "inf", + "expected": null + }, + { + "label": "the word NaN", + "input": "NaN", + "expected": null + }, + { + "label": "exponent overflowing a double", + "input": "1e999", + "expected": null + }, + { + "label": "negative exponent overflowing a double", + "input": "-1e999", + "expected": null + }, + { + "label": "400 plain digits", + "input": "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", + "expected": null + }, + { + "label": "negative decimal", + "input": "-23.4", + "expected": -23.4 + }, + { + "label": "whole number as a string", + "input": "2436", + "expected": 2436 + }, + { + "label": "whole number as a number", + "input": 2436, + "expected": 2436 + }, + { + "label": "explicit plus sign", + "input": "+5", + "expected": 5 + }, + { + "label": "leading decimal point", + "input": ".5", + "expected": 0.5 + }, + { + "label": "trailing decimal point", + "input": "5.", + "expected": 5 + }, + { + "label": "scientific notation", + "input": "1e3", + "expected": 1000 + }, + { + "label": "padded with spaces", + "input": " 12 ", + "expected": 12 + }, + { + "label": "integer past 2**53", + "input": "9007199254740993", + "expected": 9007199254740992 + }, + { + "label": "a genuine zero", + "input": 0, + "expected": 0 + } + ] +} From 9c1b261df010badd7b3af26f3b08d0a2b623c934 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 19:40:19 -0300 Subject: [PATCH 06/22] test: trim the coercion table back into each port The shared testdata/ table was more machinery than this earns: a new top-level directory, 28 cases, and workflow path filters to keep in sync, all for one coercion helper. The table is back inside each port's test file, trimmed to the nine inputs that either caught a real bug or pin a stated invariant, with a pointer to its twin so the two stay in step. Two real divergences found while doing it, both in the Python port: - The MAX_EXACT_INTEGER clamp only covered strings, so a native JSON integer above 2**53 returned exactly while Node's JSON.parse had already rounded it: {"lat": 9007199254740993} read as ...993 in Python and ...992 in Node. The int branch clamps too now. - row.get("name", "") only defaults on a missing key, not on JSON null, where Node's `?? ""` defaults on both. A null country therefore arrived as None and would have made the new live test's re.search() raise TypeError rather than fail an assertion. Now `or ""`. --- nodejs/tests/testParsersOffline.js | 34 +++++-- python/FlightRadarAPI/parsers.py | 12 ++- python/tests/test_parsers_offline.py | 47 ++++++--- testdata/numeric-coercion.json | 147 --------------------------- 4 files changed, 69 insertions(+), 171 deletions(-) delete mode 100644 testdata/numeric-coercion.json diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index f0a2e6b..4ed340f 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -16,11 +16,20 @@ const { parseAirlinesHtml, parseAirportsJson, countryToSlug } = require("../Flig const FIXTURES = path.join(__dirname, "fixtures"); const load = (name) => fs.readFileSync(path.join(FIXTURES, name), "utf-8"); -// Coercion cases live outside the port so both languages are held to the same -// table; see the `comment` field in the file itself. -const SHARED_NUMERIC_CASES = JSON.parse( - fs.readFileSync(path.join(__dirname, "..", "..", "testdata", "numeric-coercion.json"), "utf-8"), -).cases; +// Every case here caught a real bug or pins a stated invariant. Keep it in step +// with the same list in python/tests/test_parsers_offline.py: the two ports have +// drifted apart on exactly these inputs before. +const NUMERIC_CASES = [ + ["whitespace", " ", null], + ["an array holding a number", [43], null], + ["a coordinate with a hemisphere suffix", "43.30 N", null], + ["an exponent overflowing a double", "1e999", null], + ["400 plain digits", "1".repeat(400), null], + ["a whole number as a string", "2436", 2436], + ["an integer past 2**53", 9007199254740993, 9007199254740992], + ["a negative decimal", "-23.4", -23.4], + ["a genuine zero", 0, 0], +]; describe("parseAirlinesHtml (offline)", function() { @@ -144,6 +153,17 @@ describe("parseAirportsJson (offline)", function() { expect(parseAirportsJson(load("airports.json"), ["atlantis"])).to.deep.equal([]); }); + it("reads null text fields as empty strings", function() { + // A None here would break any caller that treats these as strings, e.g. + // getCountryFlag(airport.country). + const payload = JSON.stringify({ rows: [{ + name: null, iata: null, icao: null, country: null, lat: 1, lon: 2, alt: 3, + }] }); + const [airport] = parseAirportsJson(payload); + + expect([airport.name, airport.iata, airport.icao, airport.country]).to.deep.equal(["", "", "", ""]); + }); + it("parses a body that arrived as raw bytes", function() { // request() returns an ArrayBuffer when the response carries an // unexpected content-type; the JSON inside is still good. @@ -162,8 +182,8 @@ describe("parseAirportsJson (offline)", function() { }); -describe("parseAirportsJson numeric coercion (shared cases)", function() { - for (const { label, input, expected } of SHARED_NUMERIC_CASES) { +describe("parseAirportsJson numeric coercion", function() { + for (const [label, input, expected] of NUMERIC_CASES) { it(`reads ${label} as ${JSON.stringify(expected)}`, function() { const payload = JSON.stringify({ rows: [{ name: "X", iata: "XXX", icao: "XXXX", country: "Spain", diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 6709d15..2a008cf 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -104,7 +104,7 @@ def _to_number(value: object) -> Optional[Union[int, float]]: return None if isinstance(value, int): - return value + return value if abs(value) <= MAX_EXACT_INTEGER else float(value) if isinstance(value, float): return value if math.isfinite(value) else None @@ -186,13 +186,15 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li ) airports.append(Airport(basic_info={ - "name": row.get("name", ""), - "icao": row.get("icao", ""), - "iata": row.get("iata", ""), + # `or ""` rather than a get() default: a JSON null must not survive + # as None, which is what Node's `?? ""` guarantees on its side. + "name": row.get("name") or "", + "icao": row.get("icao") or "", + "iata": row.get("iata") or "", "lat": latitude, "lon": longitude, "alt": _to_number(row.get("alt")), - "country": row.get("country", ""), + "country": row.get("country") or "", })) if wanted is not None: diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index d902723..617e160 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -17,12 +17,20 @@ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") -# Coercion cases live outside the port so both languages are held to the same -# table; see the `comment` field in the file itself. -_SHARED = os.path.join(os.path.dirname(__file__), "..", "..", "testdata", "numeric-coercion.json") - -with open(_SHARED, "rb") as _f: - SHARED_NUMERIC_CASES = json.load(_f)["cases"] +# Every case here caught a real bug or pins a stated invariant. Keep it in step +# with the same list in nodejs/tests/testParsersOffline.js: the two ports have +# drifted apart on exactly these inputs before. +NUMERIC_CASES = [ + ("whitespace", " ", None), + ("an array holding a number", [43], None), + ("a coordinate with a hemisphere suffix", "43.30 N", None), + ("an exponent overflowing a double", "1e999", None), + ("400 plain digits", "1" * 400, None), + ("a whole number as a string", "2436", 2436), + ("an integer past 2**53", 9007199254740993, 9007199254740992), + ("a negative decimal", "-23.4", -23.4), + ("a genuine zero", 0, 0), +] def _load(name: str) -> bytes: @@ -127,17 +135,32 @@ def test_parse_airports_json_rejects_numeric_looking_junk(): assert isinstance(airport.altitude, int) -@pytest.mark.parametrize("case", SHARED_NUMERIC_CASES, ids=lambda case: case["label"]) -def test_parse_airports_json_numeric_coercion(case): +@pytest.mark.parametrize( + "value,expected", + [(case[1], case[2]) for case in NUMERIC_CASES], + ids=[case[0] for case in NUMERIC_CASES], +) +def test_parse_airports_json_numeric_coercion(value, expected): payload = json.dumps({"rows": [{ "name": "X", "iata": "XXX", "icao": "XXXX", "country": "Spain", - "lat": case["input"], "lon": case["input"], "alt": case["input"], + "lat": value, "lon": value, "alt": value, + }]}) + airport = parse_airports_json(payload)[0] + + assert airport.latitude == expected + assert airport.longitude == expected + assert airport.altitude == expected + + +def test_parse_airports_json_null_text_fields_become_empty_strings(): + """A None here would break any caller that treats these as strings, e.g. + get_country_flag(airport.country).""" + payload = json.dumps({"rows": [{ + "name": None, "iata": None, "icao": None, "country": None, "lat": 1, "lon": 2, "alt": 3, }]}) airport = parse_airports_json(payload)[0] - assert airport.latitude == case["expected"] - assert airport.longitude == case["expected"] - assert airport.altitude == case["expected"] + assert [airport.name, airport.iata, airport.icao, airport.country] == ["", "", "", ""] def test_parse_airports_json_country_spelling_and_slug_round_trip(): diff --git a/testdata/numeric-coercion.json b/testdata/numeric-coercion.json deleted file mode 100644 index fc11f57..0000000 --- a/testdata/numeric-coercion.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "comment": "Shared by nodejs/tests/testParsersOffline.js and python/tests/test_parsers_offline.py. Every case must coerce identically in both ports, so add cases here instead of to one language. expected null means the value is unusable and must not become a number: 0 in particular would place the airport in the Gulf of Guinea.", - "cases": [ - { - "label": "empty string", - "input": "", - "expected": null - }, - { - "label": "single space", - "input": " ", - "expected": null - }, - { - "label": "only spaces", - "input": " ", - "expected": null - }, - { - "label": "json null", - "input": null, - "expected": null - }, - { - "label": "boolean true", - "input": true, - "expected": null - }, - { - "label": "boolean false", - "input": false, - "expected": null - }, - { - "label": "empty array", - "input": [], - "expected": null - }, - { - "label": "array holding a number", - "input": [ - 43 - ], - "expected": null - }, - { - "label": "empty object", - "input": {}, - "expected": null - }, - { - "label": "letters", - "input": "abc", - "expected": null - }, - { - "label": "coordinate with a hemisphere suffix", - "input": "43.30 N", - "expected": null - }, - { - "label": "hexadecimal", - "input": "0x10", - "expected": null - }, - { - "label": "digits grouped with underscores", - "input": "1_000", - "expected": null - }, - { - "label": "the word inf", - "input": "inf", - "expected": null - }, - { - "label": "the word NaN", - "input": "NaN", - "expected": null - }, - { - "label": "exponent overflowing a double", - "input": "1e999", - "expected": null - }, - { - "label": "negative exponent overflowing a double", - "input": "-1e999", - "expected": null - }, - { - "label": "400 plain digits", - "input": "1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111", - "expected": null - }, - { - "label": "negative decimal", - "input": "-23.4", - "expected": -23.4 - }, - { - "label": "whole number as a string", - "input": "2436", - "expected": 2436 - }, - { - "label": "whole number as a number", - "input": 2436, - "expected": 2436 - }, - { - "label": "explicit plus sign", - "input": "+5", - "expected": 5 - }, - { - "label": "leading decimal point", - "input": ".5", - "expected": 0.5 - }, - { - "label": "trailing decimal point", - "input": "5.", - "expected": 5 - }, - { - "label": "scientific notation", - "input": "1e3", - "expected": 1000 - }, - { - "label": "padded with spaces", - "input": " 12 ", - "expected": 12 - }, - { - "label": "integer past 2**53", - "input": "9007199254740993", - "expected": 9007199254740992 - }, - { - "label": "a genuine zero", - "input": 0, - "expected": 0 - } - ] -} From 47ae32f390c55cd5bdfa4339d16012b92649f95b Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 19:44:51 -0300 Subject: [PATCH 07/22] fix: stop _to_number from raising on unbounded JSON integers The clamp added in the previous commit called float() on any int above 2**53, and float() raises OverflowError -- not inf -- once the int exceeds the double range. A feed row carrying 400 digits as a raw JSON number therefore crashed get_airports() outright, where Node returns null. This is the fourth bug in a row in this one helper, each planted by the fix before it, so the shape changed rather than another branch being patched. Every value now funnels through a single float() conversion with one OverflowError guard, instead of three type branches each needing their own. Int-ness is recovered afterwards, and only when the round trip is lossless (`exact == number`), which is also what keeps -23.4 from truncating to -23. The table's "integer past 2**53" case was vacuous in Node -- a JS number literal is already rounded before it can reach the payload -- so it is replaced by a test that writes the numbers straight into the JSON text, the only way to hand both ports a value their parsers must survive rather than one already flattened for them. --- nodejs/tests/testParsersOffline.js | 15 ++++++++- python/FlightRadarAPI/parsers.py | 46 ++++++++++++++-------------- python/tests/test_parsers_offline.py | 15 ++++++++- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index 4ed340f..b76acdb 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -26,7 +26,6 @@ const NUMERIC_CASES = [ ["an exponent overflowing a double", "1e999", null], ["400 plain digits", "1".repeat(400), null], ["a whole number as a string", "2436", 2436], - ["an integer past 2**53", 9007199254740993, 9007199254740992], ["a negative decimal", "-23.4", -23.4], ["a genuine zero", 0, 0], ]; @@ -153,6 +152,20 @@ describe("parseAirportsJson (offline)", function() { expect(parseAirportsJson(load("airports.json"), ["atlantis"])).to.deep.equal([]); }); + it("survives numbers written raw in the JSON that no double can hold", function() { + // Built as text on purpose: a JS number literal is already rounded (or + // Infinity) before it can reach the payload, so only raw JSON exercises + // this. Python reaches it as an unbounded int, where float() overflows. + for (const literal of ["1".repeat(400), "9007199254740993", "-1".concat("1".repeat(399))]) { + const payload = `{"rows":[{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain","lat":${literal},"lon":1,"alt":2}]}`; + const [airport] = parseAirportsJson(payload); + + expect(airport.latitude, literal.slice(0, 12)).to.satisfy( + (latitude) => latitude === null || Number.isFinite(latitude), + ); + } + }); + it("reads null text fields as empty strings", function() { // A None here would break any caller that treats these as strings, e.g. // getCountryFlag(airport.country). diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 2a008cf..b3b4089 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -100,43 +100,43 @@ def _to_number(value: object) -> Optional[Union[int, float]]: stay ints so that altitudes read the same in both ports -- the feed sends `alt` as an int for most rows and as a string ("-1") for the rest. """ - if value is None or isinstance(value, bool): + # Booleans are ints in Python, and str() on a list or dict would invent a + # number: str([43]) == "43". + if isinstance(value, bool) or not isinstance(value, (int, float, str)): return None - if isinstance(value, int): - return value if abs(value) <= MAX_EXACT_INTEGER else float(value) + if isinstance(value, str): + stripped = value.strip() - if isinstance(value, float): - return value if math.isfinite(value) else None + # Decimal numbers only, so both ports accept and reject exactly the same + # strings: bare `float` would also take "1_000" and "inf". + if not NUMERIC_PATTERN.match(stripped): + return None - # Only strings are worth parsing; str() on anything else would invent a - # number, e.g. str([43]) == "43". - if not isinstance(value, str): - return None - - text = value.strip() + text: Union[int, float, str] = stripped + else: + text = value - # Decimal numbers only, so both ports accept and reject exactly the same - # strings: bare `float` would also take "1_000" and "inf". - if not NUMERIC_PATTERN.match(text): + # Every value funnels through one conversion, so overflow is handled once: + # Python ints have no ceiling, and both "1e999" and 400 plain digits reach + # here as something a double cannot hold. + try: + number = float(text) + except OverflowError: return None - # Float first: the pattern still admits values that overflow a double, both - # as exponents ("1e999") and as plain digits ("1" * 400). Python ints have no - # such ceiling, so checking them after int() would let those through. - number = float(text) - if not math.isfinite(number): return None try: exact = int(text) - except ValueError: + except (ValueError, OverflowError): return number - # JavaScript cannot represent integers past 2**53 - 1 exactly, so past that - # both ports report the same double instead of drifting apart. - return exact if abs(exact) <= MAX_EXACT_INTEGER else number + # Whole numbers stay ints so altitudes read the same in both ports, but only + # up to where JavaScript is still exact. `exact == number` keeps a decimal + # like -23.4 from being truncated to -23. + return exact if exact == number and abs(exact) <= MAX_EXACT_INTEGER else number def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[List[str]] = None) -> List[Airport]: diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index 617e160..0816981 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -9,6 +9,7 @@ """ import json +import math import os import pytest @@ -27,7 +28,6 @@ ("an exponent overflowing a double", "1e999", None), ("400 plain digits", "1" * 400, None), ("a whole number as a string", "2436", 2436), - ("an integer past 2**53", 9007199254740993, 9007199254740992), ("a negative decimal", "-23.4", -23.4), ("a genuine zero", 0, 0), ] @@ -152,6 +152,19 @@ def test_parse_airports_json_numeric_coercion(value, expected): assert airport.altitude == expected +@pytest.mark.parametrize("literal", ["1" * 400, "9007199254740993", "-" + "1" * 400]) +def test_parse_airports_json_survives_raw_numbers_no_double_can_hold(literal): + """Written as text on purpose: json.loads turns these into unbounded ints, + where float() raises OverflowError instead of returning inf.""" + payload = ( + '{"rows":[{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain",' + f'"lat":{literal},"lon":1,"alt":2}}]}}' + ) + latitude = parse_airports_json(payload)[0].latitude + + assert latitude is None or math.isfinite(latitude) + + def test_parse_airports_json_null_text_fields_become_empty_strings(): """A None here would break any caller that treats these as strings, e.g. get_country_flag(airport.country).""" From a1dc3b957c04aeb31d340fa1a841c5d8d67bf8a7 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 19:52:36 -0300 Subject: [PATCH 08/22] fix: keep airport text fields as strings in both ports Fuzzing the text path -- the half the numeric corpus never touched -- showed the two ports disagreeing on nearly every non-string value the feed could put in name, iata, icao or country. `?? ""` only defaults on null and undefined, so Node stored 0, false, [] and {} verbatim; `or ""` defaults on every falsy value, so Python stored True and 123 but blanked the rest. Both ports now accept a string and blank anything else, which is what Airport documents and what callers assume: getCountryFlag(airport.country) would otherwise slugify an object into "object-object" and request that flag over the network. The feed has never sent a non-string here, so this is about the contract rather than observed breakage -- but it is four lines, and it closes the bogus-request path. --- nodejs/FlightRadarAPI/parsers.js | 24 +++++++++++++++++++----- nodejs/tests/testParsersOffline.js | 18 ++++++++++-------- python/FlightRadarAPI/parsers.py | 23 ++++++++++++++++------- python/tests/test_parsers_offline.py | 14 ++++++++------ 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index cdddc47..228f21e 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -110,6 +110,20 @@ function toNumber(value) { 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. * @@ -175,19 +189,19 @@ function parseAirportsJson(payload, countries = null) { if (latitude === null || longitude === null) { console.warn( - `parseAirportsJson: invalid coordinates for airport "${row["name"] ?? ""}" ` + + `parseAirportsJson: invalid coordinates for airport "${toStringField(row["name"])}" ` + `(lat=${row["lat"]}, lon=${row["lon"]}) — skipping position.`, ); } airports.push(new Airport({ - "name": row["name"] ?? "", - "icao": row["icao"] ?? "", - "iata": row["iata"] ?? "", + "name": toStringField(row["name"]), + "icao": toStringField(row["icao"]), + "iata": toStringField(row["iata"]), "lat": latitude, "lon": longitude, "alt": toNumber(row["alt"]), - "country": row["country"] ?? "", + "country": toStringField(row["country"]), })); } diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index b76acdb..7e271e2 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -166,15 +166,17 @@ describe("parseAirportsJson (offline)", function() { } }); - it("reads null text fields as empty strings", function() { - // A None here would break any caller that treats these as strings, e.g. - // getCountryFlag(airport.country). - const payload = JSON.stringify({ rows: [{ - name: null, iata: null, icao: null, country: null, lat: 1, lon: 2, alt: 3, - }] }); - const [airport] = parseAirportsJson(payload); + it("keeps text fields as strings whatever the feed sends", function() { + // Anything else breaks callers that treat these as strings, e.g. + // getCountryFlag(airport.country) slugifying an object. + for (const literal of ["null", "0", "false", "true", "[]", "{}", "123"]) { + const payload = `{"rows":[{"name":${literal},"iata":${literal},"icao":${literal},` + + `"country":${literal},"lat":1,"lon":2,"alt":3}]}`; + const [airport] = parseAirportsJson(payload); - expect([airport.name, airport.iata, airport.icao, airport.country]).to.deep.equal(["", "", "", ""]); + expect([airport.name, airport.iata, airport.icao, airport.country], literal) + .to.deep.equal(["", "", "", ""]); + } }); it("parses a body that arrived as raw bytes", function() { diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index b3b4089..3ab6f6d 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -91,6 +91,17 @@ def country_to_slug(country: Optional[str]) -> str: return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-") +def _to_text(value: object) -> str: + """ + Keep a text field as a string, or "" when the feed sends anything else. + + Airport documents these as strings and callers treat them as such: + get_country_flag(airport.country) would slugify a dict into "object-object" + and request that flag. + """ + return value if isinstance(value, str) else "" + + def _to_number(value: object) -> Optional[Union[int, float]]: """ Coerce a numeric feed field into a number, or None when it is unusable. @@ -182,19 +193,17 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li if latitude is None or longitude is None: _logger.warning( "parse_airports_json: invalid coordinates for airport %r (lat=%r, lon=%r) — skipping position.", - row.get("name", ""), row.get("lat"), row.get("lon"), + _to_text(row.get("name")), row.get("lat"), row.get("lon"), ) airports.append(Airport(basic_info={ - # `or ""` rather than a get() default: a JSON null must not survive - # as None, which is what Node's `?? ""` guarantees on its side. - "name": row.get("name") or "", - "icao": row.get("icao") or "", - "iata": row.get("iata") or "", + "name": _to_text(row.get("name")), + "icao": _to_text(row.get("icao")), + "iata": _to_text(row.get("iata")), "lat": latitude, "lon": longitude, "alt": _to_number(row.get("alt")), - "country": row.get("country") or "", + "country": _to_text(row.get("country")), })) if wanted is not None: diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index 0816981..da572f4 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -165,12 +165,14 @@ def test_parse_airports_json_survives_raw_numbers_no_double_can_hold(literal): assert latitude is None or math.isfinite(latitude) -def test_parse_airports_json_null_text_fields_become_empty_strings(): - """A None here would break any caller that treats these as strings, e.g. - get_country_flag(airport.country).""" - payload = json.dumps({"rows": [{ - "name": None, "iata": None, "icao": None, "country": None, "lat": 1, "lon": 2, "alt": 3, - }]}) +@pytest.mark.parametrize("literal", ["null", "0", "false", "true", "[]", "{}", "123"]) +def test_parse_airports_json_keeps_text_fields_as_strings(literal): + """Anything else breaks callers that treat these as strings, e.g. + get_country_flag(airport.country) slugifying a dict.""" + payload = ( + '{"rows":[{"name":%s,"iata":%s,"icao":%s,"country":%s,"lat":1,"lon":2,"alt":3}]}' + % (literal, literal, literal, literal) + ) airport = parse_airports_json(payload)[0] assert [airport.name, airport.iata, airport.icao, airport.country] == ["", "", "", ""] From 85c38391b3995c85ecce8af704a1485daff8b771 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 21:40:14 -0300 Subject: [PATCH 09/22] test: pin the Countries enum against the feed's country names The filter compares a slug derived from the feed's display name ("United States") against the enum's URL path slugs ("united-states"). Those are two separate FR24 vocabularies that happen to agree today -- verified 228/228 in both directions -- but nothing detected a divergence. A rename on either side would silently return [] for that country behind a warning a caller may never see, and display names move more freely than URL paths. Both ports now assert the agreement inside the test that already downloads the whole feed, so it costs no extra request. Verified by adding a bogus enum entry: the test fails and names the country. --- nodejs/tests/testApi.js | 14 ++++++++++++++ python/tests/test_api.py | 11 +++++++++++ 2 files changed, 25 insertions(+) diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index 4be7742..3dfc4cf 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -1,4 +1,5 @@ const { FlightRadar24API, Flight, Entity, FlightTrackerConfig, Countries, version } = require(".."); +const { countryToSlug } = require("../FlightRadarAPI/parsers"); const expect = require("chai").expect; @@ -62,6 +63,19 @@ describe("Testing FlightRadarAPI version " + version, function() { const tricky = results.find((airport) => /[^a-z ]/i.test(airport.country)) ?? results[0]; expect(await frApi.getCountryFlag(tricky.country), tricky.country).to.not.equal(null); }); + + it("Expected the Countries enum and the feed to name the same countries.", async function() { + // The filter matches a slug derived from the feed's display name + // ("United States") against the enum's URL slugs ("united-states"). + // Those are two different FR24 vocabularies that happen to agree, so + // pin it: a rename on either side silently empties one country. + const feed = new Set((await frApi.getAirports()).map((airport) => countryToSlug(airport.country))); + const values = Object.values(Countries); + + expect(values.filter((value) => !feed.has(value)), "enum values absent from the feed").to.deep.equal([]); + expect([...feed].filter((slug) => !values.includes(slug)), "feed countries absent from the enum") + .to.deep.equal([]); + }); }); describe("Getting Zones", function() { diff --git a/python/tests/test_api.py b/python/tests/test_api.py index 81c7c29..86387b8 100644 --- a/python/tests/test_api.py +++ b/python/tests/test_api.py @@ -6,6 +6,7 @@ from FlightRadarAPI import Entity, Flight from FlightRadarAPI.errors import CloudflareError, LoginError +from FlightRadarAPI.parsers import country_to_slug from package import Countries, FlightRadar24API, version from util import repeat_test @@ -67,6 +68,16 @@ def test_get_airports_without_countries(expect=1800): tricky = next((a for a in results if re.search(r"[^a-zA-Z ]", a.country)), results[0]) assert fr_api.get_country_flag(tricky.country) is not None, tricky.country + # The filter matches a slug derived from the feed's display name ("United + # States") against the enum's URL slugs ("united-states"). Those are two + # different FR24 vocabularies that happen to agree, so pin it: a rename on + # either side silently empties one country. + feed = {country_to_slug(a.country) for a in results} + values = {c.value for c in Countries} + + assert not (values - feed), "enum values absent from the feed: %s" % sorted(values - feed) + assert not (feed - values), "feed countries absent from the enum: %s" % sorted(feed - values) + @repeat_test(**repeat_test_config) def test_get_zones(expect=5): From 9f62dad09eb0a45ee7e6515597764f998c119ff6 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 21:49:23 -0300 Subject: [PATCH 10/22] fix: close the remaining port divergences in coordinate handling Five findings from an independent review, all verified against both ports before changing anything: - A row with one unusable coordinate kept the other, so an airport could carry a latitude and a null longitude. Anything gating on `if airport.latitude` read that as located, and the old HTML parser had nulled both. One bad coordinate now drops the whole position, which is also what the log line always claimed to do. - The "both ports accept the same strings" invariant did not hold for non-ASCII input: Python's \d matched Arabic-Indic digits, str.strip() drops U+001C-U+001F where String.trim() keeps them, and trim() drops U+FEFF where strip() keeps it. The pattern is ASCII-only now (re.ASCII on the Python side) and both ports trim one explicit space set. My earlier fuzzing missed this because the corpus was ASCII. - country_to_slug(0) was "" in Python and "0" in Node -- `or ""` versus `?? ""` again, this time in the slugifier. - Coordinate warnings were logged per row. The feed carries every airport, so a degraded response meant hundreds of lines per call; they are now one summary line with a sample. - Demoting the decompression warning by Content-Encoding also silenced genuinely undecodable brotli, leaving the parser to misreport it as invalid JSON. For text and JSON payloads the body is now checked instead of the header, so a broken one still warns; binary bodies keep the old behaviour, having no such tell. Not changed: the review's example for the country vocabulary finding does not reproduce -- the enum says "czechia" and the feed says "Czechia", which slugify alike. The reviewer could not reach the feed (403) and assumed "Czech Republic". The underlying concern was real and is already pinned by the enum-versus-feed test. --- nodejs/FlightRadarAPI/parsers.js | 34 ++++++++++++++++++++-------- nodejs/tests/testParsersOffline.js | 22 +++++++++++++++++- python/FlightRadarAPI/parsers.py | 31 +++++++++++++++++++------ python/FlightRadarAPI/request.py | 20 ++++++++++++---- python/tests/test_parsers_offline.py | 20 +++++++++++++++- 5 files changed, 104 insertions(+), 23 deletions(-) diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index 228f21e..621c1ab 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -85,10 +85,14 @@ function countryToSlug(country) { .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); } -// Decimal numbers only, so both ports accept and reject exactly the same +// ASCII decimal numbers only, so both ports accept and reject exactly the same // strings. `Number` on its own would take "0x10" and "" (as 0) where Python's // `float` refuses them, and `parseFloat` would read "43.30 N" as 43.3. -const NUMERIC_PATTERN = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/; +const NUMERIC_PATTERN = /^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$/; + +// Trimmed explicitly: String.trim() also strips U+FEFF, which Python's +// str.strip() keeps, while str.strip() drops U+001C-U+001F, which trim() keeps. +const SURROUNDING_SPACE = /^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g; /** * Coerce a numeric feed field into a number, or null when it is unusable. @@ -105,7 +109,7 @@ function toNumber(value) { // `[43]` through as 43, which Python's `float` rejects. if (typeof value !== "string") return null; - const text = value.trim(); + const text = value.replace(SURROUNDING_SPACE, ""); return NUMERIC_PATTERN.test(text) ? toNumber(Number(text)) : null; } @@ -172,6 +176,7 @@ function parseAirportsJson(payload, countries = null) { const wanted = countries ? new Set(countries.map(countryToSlug)) : null; const matched = new Set(); + const unpositioned = []; const airports = []; for (const row of rows) { @@ -184,14 +189,15 @@ function parseAirportsJson(payload, countries = null) { matched.add(slug); } - const latitude = toNumber(row["lat"]); - const longitude = toNumber(row["lon"]); + let latitude = toNumber(row["lat"]); + let longitude = toNumber(row["lon"]); + // Half a position is not a position: a consumer gating on `latitude` + // would treat the airport as located and read a null longitude. if (latitude === null || longitude === null) { - console.warn( - `parseAirportsJson: invalid coordinates for airport "${toStringField(row["name"])}" ` + - `(lat=${row["lat"]}, lon=${row["lon"]}) — skipping position.`, - ); + latitude = null; + longitude = null; + unpositioned.push(toStringField(row["name"])); } airports.push(new Airport({ @@ -205,6 +211,16 @@ function parseAirportsJson(payload, countries = null) { })); } + // Summarised rather than logged per row: the feed carries every airport, so + // a degraded response would otherwise print hundreds of lines per call. + 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)); diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index 7e271e2..59cff5b 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -143,11 +143,31 @@ describe("parseAirportsJson (offline)", function() { }] }); const [airport] = parseAirportsJson(payload); + // One unusable coordinate drops the whole position: half a position + // would read as located to anything gating on `latitude`. expect(airport.latitude).to.equal(null); - expect(airport.longitude).to.be.closeTo(-8.37725, 1e-6); + expect(airport.longitude).to.equal(null); + // Altitude is independent of the position and survives. expect(airport.altitude).to.equal(-1); }); + it("agrees with the Python port on Unicode digits and stray control bytes", function() { + // Python's \d matches "٤٣" and str.strip() drops U+001C, while + // String.trim() drops U+FEFF -- each port used to accept what the other + // rejected. Only ASCII digits and an explicit space set are allowed now. + const read = (value) => { + const payload = JSON.stringify({ rows: [{ + name: "X", iata: "XXX", icao: "XXXX", country: "Spain", lat: value, lon: value, alt: value, + }] }); + return parseAirportsJson(payload)[0].altitude; + }; + + expect(read("\u0664\u0663"), "Arabic-Indic digits").to.equal(null); + expect(read("\ufeff43"), "leading U+FEFF").to.equal(null); + expect(read("\u001c43"), "leading U+001C").to.equal(null); + expect(read(" 43 "), "plain spaces still trimmed").to.equal(43); + }); + it("returns an empty list for an unknown country", function() { expect(parseAirportsJson(load("airports.json"), ["atlantis"])).to.deep.equal([]); }); diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 3ab6f6d..5b0f412 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -13,7 +13,13 @@ _logger = logging.getLogger(__name__) -NUMERIC_PATTERN = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$") +# ASCII only: \d would otherwise match Unicode digits such as "٤٣", which the +# Node port rejects. +NUMERIC_PATTERN = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", re.ASCII) + +# Stripped explicitly: str.strip() also drops U+001C-U+001F, which JavaScript's +# String.trim() keeps, while trim() drops U+FEFF, which str.strip() keeps. +SURROUNDING_SPACE = " \t\n\r\f\v" # Number.MAX_SAFE_INTEGER, the largest integer JavaScript holds exactly. MAX_EXACT_INTEGER = 2 ** 53 - 1 @@ -86,7 +92,9 @@ def country_to_slug(country: Optional[str]) -> str: """ # The feed is ASCII today ("Curacao"); stripping diacritics keeps the match # working if that ever changes. - decomposed = unicodedata.normalize("NFKD", str(country or "")) + # `is None` rather than a truthiness check: str(0 or "") is "" in Python but + # String(0 ?? "") is "0" in Node, and the two must slugify alike. + decomposed = unicodedata.normalize("NFKD", "" if country is None else str(country)) ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char)) return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-") @@ -117,7 +125,7 @@ def _to_number(value: object) -> Optional[Union[int, float]]: return None if isinstance(value, str): - stripped = value.strip() + stripped = value.strip(SURROUNDING_SPACE) # Decimal numbers only, so both ports accept and reject exactly the same # strings: bare `float` would also take "1_000" and "inf". @@ -174,6 +182,7 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li wanted = {country_to_slug(country) for country in countries} if countries is not None else None matched = set() + unpositioned = [] airports = [] for row in rows: @@ -190,11 +199,11 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li latitude = _to_number(row.get("lat")) longitude = _to_number(row.get("lon")) + # Half a position is not a position: a consumer gating on `latitude` + # would treat the airport as located and read a None longitude. if latitude is None or longitude is None: - _logger.warning( - "parse_airports_json: invalid coordinates for airport %r (lat=%r, lon=%r) — skipping position.", - _to_text(row.get("name")), row.get("lat"), row.get("lon"), - ) + latitude = longitude = None + unpositioned.append(_to_text(row.get("name"))) airports.append(Airport(basic_info={ "name": _to_text(row.get("name")), @@ -206,6 +215,14 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li "country": _to_text(row.get("country")), })) + # Summarised rather than logged per row: the feed carries every airport, so a + # degraded response would otherwise print hundreds of lines per call. + if unpositioned: + _logger.warning( + "parse_airports_json: %d airport(s) had unusable coordinates and carry no position (e.g. %s).", + len(unpositioned), ", ".join(repr(name) for name in unpositioned[:3]), + ) + if wanted is not None: missing = sorted(wanted - matched) diff --git a/python/FlightRadarAPI/request.py b/python/FlightRadarAPI/request.py index 57684bb..8f86658 100644 --- a/python/FlightRadarAPI/request.py +++ b/python/FlightRadarAPI/request.py @@ -234,11 +234,21 @@ def get_content(self) -> Union[Dict, bytes]: content = decode(content) except Exception as err: # curl_cffi decompresses gzip and br transparently while leaving the - # header in place, so failing here is routine rather than actionable. - # A body that does carry the gzip magic number is genuinely corrupt - # and still warns; brotli has no magic number to check. - corrupt_gzip = content_encoding == "gzip" and content.startswith(b"\x1f\x8b") - transport_decoded = content_encoding in ("gzip", "br") and not corrupt_gzip + # header in place, so failing here is usually routine. Decide by + # looking at the body rather than trusting the header: a text payload + # that will not decode is genuinely broken and must stay a warning, + # since the caller would otherwise only see the parser complain that + # the JSON is invalid. Binary bodies carry no such tell, and a gzip + # magic number under a "gzip" header means real corruption. + if content_type.startswith(("application/json", "text/")): + try: + content.decode("utf-8") + transport_decoded = True + except UnicodeDecodeError: + transport_decoded = False + else: + corrupt_gzip = content_encoding == "gzip" and content.startswith(b"\x1f\x8b") + transport_decoded = content_encoding in ("gzip", "br") and not corrupt_gzip _logger.log( logging.DEBUG if transport_decoded else logging.WARNING, diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index da572f4..eb2ec9b 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -129,12 +129,30 @@ def test_parse_airports_json_rejects_numeric_looking_junk(): }]}) airport = parse_airports_json(payload)[0] + # One unusable coordinate drops the whole position: half a position would + # read as located to anything gating on `latitude`. assert airport.latitude is None - assert abs(airport.longitude - (-8.37725)) < 1e-6 + assert airport.longitude is None + # Altitude is independent of the position and survives. assert airport.altitude == -1 assert isinstance(airport.altitude, int) +@pytest.mark.parametrize("value,expected", [ + ("٤٣", None), # Arabic-Indic digits, which \d matched before re.ASCII + ("43", None), # U+FEFF, which String.trim() drops and str.strip() keeps + ("43", None), # U+001C, which str.strip() drops and String.trim() keeps + (" 43 ", 43), # plain spaces are still trimmed +]) +def test_parse_airports_json_agrees_with_node_on_unicode(value, expected): + payload = json.dumps({"rows": [{ + "name": "X", "iata": "XXX", "icao": "XXXX", "country": "Spain", + "lat": value, "lon": value, "alt": value, + }]}) + + assert parse_airports_json(payload)[0].altitude == expected + + @pytest.mark.parametrize( "value,expected", [(case[1], case[2]) for case in NUMERIC_CASES], From 90a9ff72888727f58f166b40ebace35284d9fc68 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 21:55:38 -0300 Subject: [PATCH 11/22] docs: cut the comment volume across the PR Added comment lines drop from 101 to 41. Multi-line rationale blocks become one line where a maintainer could otherwise "simplify" the code back into a bug, and disappear where the code, a docstring or a type already said it. No behaviour change: both suites and all linters pass unchanged. --- nodejs/FlightRadarAPI/api.js | 4 +--- nodejs/FlightRadarAPI/core.js | 3 +-- nodejs/FlightRadarAPI/index.d.ts | 1 - nodejs/FlightRadarAPI/parsers.js | 19 ++++++----------- nodejs/tests/testApi.js | 10 +++------ nodejs/tests/testParsersOffline.js | 26 +++++++---------------- python/FlightRadarAPI/api.py | 9 ++------ python/FlightRadarAPI/core.py | 3 +-- python/FlightRadarAPI/parsers.py | 31 ++++++++-------------------- python/FlightRadarAPI/request.py | 9 ++------ python/tests/test_api.py | 10 +++------ python/tests/test_parsers_offline.py | 18 +++++----------- 12 files changed, 40 insertions(+), 103 deletions(-) diff --git a/nodejs/FlightRadarAPI/api.js b/nodejs/FlightRadarAPI/api.js index d256454..0c769a3 100644 --- a/nodejs/FlightRadarAPI/api.js +++ b/nodejs/FlightRadarAPI/api.js @@ -313,9 +313,7 @@ class FlightRadar24API { * @return {Promise<[object, string] | null>} */ async getCountryFlag(country) { - // Slugified the same way as the airports feed, so the `country` of an - // Airport can be handed straight back here: FR24 spells some names with - // parentheses ("Myanmar (Burma)") that must not reach the URL. + // Same slugifier as the feed, which spells some names "Myanmar (Burma)". const flagUrl = Core.countryFlagUrl(countryToSlug(country)); const headers = { ...Core.imageHeaders }; diff --git a/nodejs/FlightRadarAPI/core.js b/nodejs/FlightRadarAPI/core.js index f9f75cd..638362c 100644 --- a/nodejs/FlightRadarAPI/core.js +++ b/nodejs/FlightRadarAPI/core.js @@ -47,8 +47,7 @@ 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 and no longer carry a scrapable list. + // Every airport in one payload; the per-country pages above are client-rendered. airportsJsonUrl: `${FR24_BASE}/_json/airports.php`, airlinesDataUrl: `${FR24_BASE}/data/airlines`, diff --git a/nodejs/FlightRadarAPI/index.d.ts b/nodejs/FlightRadarAPI/index.d.ts index 00869c6..fc0406c 100644 --- a/nodejs/FlightRadarAPI/index.d.ts +++ b/nodejs/FlightRadarAPI/index.d.ts @@ -312,7 +312,6 @@ export class Entity { * Airport representation. */ export class Airport extends Entity { - // null when FR24 reports no usable coordinates for the airport. latitude: number | null; longitude: number | null; altitude: number | null; diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index 621c1ab..abd4152 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -78,20 +78,16 @@ function parseAirlinesHtml(html) { * @return {string} URL-friendly form matching the Countries enum (e.g. "united-states") */ function countryToSlug(country) { - // The feed is ASCII today ("Curacao"); stripping diacritics keeps the match - // working if that ever changes. + // Diacritics stripped so a future "Curaçao" still matches "curacao". return String(country ?? "") .normalize("NFKD").replace(/[\u0300-\u036f]/g, "") .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); } -// ASCII decimal numbers only, so both ports accept and reject exactly the same -// strings. `Number` on its own would take "0x10" and "" (as 0) where Python's -// `float` refuses them, and `parseFloat` would read "43.30 N" as 43.3. +// ASCII decimals only, so both ports accept and reject the same strings. const NUMERIC_PATTERN = /^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$/; -// Trimmed explicitly: String.trim() also strips U+FEFF, which Python's -// str.strip() keeps, while str.strip() drops U+001C-U+001F, which trim() keeps. +// 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; /** @@ -105,8 +101,7 @@ const SURROUNDING_SPACE = /^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g; */ function toNumber(value) { if (typeof value === "number") return Number.isFinite(value) ? value : null; - // Only strings are worth parsing. Stringifying anything else would let - // `[43]` through as 43, which Python's `float` rejects. + // Not stringified: String([43]) would pass as 43. if (typeof value !== "string") return null; const text = value.replace(SURROUNDING_SPACE, ""); @@ -192,8 +187,7 @@ function parseAirportsJson(payload, countries = null) { let latitude = toNumber(row["lat"]); let longitude = toNumber(row["lon"]); - // Half a position is not a position: a consumer gating on `latitude` - // would treat the airport as located and read a null longitude. + // One bad coordinate drops both: half a position reads as located. if (latitude === null || longitude === null) { latitude = null; longitude = null; @@ -211,8 +205,7 @@ function parseAirportsJson(payload, countries = null) { })); } - // Summarised rather than logged per row: the feed carries every airport, so - // a degraded response would otherwise print hundreds of lines per call. + // 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( diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index 3dfc4cf..ccb96ae 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -57,18 +57,14 @@ describe("Testing FlightRadarAPI version " + version, function() { expect(results.length).to.be.above(expected); expect(new Set(results.map((airport) => airport.country)).size).to.be.above(countries.length); - // Regression: FR24 spells some countries with characters that used to - // reach the flag URL verbatim ("Myanmar (Burma)"). Picked from the - // response rather than hard-coded, so the guard survives a rename. + // Discovered, not hard-coded: FR24 spells some names "Myanmar (Burma)". const tricky = results.find((airport) => /[^a-z ]/i.test(airport.country)) ?? results[0]; expect(await frApi.getCountryFlag(tricky.country), tricky.country).to.not.equal(null); }); it("Expected the Countries enum and the feed to name the same countries.", async function() { - // The filter matches a slug derived from the feed's display name - // ("United States") against the enum's URL slugs ("united-states"). - // Those are two different FR24 vocabularies that happen to agree, so - // pin it: a rename on either side silently empties one country. + // Two FR24 vocabularies that happen to agree; a rename on either side + // silently empties one country. const feed = new Set((await frApi.getAirports()).map((airport) => countryToSlug(airport.country))); const values = Object.values(Countries); diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index 59cff5b..ac41f52 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -16,9 +16,7 @@ const { parseAirlinesHtml, parseAirportsJson, countryToSlug } = require("../Flig const FIXTURES = path.join(__dirname, "fixtures"); const load = (name) => fs.readFileSync(path.join(FIXTURES, name), "utf-8"); -// Every case here caught a real bug or pins a stated invariant. Keep it in step -// with the same list in python/tests/test_parsers_offline.py: the two ports have -// drifted apart on exactly these inputs before. +// Keep in step with the same list in python/tests/test_parsers_offline.py. const NUMERIC_CASES = [ ["whitespace", " ", null], ["an array holding a number", [43], null], @@ -126,7 +124,6 @@ describe("parseAirportsJson (offline)", function() { expect(ann.country).to.equal("Myanmar (Burma)"); expect(countryToSlug(ann.country)).to.equal("myanmar-burma"); - // Feed sends `alt` as a string on a few rows; it must read as a number. expect(ann.altitude).to.equal(43); }); @@ -143,18 +140,14 @@ describe("parseAirportsJson (offline)", function() { }] }); const [airport] = parseAirportsJson(payload); - // One unusable coordinate drops the whole position: half a position - // would read as located to anything gating on `latitude`. + // One bad coordinate drops both; altitude is independent and survives. expect(airport.latitude).to.equal(null); expect(airport.longitude).to.equal(null); - // Altitude is independent of the position and survives. expect(airport.altitude).to.equal(-1); }); it("agrees with the Python port on Unicode digits and stray control bytes", function() { - // Python's \d matches "٤٣" and str.strip() drops U+001C, while - // String.trim() drops U+FEFF -- each port used to accept what the other - // rejected. Only ASCII digits and an explicit space set are allowed now. + // Each port used to accept what the other rejected. const read = (value) => { const payload = JSON.stringify({ rows: [{ name: "X", iata: "XXX", icao: "XXXX", country: "Spain", lat: value, lon: value, alt: value, @@ -173,9 +166,7 @@ describe("parseAirportsJson (offline)", function() { }); it("survives numbers written raw in the JSON that no double can hold", function() { - // Built as text on purpose: a JS number literal is already rounded (or - // Infinity) before it can reach the payload, so only raw JSON exercises - // this. Python reaches it as an unbounded int, where float() overflows. + // Written as text: a JS literal is already rounded before it reaches here. for (const literal of ["1".repeat(400), "9007199254740993", "-1".concat("1".repeat(399))]) { const payload = `{"rows":[{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain","lat":${literal},"lon":1,"alt":2}]}`; const [airport] = parseAirportsJson(payload); @@ -187,8 +178,7 @@ describe("parseAirportsJson (offline)", function() { }); it("keeps text fields as strings whatever the feed sends", function() { - // Anything else breaks callers that treat these as strings, e.g. - // getCountryFlag(airport.country) slugifying an object. + // Anything else breaks getCountryFlag(airport.country). for (const literal of ["null", "0", "false", "true", "[]", "{}", "123"]) { const payload = `{"rows":[{"name":${literal},"iata":${literal},"icao":${literal},` + `"country":${literal},"lat":1,"lon":2,"alt":3}]}`; @@ -200,8 +190,7 @@ describe("parseAirportsJson (offline)", function() { }); it("parses a body that arrived as raw bytes", function() { - // request() returns an ArrayBuffer when the response carries an - // unexpected content-type; the JSON inside is still good. + // request() returns an ArrayBuffer on an unexpected content-type. const json = load("airports.json"); for (const payload of [Buffer.from(json), new TextEncoder().encode(json), new TextEncoder().encode(json).buffer]) { @@ -244,8 +233,7 @@ describe("countryToSlug (offline)", function() { }); it("strips the parentheses FR24 uses, keeping country flag URLs valid", function() { - // Regression: `getCountryFlag(airport.country)` 404'd for these while the - // slug was built with a plain space-to-hyphen replacement. + // These 404'd while the slug was a plain space-to-hyphen replacement. expect(countryToSlug("Myanmar (Burma)")).to.equal("myanmar-burma"); expect(countryToSlug("Cocos (Keeling) Islands")).to.equal("cocos-keeling-islands"); expect(countryToSlug("Falkland Islands (Malvinas)")).to.equal("falkland-islands-malvinas"); diff --git a/python/FlightRadarAPI/api.py b/python/FlightRadarAPI/api.py index 40842c9..9a9adfe 100644 --- a/python/FlightRadarAPI/api.py +++ b/python/FlightRadarAPI/api.py @@ -207,12 +207,9 @@ def get_airports( slugs: Optional[List[str]] = None if wanted is not None: - # Accepts Countries members and plain slug strings alike. slugs = [str(getattr(country, "value", country)) for country in wanted] - # get_content() rather than get_json_content(): a non-JSON body (an - # interstitial served as text/html) then reaches the parser, which warns - # and returns [] instead of raising, matching the Node port. + # get_content(), not get_json_content(): a non-JSON body must reach the guard. return parse_airports_json(response.get_content(), slugs) def get_bookmarks(self) -> Dict: @@ -304,9 +301,7 @@ def get_country_flag(self, country: str) -> Optional[Tuple[bytes, str]]: :param country: Country name """ - # Slugified the same way as the airports feed, so the `country` of an - # Airport can be handed straight back here: FR24 spells some names with - # parentheses ("Myanmar (Burma)") that must not reach the URL. + # Same slugifier as the feed, which spells some names "Myanmar (Burma)". flag_url = Core.country_flag_url.format(country_to_slug(country)) headers = Core.image_headers.copy() diff --git a/python/FlightRadarAPI/core.py b/python/FlightRadarAPI/core.py index e8a76e0..5948e6c 100644 --- a/python/FlightRadarAPI/core.py +++ b/python/FlightRadarAPI/core.py @@ -32,8 +32,7 @@ class Core: airport_data_url = flightradar_base_url + "/airports/traffic-stats/?airport={}" airports_data_url = flightradar_base_url + "/data/airports" - # Every airport in one payload. The per-country pages above are - # client-rendered and no longer carry a scrapable list. + # Every airport in one payload; the per-country pages above are client-rendered. airports_json_url = flightradar_base_url + "/_json/airports.php" # Airlines data URL. diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 5b0f412..f3ae161 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -13,12 +13,10 @@ _logger = logging.getLogger(__name__) -# ASCII only: \d would otherwise match Unicode digits such as "٤٣", which the -# Node port rejects. +# ASCII only: \d would otherwise match Unicode digits such as "٤٣". NUMERIC_PATTERN = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", re.ASCII) -# Stripped explicitly: str.strip() also drops U+001C-U+001F, which JavaScript's -# String.trim() keeps, while trim() drops U+FEFF, which str.strip() keeps. +# str.strip() and trim() disagree on U+001C-U+001F and U+FEFF: pick one set. SURROUNDING_SPACE = " \t\n\r\f\v" # Number.MAX_SAFE_INTEGER, the largest integer JavaScript holds exactly. @@ -90,10 +88,8 @@ def country_to_slug(country: Optional[str]) -> str: Slugify a country name the way FR24 spells it in its data page URLs, so feed rows can be matched against the Countries enum ("United States" -> "united-states"). """ - # The feed is ASCII today ("Curacao"); stripping diacritics keeps the match - # working if that ever changes. - # `is None` rather than a truthiness check: str(0 or "") is "" in Python but - # String(0 ?? "") is "0" in Node, and the two must slugify alike. + # Diacritics stripped so a future "Curaçao" still matches "curacao". `is None` + # rather than truthiness: str(0 or "") is "" here but String(0 ?? "") is "0". decomposed = unicodedata.normalize("NFKD", "" if country is None else str(country)) ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char)) return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-") @@ -119,16 +115,13 @@ def _to_number(value: object) -> Optional[Union[int, float]]: stay ints so that altitudes read the same in both ports -- the feed sends `alt` as an int for most rows and as a string ("-1") for the rest. """ - # Booleans are ints in Python, and str() on a list or dict would invent a - # number: str([43]) == "43". + # bool is an int here, and str([43]) would invent a number. if isinstance(value, bool) or not isinstance(value, (int, float, str)): return None if isinstance(value, str): stripped = value.strip(SURROUNDING_SPACE) - # Decimal numbers only, so both ports accept and reject exactly the same - # strings: bare `float` would also take "1_000" and "inf". if not NUMERIC_PATTERN.match(stripped): return None @@ -136,9 +129,7 @@ def _to_number(value: object) -> Optional[Union[int, float]]: else: text = value - # Every value funnels through one conversion, so overflow is handled once: - # Python ints have no ceiling, and both "1e999" and 400 plain digits reach - # here as something a double cannot hold. + # One conversion, so overflow is handled once: Python ints have no ceiling. try: number = float(text) except OverflowError: @@ -152,9 +143,7 @@ def _to_number(value: object) -> Optional[Union[int, float]]: except (ValueError, OverflowError): return number - # Whole numbers stay ints so altitudes read the same in both ports, but only - # up to where JavaScript is still exact. `exact == number` keeps a decimal - # like -23.4 from being truncated to -23. + # int only when lossless (so -23.4 survives) and only where JavaScript is exact. return exact if exact == number and abs(exact) <= MAX_EXACT_INTEGER else number @@ -199,8 +188,7 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li latitude = _to_number(row.get("lat")) longitude = _to_number(row.get("lon")) - # Half a position is not a position: a consumer gating on `latitude` - # would treat the airport as located and read a None longitude. + # One bad coordinate drops both: half a position reads as located. if latitude is None or longitude is None: latitude = longitude = None unpositioned.append(_to_text(row.get("name"))) @@ -215,8 +203,7 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li "country": _to_text(row.get("country")), })) - # Summarised rather than logged per row: the feed carries every airport, so a - # degraded response would otherwise print hundreds of lines per call. + # One line, not one per row: the feed carries every airport. if unpositioned: _logger.warning( "parse_airports_json: %d airport(s) had unusable coordinates and carry no position (e.g. %s).", diff --git a/python/FlightRadarAPI/request.py b/python/FlightRadarAPI/request.py index 8f86658..c39b195 100644 --- a/python/FlightRadarAPI/request.py +++ b/python/FlightRadarAPI/request.py @@ -233,13 +233,8 @@ def get_content(self) -> Union[Dict, bytes]: try: content = decode(content) except Exception as err: - # curl_cffi decompresses gzip and br transparently while leaving the - # header in place, so failing here is usually routine. Decide by - # looking at the body rather than trusting the header: a text payload - # that will not decode is genuinely broken and must stay a warning, - # since the caller would otherwise only see the parser complain that - # the JSON is invalid. Binary bodies carry no such tell, and a gzip - # magic number under a "gzip" header means real corruption. + # Decided by the body, not the header: undecodable text is genuinely + # broken and must warn, while binary bodies carry no such tell. if content_type.startswith(("application/json", "text/")): try: content.decode("utf-8") diff --git a/python/tests/test_api.py b/python/tests/test_api.py index 86387b8..088d62c 100644 --- a/python/tests/test_api.py +++ b/python/tests/test_api.py @@ -62,16 +62,12 @@ def test_get_airports_without_countries(expect=1800): assert len(results) > expect assert len({airport.country for airport in results}) > 2 - # Regression: FR24 spells some countries with characters that used to reach - # the flag URL verbatim ("Myanmar (Burma)"). Picked from the response rather - # than hard-coded, so the guard survives a rename. + # Discovered, not hard-coded: FR24 spells some names "Myanmar (Burma)". tricky = next((a for a in results if re.search(r"[^a-zA-Z ]", a.country)), results[0]) assert fr_api.get_country_flag(tricky.country) is not None, tricky.country - # The filter matches a slug derived from the feed's display name ("United - # States") against the enum's URL slugs ("united-states"). Those are two - # different FR24 vocabularies that happen to agree, so pin it: a rename on - # either side silently empties one country. + # Two FR24 vocabularies that happen to agree; a rename on either side + # silently empties one country. feed = {country_to_slug(a.country) for a in results} values = {c.value for c in Countries} diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index eb2ec9b..ec657bc 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -18,9 +18,7 @@ FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") -# Every case here caught a real bug or pins a stated invariant. Keep it in step -# with the same list in nodejs/tests/testParsersOffline.js: the two ports have -# drifted apart on exactly these inputs before. +# Keep in step with the same list in nodejs/tests/testParsersOffline.js. NUMERIC_CASES = [ ("whitespace", " ", None), ("an array holding a number", [43], None), @@ -129,11 +127,9 @@ def test_parse_airports_json_rejects_numeric_looking_junk(): }]}) airport = parse_airports_json(payload)[0] - # One unusable coordinate drops the whole position: half a position would - # read as located to anything gating on `latitude`. + # One bad coordinate drops both; altitude is independent and survives. assert airport.latitude is None assert airport.longitude is None - # Altitude is independent of the position and survives. assert airport.altitude == -1 assert isinstance(airport.altitude, int) @@ -172,8 +168,7 @@ def test_parse_airports_json_numeric_coercion(value, expected): @pytest.mark.parametrize("literal", ["1" * 400, "9007199254740993", "-" + "1" * 400]) def test_parse_airports_json_survives_raw_numbers_no_double_can_hold(literal): - """Written as text on purpose: json.loads turns these into unbounded ints, - where float() raises OverflowError instead of returning inf.""" + """Written as text: json.loads turns these into unbounded ints.""" payload = ( '{"rows":[{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain",' f'"lat":{literal},"lon":1,"alt":2}}]}}' @@ -185,8 +180,7 @@ def test_parse_airports_json_survives_raw_numbers_no_double_can_hold(literal): @pytest.mark.parametrize("literal", ["null", "0", "false", "true", "[]", "{}", "123"]) def test_parse_airports_json_keeps_text_fields_as_strings(literal): - """Anything else breaks callers that treat these as strings, e.g. - get_country_flag(airport.country) slugifying a dict.""" + """Anything else breaks get_country_flag(airport.country).""" payload = ( '{"rows":[{"name":%s,"iata":%s,"icao":%s,"country":%s,"lat":1,"lon":2,"alt":3}]}' % (literal, literal, literal, literal) @@ -201,7 +195,6 @@ def test_parse_airports_json_country_spelling_and_slug_round_trip(): assert ann.country == "Myanmar (Burma)" assert country_to_slug(ann.country) == "myanmar-burma" - # Feed sends `alt` as a string on a few rows; it must read as a number. assert ann.altitude == 43 @@ -237,8 +230,7 @@ def test_country_to_slug_matches_countries_enum_spelling(): def test_country_to_slug_strips_parentheses_for_flag_urls(): - """Regression: get_country_flag(airport.country) 404'd for these while the - slug was built with a plain space-to-hyphen replacement.""" + """These 404'd while the slug was a plain space-to-hyphen replacement.""" assert country_to_slug("Myanmar (Burma)") == "myanmar-burma" assert country_to_slug("Cocos (Keeling) Islands") == "cocos-keeling-islands" assert country_to_slug("Falkland Islands (Malvinas)") == "falkland-islands-malvinas" From 1a157c8c1ad270f9ad1ff676b9d7487106e1d007 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 22:18:25 -0300 Subject: [PATCH 12/22] fix: drop non-object feed rows in the Node port too Arrays are objects in JavaScript, so `typeof row !== "object"` let a row like [1, 2, 3] through and built an airport with every field blank, while Python's isinstance(row, dict) dropped it. Verified: the same payload yielded 2 airports in Node and 1 in Python. Also corrects the api.py comment about get_content(): it is a body served as text/html that reaches the parser's guard. A malformed body under a JSON Content-Type still raises from json.loads, and the previous wording read as though it did not. Not changed: the review also reported that the >= 1800 threshold for Brazil + United States cannot be met because the feed carries ~1606 for those two. The live feed has 6745 rows, 282 for Brazil and 1750 for the United States, so the threshold passes with room to spare -- as the green integration runs across seven CI jobs show. The figure came from an archived 5672-row snapshot, 1073 rows short of production. --- nodejs/FlightRadarAPI/parsers.js | 3 ++- nodejs/tests/testParsersOffline.js | 7 +++++++ python/FlightRadarAPI/api.py | 3 ++- python/tests/test_parsers_offline.py | 9 +++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index abd4152..865074e 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -175,7 +175,8 @@ function parseAirportsJson(payload, countries = null) { const airports = []; for (const row of rows) { - if (!row || typeof row !== "object") continue; + // Arrays are objects in JS; Python's isinstance(row, dict) drops them. + if (!row || typeof row !== "object" || Array.isArray(row)) continue; const slug = countryToSlug(row["country"]); diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index ac41f52..9968cd3 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -198,6 +198,13 @@ describe("parseAirportsJson (offline)", function() { } }); + it("skips rows that are not objects, as the Python port does", function() { + const payload = "{\"rows\":[[1,2,3],\"x\",7,null," + + "{\"name\":\"Real\",\"iata\":\"RRR\",\"icao\":\"RRRR\",\"country\":\"Spain\",\"lat\":1,\"lon\":2,\"alt\":3}]}"; + + expect(parseAirportsJson(payload).map((airport) => airport.iata)).to.deep.equal(["RRR"]); + }); + it("returns an empty list when the feed has no rows", function() { expect(parseAirportsJson("")).to.deep.equal([]); expect(parseAirportsJson("{}")).to.deep.equal([]); diff --git a/python/FlightRadarAPI/api.py b/python/FlightRadarAPI/api.py index 9a9adfe..dff8136 100644 --- a/python/FlightRadarAPI/api.py +++ b/python/FlightRadarAPI/api.py @@ -209,7 +209,8 @@ def get_airports( if wanted is not None: slugs = [str(getattr(country, "value", country)) for country in wanted] - # get_content(), not get_json_content(): a non-JSON body must reach the guard. + # get_content(), not get_json_content(): a body served as text/html must + # reach the parser's guard rather than raise. return parse_airports_json(response.get_content(), slugs) def get_bookmarks(self) -> Dict: diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index ec657bc..55e91d0 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -209,6 +209,15 @@ def test_parse_airports_json_keeps_altitude_as_int(): assert isinstance(gru.altitude, int) +def test_parse_airports_json_skips_rows_that_are_not_objects(): + payload = ( + '{"rows":[[1,2,3],"x",7,null,' + '{"name":"Real","iata":"RRR","icao":"RRRR","country":"Spain","lat":1,"lon":2,"alt":3}]}' + ) + + assert [a.iata for a in parse_airports_json(payload)] == ["RRR"] + + def test_parse_airports_json_unknown_country_returns_empty_list(): assert parse_airports_json(_load("airports.json"), ["atlantis"]) == [] From 0593a1f217b916ae52a63d53e58659ed899d2c0b Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 22:36:17 -0300 Subject: [PATCH 13/22] fix: accept any iterable of countries and stop slugifying unused rows Three findings from the latest review, all reproduced first: - getAirports(new Set([...])) crashed in Node with "countries.map is not a function", and only after downloading the whole feed, while Python happily iterated the same argument and returned 282 airports. Node now takes any iterable, as the annotation implies. - country_to_slug ran on every row even with no filter, where the slug is unused: an NFKD normalize plus three regex passes over 6745 rows for nothing. It is computed inside the filter branch now. - The enum-versus-feed test asserted equality in both directions, so a country FR24 *adds* would fail CI even though the library handles it fine. Only the direction that breaks users is asserted now: an enum value the feed no longer names means that filter silently returns []. The Node describe also fetched the feed three times; one before() hook covers all of them. Not changed: the review's medium finding does not reproduce. It reports country_to_slug("Virgin Islands (U.S.)") slugifying to virgin-islands-u-s against an enum value of virgin-islands-us -- but the feed spells these "Virgin Islands Us" and "Virgin Islands British", and none of its 228 country names contains a dot or an apostrophe, so every slug matches. Stripping dots would be guessing at a spelling FR24 does not use; the enum-versus-feed test is what catches it if that ever changes. --- nodejs/FlightRadarAPI/api.js | 12 ++++++++---- nodejs/FlightRadarAPI/parsers.js | 6 ++++-- nodejs/tests/testApi.js | 28 ++++++++++++++++------------ python/FlightRadarAPI/parsers.py | 6 ++++-- python/tests/test_api.py | 11 +++++------ 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/nodejs/FlightRadarAPI/api.js b/nodejs/FlightRadarAPI/api.js index 0c769a3..ed2d5cf 100644 --- a/nodejs/FlightRadarAPI/api.js +++ b/nodejs/FlightRadarAPI/api.js @@ -200,19 +200,23 @@ class FlightRadar24API { /** * Return a list with all airports, optionally narrowed to some countries. * - * @param {Array|string} [countries] - Country names from the Countries enum, - * as an array or a single value; every country when omitted + * @param {Iterable|string} [countries] - Country names from the Countries enum, + * as any iterable or a single value; every country when omitted * @return {Promise>} */ async getAirports(countries) { - const wanted = typeof countries === "string" ? [countries] : countries; + // Any iterable, matching the Python port, which just iterates the argument. + let wanted = null; + + if (typeof countries === "string") wanted = [countries]; + else if (countries != null) 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 ?? null); + return parseAirportsJson(content, wanted); } /** diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index 865074e..dd23478 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -178,9 +178,11 @@ function parseAirportsJson(payload, countries = null) { // Arrays are objects in JS; Python's isinstance(row, dict) drops them. if (!row || typeof row !== "object" || Array.isArray(row)) continue; - const slug = countryToSlug(row["country"]); - + // 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); } diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index ccb96ae..9043dc0 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -52,25 +52,29 @@ describe("Testing FlightRadarAPI version " + version, function() { expect(results.length).to.be.above(expected - 1); }); + let everyAirport; + before(async function() { + everyAirport = await frApi.getAirports(); + }); + it("Expected every airport when no country is given, each with a flag.", async function() { - const results = await frApi.getAirports(); - expect(results.length).to.be.above(expected); - expect(new Set(results.map((airport) => airport.country)).size).to.be.above(countries.length); + expect(everyAirport.length).to.be.above(expected); + expect(new Set(everyAirport.map((airport) => airport.country)).size).to.be.above(countries.length); // Discovered, not hard-coded: FR24 spells some names "Myanmar (Burma)". - const tricky = results.find((airport) => /[^a-z ]/i.test(airport.country)) ?? results[0]; + const tricky = everyAirport.find((airport) => /[^a-z ]/i.test(airport.country)) ?? everyAirport[0]; expect(await frApi.getCountryFlag(tricky.country), tricky.country).to.not.equal(null); }); - it("Expected the Countries enum and the feed to name the same countries.", async function() { - // Two FR24 vocabularies that happen to agree; a rename on either side - // silently empties one country. - const feed = new Set((await frApi.getAirports()).map((airport) => countryToSlug(airport.country))); - const values = Object.values(Countries); + it("Expected every Countries value to name a country the feed knows.", async function() { + // The enum's URL slugs and the feed's display names are two FR24 + // vocabularies; a rename silently empties that country's filter. The + // reverse direction is not asserted: a country FR24 adds is a gap in + // the enum, not a regression. + const feed = new Set(everyAirport.map((airport) => countryToSlug(airport.country))); + const absent = Object.values(Countries).filter((value) => !feed.has(value)); - expect(values.filter((value) => !feed.has(value)), "enum values absent from the feed").to.deep.equal([]); - expect([...feed].filter((slug) => !values.includes(slug)), "feed countries absent from the enum") - .to.deep.equal([]); + expect(absent, "enum values absent from the feed").to.deep.equal([]); }); }); diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index f3ae161..b9281c4 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -178,9 +178,11 @@ def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[Li if not isinstance(row, dict): continue - slug = country_to_slug(row.get("country")) - + # Slugified only when filtering: it is otherwise unused, and this loop + # runs over every airport in the feed. if wanted is not None: + slug = country_to_slug(row.get("country")) + if slug not in wanted: continue matched.add(slug) diff --git a/python/tests/test_api.py b/python/tests/test_api.py index 088d62c..cdd9bb5 100644 --- a/python/tests/test_api.py +++ b/python/tests/test_api.py @@ -66,13 +66,12 @@ def test_get_airports_without_countries(expect=1800): tricky = next((a for a in results if re.search(r"[^a-zA-Z ]", a.country)), results[0]) assert fr_api.get_country_flag(tricky.country) is not None, tricky.country - # Two FR24 vocabularies that happen to agree; a rename on either side - # silently empties one country. - feed = {country_to_slug(a.country) for a in results} - values = {c.value for c in Countries} + # The enum's URL slugs and the feed's display names are two FR24 vocabularies; + # a rename silently empties that country's filter. The reverse direction is not + # asserted: a country FR24 adds is a gap in the enum, not a regression. + absent = {c.value for c in Countries} - {country_to_slug(a.country) for a in results} - assert not (values - feed), "enum values absent from the feed: %s" % sorted(values - feed) - assert not (feed - values), "feed countries absent from the enum: %s" % sorted(feed - values) + assert not absent, "enum values absent from the feed: %s" % sorted(absent) @repeat_test(**repeat_test_config) From 38104c30741136911ead523bd84cfbfa6d18b46d Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 22:53:42 -0300 Subject: [PATCH 14/22] fix: make the "any iterable" contract true and unwrap Countries in the slugifier Three findings from the latest review, each reproduced first: - get_airports called len() on the caller's argument, so a generator raised "object of type 'generator' has no len()" -- the previous commit's claim of accepting any iterable was false for the port it was written about. The argument is materialised with list() now. - Array.from turns a non-iterable into [], so getAirports(Countries) (the enum object rather than its values) or getAirports(123) answered a mistake with an empty list and no request, where Python raises TypeError. Node rejects non-iterables with a TypeError naming what it expects. - country_to_slug str()ed its input, so a Countries member became "countries-brazil" and get_country_flag(Countries.BRAZIL) returned None as though the flag were missing. It unwraps `.value` now, which get_airports already did -- and which Node got for free, its enum values being strings. Left as is: the review suggests relaxing the enum-versus-feed assertion in case FR24 delists the last airport of a marginal country. That failure is worth seeing: the enum would then advertise a country whose filter returns nothing, and the message names it, so triage is quick either way. --- nodejs/FlightRadarAPI/api.js | 15 ++++++++++++--- nodejs/tests/testParsersOffline.js | 6 ++++++ python/FlightRadarAPI/api.py | 14 ++++++++++---- python/FlightRadarAPI/parsers.py | 7 +++++-- python/tests/test_parsers_offline.py | 8 ++++++++ 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/nodejs/FlightRadarAPI/api.js b/nodejs/FlightRadarAPI/api.js index ed2d5cf..51539f6 100644 --- a/nodejs/FlightRadarAPI/api.js +++ b/nodejs/FlightRadarAPI/api.js @@ -205,11 +205,20 @@ class FlightRadar24API { * @return {Promise>} */ async getAirports(countries) { - // Any iterable, matching the Python port, which just iterates the argument. + // 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) wanted = Array.from(countries); + 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 []; diff --git a/nodejs/tests/testParsersOffline.js b/nodejs/tests/testParsersOffline.js index 9968cd3..0326870 100644 --- a/nodejs/tests/testParsersOffline.js +++ b/nodejs/tests/testParsersOffline.js @@ -12,6 +12,7 @@ const path = require("path"); const expect = require("chai").expect; const { parseAirlinesHtml, parseAirportsJson, countryToSlug } = require("../FlightRadarAPI/parsers"); +const { Countries } = require("../FlightRadarAPI/core"); const FIXTURES = path.join(__dirname, "fixtures"); const load = (name) => fs.readFileSync(path.join(FIXTURES, name), "utf-8"); @@ -239,6 +240,11 @@ describe("countryToSlug (offline)", function() { expect(countryToSlug("")).to.equal(""); }); + it("takes a Countries value, which is what getAirports accepts", function() { + expect(countryToSlug(Countries.BRAZIL)).to.equal("brazil"); + expect(countryToSlug(Countries.MYANMAR_BURMA)).to.equal("myanmar-burma"); + }); + it("strips the parentheses FR24 uses, keeping country flag URLs valid", function() { // These 404'd while the slug was a plain space-to-hyphen replacement. expect(countryToSlug("Myanmar (Burma)")).to.equal("myanmar-burma"); diff --git a/python/FlightRadarAPI/api.py b/python/FlightRadarAPI/api.py index dff8136..3bcd2db 100644 --- a/python/FlightRadarAPI/api.py +++ b/python/FlightRadarAPI/api.py @@ -3,7 +3,7 @@ import dataclasses import math from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, Iterable, List, Optional, Tuple, Union from urllib.parse import quote from .core import Core, Countries @@ -188,15 +188,21 @@ def get_airport_disruptions(self) -> Dict: return response.get_json_content() def get_airports( - self, countries: Optional[Union[List[Union[Countries, str]], Countries, str]] = None, + self, countries: Optional[Union[Iterable[Union[Countries, str]], Countries, str]] = None, ) -> List[Airport]: """ Return a list with all airports, optionally narrowed to some countries. :param countries: Country names from the Countries enum, or their slug strings, - as a list or a single value. Every country when omitted. + as any iterable or a single value. Every country when omitted. """ - wanted = [countries] if isinstance(countries, (Countries, str)) else countries + wanted: Optional[List[Union[Countries, str]]] = None + + if isinstance(countries, (Countries, str)): + wanted = [countries] + elif countries is not None: + # Materialised: a generator has no len() and is consumed once. + wanted = list(countries) if wanted is not None and len(wanted) == 0: return [] diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index b9281c4..fe7ccb3 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -88,8 +88,11 @@ def country_to_slug(country: Optional[str]) -> str: Slugify a country name the way FR24 spells it in its data page URLs, so feed rows can be matched against the Countries enum ("United States" -> "united-states"). """ - # Diacritics stripped so a future "Curaçao" still matches "curacao". `is None` - # rather than truthiness: str(0 or "") is "" here but String(0 ?? "") is "0". + # `.value` unwraps a Countries member, which str() would render as + # "Countries.BRAZIL". Diacritics are stripped so a future "Curaçao" still + # matches "curacao"; `is None` rather than truthiness because str(0 or "") is + # "" here while String(0 ?? "") is "0" in Node. + country = getattr(country, "value", country) decomposed = unicodedata.normalize("NFKD", "" if country is None else str(country)) ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char)) return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-") diff --git a/python/tests/test_parsers_offline.py b/python/tests/test_parsers_offline.py index 55e91d0..cf675ac 100644 --- a/python/tests/test_parsers_offline.py +++ b/python/tests/test_parsers_offline.py @@ -14,6 +14,7 @@ import pytest +from FlightRadarAPI.core import Countries from FlightRadarAPI.parsers import country_to_slug, parse_airlines_html, parse_airports_json FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") @@ -238,6 +239,13 @@ def test_country_to_slug_matches_countries_enum_spelling(): assert country_to_slug(None) == "" +def test_country_to_slug_takes_a_countries_member(): + """get_airports accepts them, so get_country_flag(airport.country) style calls + must not slugify "Countries.BRAZIL".""" + assert country_to_slug(Countries.BRAZIL) == "brazil" + assert country_to_slug(Countries.MYANMAR_BURMA) == "myanmar-burma" + + def test_country_to_slug_strips_parentheses_for_flag_urls(): """These 404'd while the slug was a plain space-to-hyphen replacement.""" assert country_to_slug("Myanmar (Burma)") == "myanmar-burma" From a357b0f127745c8b76849f8d0bc212883f2d6582 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 23:03:34 -0300 Subject: [PATCH 15/22] fix: unwrap only real enum members in country_to_slug Auditing my own review fixes turned up one: getattr(country, "value", country) unwrapped anything with a `.value` attribute, so an unrelated object slugified to whatever that attribute held. isinstance(country, Enum) is what was meant. --- python/FlightRadarAPI/parsers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index fe7ccb3..3e0917c 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -5,6 +5,7 @@ import math import re import unicodedata +from enum import Enum from typing import Dict, List, Optional, Union from bs4 import BeautifulSoup @@ -92,7 +93,9 @@ def country_to_slug(country: Optional[str]) -> str: # "Countries.BRAZIL". Diacritics are stripped so a future "Curaçao" still # matches "curacao"; `is None` rather than truthiness because str(0 or "") is # "" here while String(0 ?? "") is "0" in Node. - country = getattr(country, "value", country) + if isinstance(country, Enum): + country = country.value + decomposed = unicodedata.normalize("NFKD", "" if country is None else str(country)) ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char)) return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-") From af2b5258955469a8ae6d2218eae12dba9ebee2a6 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 23:34:03 -0300 Subject: [PATCH 16/22] ci: stop live FR24 tests from blocking pull requests The step named "Offline tests (PR gate)" is the intended gate, but the live integration step was only soft-failing on push, so anything FR24 changed on its side blocked every unrelated PR. That is how #115, a one-line js-yaml lockfile bump, opened red, and how the broken getAirports() stayed invisible on main for four days: soft on push hid it there while it blocked elsewhere. Both workflows now soft-fail on push and pull_request and stay hard on the weekly cron and on manual runs, which are the runs whose job is to notice upstream drift. Also aligns the Node bounds assertion with the Python port, which has always been inclusive: FR24 returns flights sitting exactly on the boundary of the requested box, and `to.be.below(zone.tl_y)` failed them with "expected -52 to be below -52". That was an intermittent red with no bug behind it. --- .github/workflows/node-package.yml | 5 ++++- .github/workflows/python-package.yml | 5 ++++- nodejs/tests/testApi.js | 10 ++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/node-package.yml b/.github/workflows/node-package.yml index 905a59b..0da557f 100644 --- a/.github/workflows/node-package.yml +++ b/.github/workflows/node-package.yml @@ -46,7 +46,10 @@ jobs: timeout_minutes: 10 max_attempts: 3 command: cd nodejs && npm run test:integration - continue-on-error: ${{ github.event_name == 'push' }} + # Soft on push and pull_request: the offline step above is the PR gate, and + # FR24 changing its site must not block unrelated PRs. Hard on the weekly + # cron and on manual runs, which exist to catch exactly that. + continue-on-error: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} - name: Dependency audit (informational) # Informational only: surfaces high-severity advisories as a soft-fail # without blocking PRs. Re-enable enforcement (drop continue-on-error) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 54a6167..e5079a7 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -45,7 +45,10 @@ jobs: timeout_minutes: 10 max_attempts: 3 command: cd python && pytest -m integration -vv -s - continue-on-error: ${{ github.event_name == 'push' }} + # Soft on push and pull_request: the offline step above is the PR gate, and + # FR24 changing its site must not block unrelated PRs. Hard on the weekly + # cron and on manual runs, which exist to catch exactly that. + continue-on-error: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} - name: Dependency audit (informational) # Informational only: surfaces vulnerable transitive deps as a soft-fail # without blocking PRs. Re-enable enforcement (drop continue-on-error) diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index 9043dc0..5ac66f2 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -152,11 +152,13 @@ describe("Testing FlightRadarAPI version " + version, function() { const flights = await frApi.getFlights(null, bounds); for (const flight of flights) { - expect(flight.latitude).to.be.below(zone["tl_y"]); - expect(flight.latitude).to.be.above(zone["br_y"]); + // Inclusive, like the Python port: FR24 returns flights sitting + // exactly on the boundary of the requested box. + expect(flight.latitude).to.be.at.most(zone["tl_y"]); + expect(flight.latitude).to.be.at.least(zone["br_y"]); - expect(flight.longitude).to.be.below(zone["br_x"]); - expect(flight.longitude).to.be.above(zone["tl_x"]); + expect(flight.longitude).to.be.at.most(zone["br_x"]); + expect(flight.longitude).to.be.at.least(zone["tl_x"]); } expect(flights.length).to.be.above(expected - 1); } From 92a4b33354b4e4e2a2405619c7efcd7e1910aaeb Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 23:39:32 -0300 Subject: [PATCH 17/22] Revert "ci: stop live FR24 tests from blocking pull requests" (workflow part) Reverts the continue-on-error widening in both package workflows at the maintainer's request; live integration keeps blocking pull requests as before. The bounds assertion fix from the same commit stays. --- .github/workflows/node-package.yml | 5 +---- .github/workflows/python-package.yml | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/node-package.yml b/.github/workflows/node-package.yml index 0da557f..905a59b 100644 --- a/.github/workflows/node-package.yml +++ b/.github/workflows/node-package.yml @@ -46,10 +46,7 @@ jobs: timeout_minutes: 10 max_attempts: 3 command: cd nodejs && npm run test:integration - # Soft on push and pull_request: the offline step above is the PR gate, and - # FR24 changing its site must not block unrelated PRs. Hard on the weekly - # cron and on manual runs, which exist to catch exactly that. - continue-on-error: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} + continue-on-error: ${{ github.event_name == 'push' }} - name: Dependency audit (informational) # Informational only: surfaces high-severity advisories as a soft-fail # without blocking PRs. Re-enable enforcement (drop continue-on-error) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index e5079a7..54a6167 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -45,10 +45,7 @@ jobs: timeout_minutes: 10 max_attempts: 3 command: cd python && pytest -m integration -vv -s - # Soft on push and pull_request: the offline step above is the PR gate, and - # FR24 changing its site must not block unrelated PRs. Hard on the weekly - # cron and on manual runs, which exist to catch exactly that. - continue-on-error: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} + continue-on-error: ${{ github.event_name == 'push' }} - name: Dependency audit (informational) # Informational only: surfaces vulnerable transitive deps as a soft-fail # without blocking PRs. Re-enable enforcement (drop continue-on-error) From 053c9e8f83981de4cc2b8eed6cb569fd6565978a Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Wed, 12 Aug 2026 23:39:32 -0300 Subject: [PATCH 18/22] chore: release 1.5.3 getAirports()/get_airports() answered with an empty list until now, so this ships as a fix release. Note for TypeScript consumers: Airport.latitude and Airport.longitude are declared 'number | null' from this version on, matching what the parser has always been able to return. Code reading either field under strictNullChecks needs a null check. --- nodejs/package-lock.json | 4 ++-- nodejs/package.json | 2 +- python/FlightRadarAPI/__init__.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index b39a1b8..a28f5c9 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "flightradarapi", - "version": "1.5.2", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flightradarapi", - "version": "1.5.2", + "version": "1.5.3", "license": "MIT", "dependencies": { "node-html-parser": "^6.1.13", diff --git a/nodejs/package.json b/nodejs/package.json index b4fb9a3..9ff4cac 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -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", diff --git a/python/FlightRadarAPI/__init__.py b/python/FlightRadarAPI/__init__.py index e7c008a..52e7635 100644 --- a/python/FlightRadarAPI/__init__.py +++ b/python/FlightRadarAPI/__init__.py @@ -12,7 +12,7 @@ """ __author__ = "Jean Loui Bernard Silva de Jesus" -__version__ = "1.5.2" +__version__ = "1.5.3" from .api import FlightRadar24API from .core import Countries From a52c7dd9276bc6ca6d166b29e172e48901033c15 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 13 Aug 2026 00:00:03 -0300 Subject: [PATCH 19/22] fix: stop the decode handler from raising and skip empty flag lookups Four findings from the latest review, verified first: - The decode-failure block assumed bytes, so a non-bytes body raised AttributeError from inside the except clause and replaced the original error with its own. Reproduced with a str body; nothing in that block can raise now. - getCountryFlag(None) built .../flags-small/.svg and spent a real round trip (568 ms measured) to return None as though the flag were missing. It used to raise before country_to_slug started accepting anything. An empty slug returns None without a request in both ports. - The _to_number docstring claimed whole numbers keep their type in both ports, which holds for "2436" but not for "1e3" -- a float here, an integral Number in Node. The docstring says so now; the feed never sends exponent notation in `alt`, and forcing an int there would change latitude too. - The live flag test picked the first punctuated country in feed order, so the assertion depended on how FR24 happened to sort its rows. Sorted now. Left alone: get_airports("") returns [] but logs a warning naming an empty country. It is cosmetic, and the message already points at the empty slug. --- nodejs/FlightRadarAPI/api.js | 6 +++++- nodejs/tests/testApi.js | 8 ++++++-- python/FlightRadarAPI/api.py | 7 ++++++- python/FlightRadarAPI/parsers.py | 9 ++++++--- python/FlightRadarAPI/request.py | 7 +++++-- python/tests/test_api.py | 9 ++++++--- 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/nodejs/FlightRadarAPI/api.js b/nodejs/FlightRadarAPI/api.js index 51539f6..6d50ebf 100644 --- a/nodejs/FlightRadarAPI/api.js +++ b/nodejs/FlightRadarAPI/api.js @@ -327,7 +327,11 @@ class FlightRadar24API { */ async getCountryFlag(country) { // Same slugifier as the feed, which spells some names "Myanmar (Burma)". - const flagUrl = Core.countryFlagUrl(countryToSlug(country)); + const slug = countryToSlug(country); + + if (!slug) return null; + + const flagUrl = Core.countryFlagUrl(slug); const headers = { ...Core.imageHeaders }; delete headers["origin"]; diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index 5ac66f2..c20ddaa 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -62,8 +62,12 @@ describe("Testing FlightRadarAPI version " + version, function() { expect(new Set(everyAirport.map((airport) => airport.country)).size).to.be.above(countries.length); // Discovered, not hard-coded: FR24 spells some names "Myanmar (Burma)". - const tricky = everyAirport.find((airport) => /[^a-z ]/i.test(airport.country)) ?? everyAirport[0]; - expect(await frApi.getCountryFlag(tricky.country), tricky.country).to.not.equal(null); + // Sorted so the pick does not depend on the order the feed happens to + // list airports in. + const punctuated = everyAirport.map((airport) => airport.country).filter((name) => /[^a-z ]/i.test(name)); + const tricky = punctuated.sort()[0] ?? everyAirport[0].country; + + expect(await frApi.getCountryFlag(tricky), tricky).to.not.equal(null); }); it("Expected every Countries value to name a country the feed knows.", async function() { diff --git a/python/FlightRadarAPI/api.py b/python/FlightRadarAPI/api.py index 3bcd2db..ebb78bb 100644 --- a/python/FlightRadarAPI/api.py +++ b/python/FlightRadarAPI/api.py @@ -309,7 +309,12 @@ def get_country_flag(self, country: str) -> Optional[Tuple[bytes, str]]: :param country: Country name """ # Same slugifier as the feed, which spells some names "Myanmar (Burma)". - flag_url = Core.country_flag_url.format(country_to_slug(country)) + slug = country_to_slug(country) + + if not slug: + return None + + flag_url = Core.country_flag_url.format(slug) headers = Core.image_headers.copy() headers.pop("origin", None) # Does not work for this request. diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 3e0917c..120ec48 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -117,9 +117,12 @@ def _to_number(value: object) -> Optional[Union[int, float]]: Coerce a numeric feed field into a number, or None when it is unusable. An unusable coordinate must not become 0.0: that would place the airport in - the Gulf of Guinea instead of marking its position as unknown. Whole numbers - stay ints so that altitudes read the same in both ports -- the feed sends - `alt` as an int for most rows and as a string ("-1") for the rest. + the Gulf of Guinea instead of marking its position as unknown. + + Plain whole numbers stay ints, so an altitude reads 2436 in both ports rather + than 2436.0 here -- the feed sends `alt` as an int for most rows and as a + string ("-1") for the rest. Exponent notation is the exception: "1e3" is a + float here and an integral Number in Node, equal in value but not in type. """ # bool is an int here, and str([43]) would invent a number. if isinstance(value, bool) or not isinstance(value, (int, float, str)): diff --git a/python/FlightRadarAPI/request.py b/python/FlightRadarAPI/request.py index c39b195..991b113 100644 --- a/python/FlightRadarAPI/request.py +++ b/python/FlightRadarAPI/request.py @@ -234,8 +234,11 @@ def get_content(self) -> Union[Dict, bytes]: content = decode(content) except Exception as err: # Decided by the body, not the header: undecodable text is genuinely - # broken and must warn, while binary bodies carry no such tell. - if content_type.startswith(("application/json", "text/")): + # broken and must warn, while binary bodies carry no such tell. Nothing + # here may raise, or it would replace `err` with its own failure. + if not isinstance(content, bytes): + transport_decoded = True + elif content_type.startswith(("application/json", "text/")): try: content.decode("utf-8") transport_decoded = True diff --git a/python/tests/test_api.py b/python/tests/test_api.py index cdd9bb5..43b605a 100644 --- a/python/tests/test_api.py +++ b/python/tests/test_api.py @@ -62,9 +62,12 @@ def test_get_airports_without_countries(expect=1800): assert len(results) > expect assert len({airport.country for airport in results}) > 2 - # Discovered, not hard-coded: FR24 spells some names "Myanmar (Burma)". - tricky = next((a for a in results if re.search(r"[^a-zA-Z ]", a.country)), results[0]) - assert fr_api.get_country_flag(tricky.country) is not None, tricky.country + # Discovered, not hard-coded: FR24 spells some names "Myanmar (Burma)". Sorted + # so the pick does not depend on the order the feed happens to list airports in. + punctuated = sorted(a.country for a in results if re.search(r"[^a-zA-Z ]", a.country)) + tricky = punctuated[0] if punctuated else results[0].country + + assert fr_api.get_country_flag(tricky) is not None, tricky # The enum's URL slugs and the feed's display names are two FR24 vocabularies; # a rename silently empties that country's filter. The reverse direction is not From 4bf126ac6e27b078fd7b310e6806798751109b0b Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 13 Aug 2026 00:30:58 -0300 Subject: [PATCH 20/22] fix: delete punctuation before hyphenating slugs and widen the getAirports type Four of the seven findings from the latest review; the other three are argued against below. - country_to_slug collapsed every run of non-alphanumerics into a hyphen, while the Countries values were evidently built by deleting punctuation inside words: "Virgin Islands (U.S.)" would slugify to virgin-islands-u-s against an enum saying virgin-islands-us. The feed spells it "Virgin Islands Us" today, so nothing is broken -- and measuring first showed the stricter rule leaves all 228 current mappings byte-identical, which makes it free insurance against a rename rather than a guess. - index.d.ts declared getAirports(countries?: string[] | string) while the runtime and the JSDoc take any iterable, so new Set(["Brazil"]) worked but failed to compile. Iterable now, with a tsd case pinning it. - get_airports mapped its argument through str(getattr(country, "value", country)), re-adding the duck-typed unwrap that a357b0f had just narrowed to isinstance(Enum) and turning None into the slug "none". The parser already slugifies Countries members, so the whole mapping is gone. - The get_content() comment now says a malformed JSON body still raises. Four reviews read the old wording as a blanket promise; the wording was the problem. Left alone: raising instead of warning for a country that matches nothing would fail the day FR24 delists a country's last airport, and the enum-versus-feed test already covers the systematic case. bytearray/memoryview payloads are unreachable from get_airports, since the client hands back bytes. And airports_data_url is one of seven unreferenced entries in the Core URL catalogue -- five base URLs and zones_data_url predate this branch -- so removing only this one would be inconsistent with the file. --- nodejs/FlightRadarAPI/index.d.ts | 6 +++--- nodejs/FlightRadarAPI/parsers.js | 3 +++ nodejs/tests/index.test-d.ts | 1 + python/FlightRadarAPI/api.py | 10 +++------- python/FlightRadarAPI/parsers.py | 16 +++++++++++----- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/nodejs/FlightRadarAPI/index.d.ts b/nodejs/FlightRadarAPI/index.d.ts index fc0406c..0ed2c1a 100644 --- a/nodejs/FlightRadarAPI/index.d.ts +++ b/nodejs/FlightRadarAPI/index.d.ts @@ -116,10 +116,10 @@ export class FlightRadar24API { /** * Return a list with all airports, optionally narrowed to some countries. * - * @param {string[]|string} [countries] - Country names, as an array or a single - * value; every country when omitted + * @param {Iterable|string} [countries] - Country names, as any iterable + * or a single value; every country when omitted */ - getAirports(countries?: string[] | string): Promise; + getAirports(countries?: Iterable | string): Promise; /** * Return the bookmarks from the FlightRadar24 account. diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index dd23478..e1cad82 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -79,8 +79,11 @@ function parseAirlinesHtml(html) { */ function countryToSlug(country) { // Diacritics stripped so a future "Curaçao" still matches "curacao". + // Punctuation is deleted before the hyphenation pass, the way the enum values + // were built: "Virgin Islands (U.S.)" is virgin-islands-us, not -u-s. return String(country ?? "") .normalize("NFKD").replace(/[\u0300-\u036f]/g, "") + .replace(/[^A-Za-z0-9\s-]/g, "") .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); } diff --git a/nodejs/tests/index.test-d.ts b/nodejs/tests/index.test-d.ts index e03c827..f7641a4 100644 --- a/nodejs/tests/index.test-d.ts +++ b/nodejs/tests/index.test-d.ts @@ -27,6 +27,7 @@ expectType>(api.getAirportDetails("ATL", 10, 1)); // getAirports expectType>(api.getAirports(["Brazil"])); expectType>(api.getAirports("Brazil")); +expectType>(api.getAirports(new Set(["Brazil"]))); expectType>(api.getAirports()); // getAirlines diff --git a/python/FlightRadarAPI/api.py b/python/FlightRadarAPI/api.py index ebb78bb..e440e8a 100644 --- a/python/FlightRadarAPI/api.py +++ b/python/FlightRadarAPI/api.py @@ -210,14 +210,10 @@ def get_airports( response = self.__client.request( Core.airports_json_url, headers=Core.json_headers, timeout=self.timeout, ) - slugs: Optional[List[str]] = None - if wanted is not None: - slugs = [str(getattr(country, "value", country)) for country in wanted] - - # get_content(), not get_json_content(): a body served as text/html must - # reach the parser's guard rather than raise. - return parse_airports_json(response.get_content(), slugs) + # get_content(), not get_json_content(): an html body reaches the parser's + # guard; a malformed JSON body still raises. The parser slugifies `wanted`. + return parse_airports_json(response.get_content(), wanted) def get_bookmarks(self) -> Dict: """ diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 120ec48..6e01aae 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -6,7 +6,7 @@ import re import unicodedata from enum import Enum -from typing import Dict, List, Optional, Union +from typing import Dict, Iterable, List, Optional, Union from bs4 import BeautifulSoup @@ -84,7 +84,7 @@ def parse_airlines_html(html: bytes) -> List[Dict]: return airlines -def country_to_slug(country: Optional[str]) -> str: +def country_to_slug(country: object) -> str: """ Slugify a country name the way FR24 spells it in its data page URLs, so feed rows can be matched against the Countries enum ("United States" -> "united-states"). @@ -98,7 +98,11 @@ def country_to_slug(country: Optional[str]) -> str: decomposed = unicodedata.normalize("NFKD", "" if country is None else str(country)) ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char)) - return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-") + + # Punctuation is deleted before the hyphenation pass, the way the enum values + # were built: "Virgin Islands (U.S.)" is virgin-islands-us, not -u-s. + depunctuated = re.sub(r"[^A-Za-z0-9\s-]", "", ascii_only) + return re.sub(r"[^a-z0-9]+", "-", depunctuated.lower()).strip("-") def _to_text(value: object) -> str: @@ -156,12 +160,14 @@ def _to_number(value: object) -> Optional[Union[int, float]]: return exact if exact == number and abs(exact) <= MAX_EXACT_INTEGER else number -def parse_airports_json(payload: Union[bytes, str, Dict], countries: Optional[List[str]] = None) -> List[Airport]: +def parse_airports_json( + payload: Union[bytes, str, Dict], countries: Optional[Iterable[object]] = None, +) -> List[Airport]: """ Parse the airports JSON feed into a list of Airport instances. :param payload: Body of Core.airports_json_url. - :param countries: Country slugs from the Countries enum. Every airport is kept when omitted. + :param countries: Country slugs, or Countries members. Every airport is kept when omitted. """ if isinstance(payload, (bytes, str)): try: From fbfde590a6788f65b6f98d31fe63793dc89a7424 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 13 Aug 2026 00:39:09 -0300 Subject: [PATCH 21/22] Revert the slug punctuation change: it broke a working flag lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probing the CDN shows FR24 does not follow one rule for punctuation in its asset names: flags-small/cote-d-ivoire.svg 200 (apostrophe hyphenated) flags-small/cote-divoire.svg 404 flags-small/virgin-islands-us.svg 200 (dots absent) flags-small/virgin-islands-u-s.svg 404 Deleting punctuation before hyphenating therefore fixed the dotted spelling -- which the feed does not use, since it says "Virgin Islands Us" -- at the cost of getCountryFlag("Côte d'Ivoire"), which worked before and returned null after. Trading a live case for a hypothetical one is the wrong side of that trade, and "virgin-islands-us" exists because that is the display name, not because a rule deleted the dots. The other three fixes from that commit stand: the widened getAirports type, the removed duck-typed unwrap, and the get_content() comment. --- nodejs/FlightRadarAPI/parsers.js | 5 ++--- python/FlightRadarAPI/parsers.py | 7 +++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/nodejs/FlightRadarAPI/parsers.js b/nodejs/FlightRadarAPI/parsers.js index e1cad82..79a16dd 100644 --- a/nodejs/FlightRadarAPI/parsers.js +++ b/nodejs/FlightRadarAPI/parsers.js @@ -79,11 +79,10 @@ function parseAirlinesHtml(html) { */ function countryToSlug(country) { // Diacritics stripped so a future "Curaçao" still matches "curacao". - // Punctuation is deleted before the hyphenation pass, the way the enum values - // were built: "Virgin Islands (U.S.)" is virgin-islands-us, not -u-s. + // 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, "") - .replace(/[^A-Za-z0-9\s-]/g, "") .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, ""); } diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 6e01aae..4c56623 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -99,10 +99,9 @@ def country_to_slug(country: object) -> str: decomposed = unicodedata.normalize("NFKD", "" if country is None else str(country)) ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char)) - # Punctuation is deleted before the hyphenation pass, the way the enum values - # were built: "Virgin Islands (U.S.)" is virgin-islands-us, not -u-s. - depunctuated = re.sub(r"[^A-Za-z0-9\s-]", "", ascii_only) - return re.sub(r"[^a-z0-9]+", "-", depunctuated.lower()).strip("-") + # 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 re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-") def _to_text(value: object) -> str: From 7ea46e97c1aae8e922f78d6ec76291c6980aec2a Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 13 Aug 2026 00:59:09 -0300 Subject: [PATCH 22/22] docs: name every case where _to_number's type differs from Node The docstring called exponent notation the only exception, but a decimal point does the same: "2436.0" is a float here and an integral Number in Node. --- python/FlightRadarAPI/parsers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/FlightRadarAPI/parsers.py b/python/FlightRadarAPI/parsers.py index 4c56623..77667ad 100644 --- a/python/FlightRadarAPI/parsers.py +++ b/python/FlightRadarAPI/parsers.py @@ -124,8 +124,9 @@ def _to_number(value: object) -> Optional[Union[int, float]]: Plain whole numbers stay ints, so an altitude reads 2436 in both ports rather than 2436.0 here -- the feed sends `alt` as an int for most rows and as a - string ("-1") for the rest. Exponent notation is the exception: "1e3" is a - float here and an integral Number in Node, equal in value but not in type. + string ("-1") for the rest. Anything written with a decimal point or an + exponent stays a float: "2436.0" and "1e3" are floats here and integral + Numbers in Node, equal in value but not in type. """ # bool is an int here, and str([43]) would invent a number. if isinstance(value, bool) or not isinstance(value, (int, float, str)):