From 175949be78bd4f59a8e7543fbbcfffcaeaaa80d9 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 11 May 2026 04:02:16 -0300 Subject: [PATCH 1/8] refactor: modularize SDK, improve typing and add snapshot tests Both Python and Node.js SDKs received the same set of structural changes: Parsers extracted - Move all HTML scraping logic (airlines, airports) out of api.py/api.js into dedicated parsers.py / parsers.js modules so the API class is no longer responsible for parsing. Named field indices - Replace raw magic-number array accesses in the Flight entity with a _Field IntEnum (Python) and a frozen FIELDS object (Node.js), mapping each position in the FR24 live-feed array to a readable name (LATITUDE, LONGITUDE, ALTITUDE, REGISTRATION, etc.). FlightTrackerConfig extracted (Python) - Move the FlightTrackerConfig dataclass from api.py into its own flight_tracker_config.py module. Request layer refactored - Node.js: replace the APIRequest class with a plain async `request()` function; add explicit DEFAULT_TIMEOUT_MS constant. - Python: switch to urllib.parse.urlencode for query-string building, guard against None exclude_status_codes, and make content-type header access safer with .get(). Snapshot tests - Add testSnapshots.js and test_snapshots.py with an assertShape() helper that validates the structure of live API responses (keys, types, nesting) without requiring exact values, making tests resilient to data changes. Python packaging - Consolidate config into pyproject.toml; drop requirements.txt and pytest.ini; add py.typed marker for PEP 561 compliance. GitHub - Add pull_request_template.md and pr-closed.yml workflow. --- .github/pull_request_template.md | 27 + .github/workflows/pr-closed.yml | 16 + nodejs/.eslintrc.json | 2 +- nodejs/FlightRadar24/api.js | 548 +++++------------- nodejs/FlightRadar24/core.js | 164 +++--- nodejs/FlightRadar24/entities/airport.js | 9 +- nodejs/FlightRadar24/entities/entity.js | 37 +- nodejs/FlightRadar24/entities/flight.js | 100 ++-- nodejs/FlightRadar24/errors.js | 33 +- nodejs/FlightRadar24/flightTrackerConfig.js | 13 +- nodejs/FlightRadar24/index.d.ts | 104 +++- nodejs/FlightRadar24/index.js | 9 +- nodejs/FlightRadar24/parsers.js | 155 +++++ nodejs/FlightRadar24/request.js | 207 +++---- nodejs/FlightRadar24/util.js | 31 +- nodejs/FlightRadar24/zones.js | 402 ++++++------- nodejs/package-lock.json | 4 +- nodejs/package.json | 2 +- nodejs/tests/testApi.js | 116 +++- nodejs/tests/testSnapshots.js | 254 ++++++++ python/FlightRadar24/__init__.py | 18 +- python/FlightRadar24/api.py | 287 ++------- python/FlightRadar24/core.py | 3 +- python/FlightRadar24/entities/airport.py | 67 ++- python/FlightRadar24/entities/entity.py | 15 +- python/FlightRadar24/entities/flight.py | 120 ++-- python/FlightRadar24/errors.py | 19 +- python/FlightRadar24/flight_tracker_config.py | 23 + python/FlightRadar24/parsers.py | 127 ++++ python/FlightRadar24/py.typed | 0 python/FlightRadar24/request.py | 36 +- python/Makefile | 8 +- python/pyproject.toml | 19 +- python/pytest.ini | 15 - python/requirements.txt | 4 - python/tests/test_api.py | 118 +++- python/tests/test_snapshots.py | 214 +++++++ python/tests/util.py | 6 +- 38 files changed, 1939 insertions(+), 1393 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/pr-closed.yml create mode 100644 nodejs/FlightRadar24/parsers.js create mode 100644 nodejs/tests/testSnapshots.js create mode 100644 python/FlightRadar24/flight_tracker_config.py create mode 100644 python/FlightRadar24/parsers.py create mode 100644 python/FlightRadar24/py.typed delete mode 100644 python/pytest.ini delete mode 100644 python/requirements.txt create mode 100644 python/tests/test_snapshots.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..24896ca --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +**Why is this PR necessary, what does it do?** + + + +**Checklist (complete all items)**: + +- [ ] Added tests as necessary. +- [ ] There is no break change for existing features. + +**References:** + + + +No references to be shared. + +**Notes:** + + + +No notes to be shared. \ No newline at end of file diff --git a/.github/workflows/pr-closed.yml b/.github/workflows/pr-closed.yml new file mode 100644 index 0000000..8d04cb5 --- /dev/null +++ b/.github/workflows/pr-closed.yml @@ -0,0 +1,16 @@ +name: PR Closed + +on: + pull_request: + types: + - closed + +jobs: + pr-closed: + name: PR Closed + runs-on: ubuntu-latest + + steps: + # Checkout + - name: Checkout + uses: actions/checkout@v2 \ No newline at end of file diff --git a/nodejs/.eslintrc.json b/nodejs/.eslintrc.json index 9514c11..122e7d1 100644 --- a/nodejs/.eslintrc.json +++ b/nodejs/.eslintrc.json @@ -9,7 +9,7 @@ "ecmaVersion": "latest" }, "rules": { - "max-len": ["error", {"code": 130}], + "max-len": ["error", {"code": 130, "ignoreStrings": true, "ignoreTemplateLiterals": true}], "quotes": ["warn", "double"], "indent": ["warn", 4], "brace-style": ["warn", "stroustrup"], diff --git a/nodejs/FlightRadar24/api.js b/nodejs/FlightRadar24/api.js index e44f246..3999da6 100644 --- a/nodejs/FlightRadar24/api.js +++ b/nodejs/FlightRadar24/api.js @@ -1,11 +1,11 @@ const Core = require("./core"); -const APIRequest = require("./request"); +const {request} = require("./request"); const Airport = require("./entities/airport"); const Flight = require("./entities/flight"); const FlightTrackerConfig = require("./flightTrackerConfig"); const {AirportNotFoundError, LoginError} = require("./errors"); -const {isNumeric} = require("./util"); -const {JSDOM} = require("jsdom"); +const {isNumeric, radians, rad2deg} = require("./util"); +const {parseAirlinesHtml, parseAirportsHtml} = require("./parsers"); /** @@ -23,128 +23,48 @@ class FlightRadar24API { /** * Return a list with all airlines. * - * @return {Array} + * @return {Promise>} */ async getAirlines() { - const response = new APIRequest(Core.airlinesDataUrl, null, Core.htmlHeaders); - await response.receive(); - - const htmlContent = await response.getContent(); - const airlinesData = []; - - // Parse HTML content. - const dom = new JSDOM(htmlContent); - const document = dom.window.document; - - const tbody = document.querySelector("tbody"); - - if (!tbody) { - return []; - } - - // Extract data from HTML content. - const trElements = tbody.querySelectorAll("tr"); - - for (const tr of trElements) { - const tdNotranslate = tr.querySelector("td.notranslate"); - - if (tdNotranslate) { - const aElement = tdNotranslate.querySelector("a[href^='/data/airlines']"); - - if (aElement) { - const tdElements = tr.querySelectorAll("td"); - - // Extract airline name. - const airlineName = aElement.textContent.trim(); - - if (airlineName.length < 2) { - continue; - } - - // Extract IATA / ICAO codes. - let iata = null; - let icao = null; - - if (tdElements.length >= 4) { - const codesText = tdElements[3].textContent.trim(); - - if (codesText.includes(" / ")) { - const parts = codesText.split(" / "); - - if (parts.length === 2) { - iata = parts[0].trim(); - icao = parts[1].trim(); - } - } else if (codesText.length === 2) { - iata = codesText; - } else if (codesText.length === 3) { - icao = codesText; - } - } - - // Extract number of aircrafts. - let nAircrafts = null; - - if (tdElements.length >= 5) { - const aircraftsText = tdElements[4].textContent.trim(); - - if (aircraftsText) { - nAircrafts = aircraftsText.split(" ")[0].trim(); - nAircrafts = parseInt(nAircrafts); - } - } - - const airlineData = { - "Name": airlineName, - "ICAO": icao, - "IATA": iata, - "n_aircrafts": nAircrafts - }; - - airlinesData.push(airlineData); - } - } - } - - return airlinesData; + const {content} = await request(Core.airlinesDataUrl, {headers: Core.htmlHeaders}); + return parseAirlinesHtml(content); } /** * Download the logo of an airline from FlightRadar24 and return it as bytes. + * Returns null if the logo is not found. * * @param {string} iata - IATA of the airline * @param {string} icao - ICAO of the airline - * @return {[object, string]} + * @return {Promise<[object, string] | null>} */ async getAirlineLogo(iata, icao) { iata = iata.toUpperCase(); icao = icao.toUpperCase(); - const firstLogoUrl = Core.airlineLogoUrl.format(iata, icao); + const notFound = [403, 404]; - // Try to get the image by the first URL option. - let response = new APIRequest(firstLogoUrl, null, Core.imageHeaders, null, null, [403]); - await response.receive(); + const firstLogoUrl = Core.airlineLogoUrl(iata, icao); + let {content, statusCode} = await request(firstLogoUrl, { + headers: Core.imageHeaders, + allowedErrorCodes: notFound, + }); - let statusCode = response.getStatusCode(); - - if (!statusCode.toString().startsWith("4")) { - const splitUrl = firstLogoUrl.split("."); - return [(await response.getContent()), splitUrl[splitUrl.length - 1]]; + if (statusCode < 400) { + return [content, firstLogoUrl.split(".").pop()]; } - // Get the image by the second airline logo URL. - const secondLogoUrl = Core.alternativeAirlineLogoUrl.format(icao); - - response = new APIRequest(secondLogoUrl, null, Core.imageHeaders); - await response.receive(); + const secondLogoUrl = Core.alternativeAirlineLogoUrl(icao); + ({content, statusCode} = await request(secondLogoUrl, { + headers: Core.imageHeaders, + allowedErrorCodes: notFound, + })); - statusCode = response.getStatusCode(); - - if (!statusCode.toString().startsWith("4")) { - const splitUrl = secondLogoUrl.split("."); - return [(await response.getContent()), splitUrl[splitUrl.length - 1]]; + if (statusCode < 400) { + return [content, secondLogoUrl.split(".").pop()]; } + + return null; } /** @@ -152,26 +72,21 @@ class FlightRadar24API { * * @param {string} code - ICAO or IATA of the airport * @param {boolean} details - If true, it returns flights with detailed information - * @return {Airport} + * @return {Promise} */ async getAirport(code, details = false) { - if (4 < code.length || code.length < 3) { + if (code.length < 3 || code.length > 4) { throw new Error("The code '" + code + "' is invalid. It must be the IATA or ICAO of the airport."); } if (details) { const airport = new Airport(); - - const airportDetails = await this.getAirportDetails(code); - airport.setAirportDetails(airportDetails); - + airport.setAirportDetails(await this.getAirportDetails(code)); return airport; } - const response = new APIRequest(Core.airportDataUrl.format(code), null, Core.jsonHeaders); - await response.receive(); - - const info = (await response.getContent())["details"]; + const {content} = await request(Core.airportDataUrl(code), {headers: Core.jsonHeaders}); + const info = content["details"]; if (info === undefined) { throw new AirportNotFoundError("Could not find an airport by the code '" + code + "'."); @@ -185,31 +100,26 @@ class FlightRadar24API { * @param {string} code - ICAO or IATA of the airport * @param {number} [flightLimit=100] - Limit of flights related to the airport * @param {number} [page=1] - Page of result to display - * @return {object} + * @return {Promise} */ async getAirportDetails(code, flightLimit = 100, page = 1) { - if (4 < code.length || code.length < 3) { + if (code.length < 3 || code.length > 4) { throw new Error("The code '" + code + "' is invalid. It must be the IATA or ICAO of the airport."); } - const requestParams = {"format": "json"}; + const params = {"format": "json", "code": code, "limit": flightLimit, "page": page}; - if (this.__loginData != null) { - requestParams["token"] = this.__loginData["cookies"]["_frPl"]; + if (this.__loginData !== null) { + params["token"] = this.__loginData["cookies"]["_frPl"]; } - // Insert the method parameters into the dictionary for the request. - requestParams["code"] = code; - requestParams["limit"] = flightLimit; - requestParams["page"] = page; - - // Request details from the FlightRadar24. - const response = new APIRequest(Core.apiAirportDataUrl, requestParams, Core.jsonHeaders, null, null, [400]); - await response.receive(); - - const content = await response.getContent(); + const {content, statusCode} = await request(Core.apiAirportDataUrl, { + params, + headers: Core.jsonHeaders, + allowedErrorCodes: [400], + }); - if (response.getStatusCode() === 400 && content?.["errors"] !== undefined) { + if (statusCode === 400 && content?.["errors"] !== undefined) { const errors = content["errors"]?.["errors"]?.["parameters"]; const limit = errors?.["limit"]; @@ -220,163 +130,62 @@ class FlightRadar24API { } const result = content["result"]["response"]; - - // Check whether it received data of an airport. const data = result?.["airport"]?.["pluginData"]; const dataCount = typeof data === "object" ? Object.entries(data).length : 0; const runways = data?.["runways"]; const runwaysCount = typeof runways === "object" ? Object.entries(runways).length : 0; - if (data?.["details"] === undefined && runwaysCount == 0 && dataCount <= 3) { + if (data?.["details"] === undefined && runwaysCount === 0 && dataCount <= 3) { throw new AirportNotFoundError("Could not find an airport by the code '" + code + "'."); } - // Return the airport details. return result; } /** * Return airport disruptions. * - * @return {object} + * @return {Promise} */ async getAirportDisruptions() { - const response = new APIRequest(Core.airportDisruptionsUrl, null, Core.jsonHeaders); - await response.receive(); - - return await response.getContent(); + const {content} = await request(Core.airportDisruptionsUrl, {headers: Core.jsonHeaders}); + return content; } /** * Return a list with all airports for specified countries. * * @param {Array} countries - Array of country names from Countries enum - * @return {Array} + * @return {Promise>} */ async getAirports(countries) { - const airports = []; - - for (const countryName of countries) { + const results = await Promise.all(countries.map(async (countryName) => { const countryHref = Core.airportsDataUrl + "/" + countryName; + const {content} = await request(countryHref, {headers: Core.htmlHeaders}); + return parseAirportsHtml(content, countryHref); + })); - const response = new APIRequest(countryHref, null, Core.htmlHeaders); - await response.receive(); - - const htmlContent = await response.getContent(); - - // Parse HTML content. - const dom = new JSDOM(htmlContent); - const document = dom.window.document; - - const tbody = document.querySelector("tbody"); - - if (!tbody) { - continue; - } - - // Extract country name from the URL - const countryDisplayName = countryHref.split("/").pop().replace(/-/g, " ") - .split(" ").map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); - - const trElements = tbody.querySelectorAll("tr"); - - for (const tr of trElements) { - const aElements = tr.querySelectorAll("a[data-iata][data-lat][data-lon]"); - - if (aElements.length > 0) { - const aElement = aElements[0]; - - let icao = ""; - let iata = aElement.getAttribute("data-iata") || ""; - const latitude = aElement.getAttribute("data-lat") || ""; - const longitude = aElement.getAttribute("data-lon") || ""; - - const airportText = aElement.textContent.trim(); - let namePart = airportText; - - // Get IATA / ICAO from airport text. - const smallElement = aElement.querySelector("small"); - - if (smallElement) { - let codesText = smallElement.textContent.trim(); - codesText = codesText.replace(/^\(/, "").replace(/\)$/, "").trim(); - - // Remove IATA / ICAO from name part. - namePart = namePart.replace(smallElement.textContent, "").replace(/\(\)/, "").trim(); - - // Parse codes (can be "IATA/ICAO", "IATA", or "ICAO") - if (codesText.includes("/")) { - const codes = codesText.split("/"); - const code1 = codes[0].trim(); - const code2 = codes[1].trim(); - - // Use length to determine IATA vs ICAO - 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; - } - } - - // Convert latitude and longitude to float - let latFloat = 0.0; - let lonFloat = 0.0; - - try { - latFloat = latitude ? parseFloat(latitude) : 0.0; - lonFloat = longitude ? parseFloat(longitude) : 0.0; - } catch (error) { - latFloat = 0.0; - lonFloat = 0.0; - } - - // Create Airport instance with basic_info format - const airportData = { - "name": namePart, - "icao": icao, - "iata": iata, - "lat": latFloat, - "lon": lonFloat, - "alt": null, // Altitude not available in this format - "country": countryDisplayName - }; - - const airport = new Airport(airportData); - airports.push(airport); - } - } - } - - return airports; + return results.flat(); } /** * Return the bookmarks from the FlightRadar24 account. * - * @return {object} + * @return {Promise} */ async getBookmarks() { if (!this.isLoggedIn()) { throw new LoginError("You must log in to your account."); } - const headers = {...Core.jsonHeaders}; - headers["accesstoken"] = this.getLoginData()["accessToken"]; - - const cookies = this.__loginData["cookies"]; - - const response = new APIRequest(Core.bookmarksUrl, null, headers, null, cookies); - await response.receive(); + const headers = {...Core.jsonHeaders, "accesstoken": this.getLoginData()["accessToken"]}; + const {content} = await request(Core.bookmarksUrl, { + headers, + cookies: this.__loginData["cookies"], + }); - return await response.getContent(); + return content; } /** @@ -386,7 +195,7 @@ class FlightRadar24API { * @return {string} */ getBounds(zone) { - return "" + zone["tl_y"] + "," + zone["br_y"] + "," + zone["tl_x"] + "," + zone["br_x"]; + return `${zone["tl_y"]},${zone["br_y"]},${zone["tl_x"]},${zone["br_x"]}`; } /** @@ -400,11 +209,8 @@ class FlightRadar24API { getBoundsByPoint(latitude, longitude, radius) { const halfSideInKm = Math.abs(radius) / 1000; - Math.rad2deg = (x) => x * (180 / Math.PI); - Math.radians = (x) => x * (Math.PI / 180); - - const lat = Math.radians(latitude); - const lon = Math.radians(longitude); + const lat = radians(latitude); + const lon = radians(longitude); const approxEarthRadius = 6371; const hypotenuseDistance = Math.sqrt(2 * (Math.pow(halfSideInKm, 2))); @@ -437,51 +243,48 @@ class FlightRadar24API { Math.sin(lat) * Math.sin(latMax), ); - const zone = { - "tl_y": Math.rad2deg(latMax), - "br_y": Math.rad2deg(latMin), - "tl_x": Math.rad2deg(lonMin), - "br_x": Math.rad2deg(lonMax), - }; - return this.getBounds(zone); + return this.getBounds({ + "tl_y": rad2deg(latMax), + "br_y": rad2deg(latMin), + "tl_x": rad2deg(lonMin), + "br_x": rad2deg(lonMax), + }); } /** * Download the flag of a country from FlightRadar24 and return it as bytes. + * Returns null if the flag is not found. * * @param {string} country - Country name - * @return {[object, string]} + * @return {Promise<[object, string] | null>} */ async getCountryFlag(country) { - const flagUrl = Core.countryFlagUrl.format(country.toLowerCase().replace(" ", "-")); - const headers = {...Core.imageHeaders}; + const flagUrl = Core.countryFlagUrl(country.toLowerCase().replaceAll(" ", "-")); - if (headers.hasOwnProperty("origin")) { - delete headers["origin"]; // Does not work for this request. - } - - const response = new APIRequest(flagUrl, null, headers); - await response.receive(); + const headers = {...Core.imageHeaders}; + delete headers["origin"]; - const statusCode = response.getStatusCode(); + const {content, statusCode} = await request(flagUrl, { + headers, + allowedErrorCodes: [403, 404], + }); - if (!statusCode.toString().startsWith("4")) { - const splitUrl = flagUrl.split("."); - return [(await response.getContent()), splitUrl[splitUrl.length - 1]]; + if (statusCode < 400) { + return [content, flagUrl.split(".").pop()]; } + + return null; } /** * Return the flight details from Data Live FlightRadar24. * * @param {Flight} flight - A Flight instance - * @return {object} + * @return {Promise} */ async getFlightDetails(flight) { - const response = new APIRequest(Core.flightDataUrl.format(flight.id), null, Core.jsonHeaders); - await response.receive(); - - return (await response.getContent()); + const {content} = await request(Core.flightDataUrl(flight.id), {headers: Core.jsonHeaders}); + return content; } /** @@ -492,56 +295,40 @@ class FlightRadar24API { * @param {string} [registration] - Aircraft registration * @param {string} [aircraftType] - Aircraft model code. Ex: "B737" * @param {boolean} [details] - If true, it returns flights with detailed information - * @return {Array} + * @return {Promise>} */ async getFlights(airline = null, bounds = null, registration = null, aircraftType = null, details = false) { - const requestParams = {...this.__flightTrackerConfig}; + const params = {...this.__flightTrackerConfig}; - if (this.__loginData != null) { - requestParams["enc"] = this.__loginData["cookies"]["_frPl"]; + if (this.__loginData !== null) { + params["enc"] = this.__loginData["cookies"]["_frPl"]; } + if (airline !== null) params["airline"] = airline; + if (bounds !== null) params["bounds"] = bounds; + if (registration !== null) params["reg"] = registration; + if (aircraftType !== null) params["type"] = aircraftType; - // Insert the method parameters into the dictionary for the request. - if (airline != null) { - requestParams["airline"] = airline; - } - if (bounds != null) { - requestParams["bounds"] = bounds.replace(",", "%2C"); - } - if (registration != null) { - requestParams["reg"] = registration; - } - if (aircraftType != null) { - requestParams["type"] = aircraftType; - } - - // Get all flights from Data Live FlightRadar24. - const response = new APIRequest(Core.realTimeFlightTrackerDataUrl, requestParams, Core.jsonHeaders); - await response.receive(); + const {content} = await request(Core.realTimeFlightTrackerDataUrl, { + params, + headers: Core.jsonHeaders, + }); - const content = await response.getContent(); const flights = []; for (const flightId in content) { - if (!Object.prototype.hasOwnProperty.call(content, flightId)) { // guard-for-in + if (!Object.prototype.hasOwnProperty.call(content, flightId)) { continue; } - - const flightInfo = content[flightId]; - - // Get flights only. if (!isNumeric(flightId[0])) { continue; } + flights.push(new Flight(flightId, content[flightId])); + } - const flight = new Flight(flightId, flightInfo); - flights.push(flight); - - // Set flight details. - if (details) { - const flightDetails = await this.getFlightDetails(flight); - flight.setFlightDetails(flightDetails); - } + if (details) { + await Promise.all(flights.map(async (flight) => { + flight.setFlightDetails(await this.getFlightDetails(flight)); + })); } return flights; @@ -574,13 +361,12 @@ class FlightRadar24API { throw new Error("File type '" + fileType + "' is not supported. Only CSV and KML are supported."); } - const response = new APIRequest( - Core.historicalDataUrl.format(flight.id, fileType, timestamp), - null, Core.jsonHeaders, null, this.__loginData["cookies"], - ); - await response.receive(); + const headers = {...Core.jsonHeaders, "accesstoken": this.getLoginData()["accessToken"]}; + const {content} = await request(Core.historicalDataUrl(flight.id, fileType, timestamp), { + headers, + cookies: this.__loginData["cookies"], + }); - const content = await response.getContent(); return content; } @@ -599,25 +385,21 @@ class FlightRadar24API { /** * Return the most tracked data. * - * @return {object} + * @return {Promise} */ async getMostTracked() { - const response = new APIRequest(Core.mostTrackedUrl, null, Core.jsonHeaders); - await response.receive(); - - return await response.getContent(); + const {content} = await request(Core.mostTrackedUrl, {headers: Core.jsonHeaders}); + return content; } /** * Return boundaries of volcanic eruptions and ash clouds impacting aviation. * - * @return {object} + * @return {Promise} */ async getVolcanicEruptions() { - const response = new APIRequest(Core.volcanicEruptionDataUrl, null, Core.jsonHeaders); - await response.receive(); - - return await response.getContent(); + const {content} = await request(Core.volcanicEruptionDataUrl, {headers: Core.jsonHeaders}); + return content; } /** @@ -625,17 +407,9 @@ class FlightRadar24API { * * @return {object} */ - async getZones() { - // [Deprecated Code] - // const response = new APIRequest(Core.zonesDataUrl, null, Core.jsonHeaders); - // await response.receive(); - - // const zones = await response.getContent(); - const zones = Core.staticZones; - - if (zones.hasOwnProperty("version")) { - delete zones["version"]; - } + getZones() { + const zones = {...Core.staticZones}; + delete zones.version; return zones; } @@ -644,44 +418,25 @@ class FlightRadar24API { * * @param {string} query * @param {number} [limit=50] - * @return {object} + * @return {Promise} */ async search(query, limit = 50) { - const url = Core.searchDataUrl.format(query, limit); - - const response = new APIRequest(url, null, Core.jsonHeaders); - await response.receive(); - - const content = await response.getContent(); - - let results = content["results"]; - results = results == null ? [] : results; + const {content} = await request(Core.searchDataUrl(query, limit), {headers: Core.jsonHeaders}); - let stats = content["stats"]; - stats = stats == null ? {} : stats; - - let countDict = stats["count"]; - countDict = countDict == null ? {} : countDict; + const results = content["results"] ?? []; + const countDict = content["stats"]?.["count"] ?? {}; let index = 0; - let countedTotal = 0; - const data = {}; for (const name in countDict) { - if (!Object.prototype.hasOwnProperty.call(countDict, name)) { // guard-for-in + if (!Object.prototype.hasOwnProperty.call(countDict, name)) { continue; } const count = countDict[name]; - - data[name] = []; - - while (index < (countedTotal + count) && (index < results.length)) { - data[name].push(results[index]); - index++; - } - countedTotal += count; + data[name] = results.slice(index, index + count); + index += count; } return data; @@ -693,7 +448,7 @@ class FlightRadar24API { * @return {boolean} */ isLoggedIn() { - return this.__loginData != null; + return this.__loginData !== null; } /** @@ -701,54 +456,38 @@ class FlightRadar24API { * * @param {string} user - Your email. * @param {string} password - Your password. - * @return {undefined} + * @return {Promise} */ async login(user, password) { - const data = { - "email": user, - "password": password, - "remember": "true", - "type": "web", - }; - - const response = new APIRequest(Core.userLoginUrl, null, Core.jsonHeaders, data); - await response.receive(); - - const statusCode = response.getStatusCode(); - const content = await response.getContent(); - - if (!statusCode.toString().startsWith("2") || !content["success"]) { - if (typeof content === "object") { - throw new LoginError(content["message"]); - } - else { - throw new LoginError("Your email or password is incorrect"); - } + const {content, statusCode, cookies} = await request(Core.userLoginUrl, { + headers: Core.jsonHeaders, + data: {"email": user, "password": password, "remember": "true", "type": "web"}, + }); + + if (statusCode < 200 || statusCode >= 300 || !content["success"]) { + throw new LoginError( + typeof content === "object" ? content["message"] : "Your email or password is incorrect", + ); } - this.__loginData = { - "userData": content["userData"], - "cookies": response.getCookies(), - }; + this.__loginData = {"userData": content["userData"], "cookies": cookies}; } /** * Log out of the FlightRadar24 account. * - * @return {boolean} - Return a boolean indicating that it successfully logged out of the server. + * @return {Promise} - Return a boolean indicating that it successfully logged out of the server. */ async logout() { - if (this.__loginData == null) { + if (this.__loginData === null) { return true; } const cookies = this.__loginData["cookies"]; this.__loginData = null; - const response = new APIRequest(Core.userLoginUrl, null, Core.jsonHeaders, null, cookies); - await response.receive(); - - return response.getStatusCode().toString().startsWith("2"); + const {statusCode} = await request(Core.userLogoutUrl, {headers: Core.jsonHeaders, cookies}); + return statusCode >= 200 && statusCode < 300; } /** @@ -758,15 +497,14 @@ class FlightRadar24API { * @param {object} [config={}] - Config as an JSON object * @return {undefined} */ - async setFlightTrackerConfig(flightTrackerConfig = null, config = {}) { - if (flightTrackerConfig != null) { + setFlightTrackerConfig(flightTrackerConfig = null, config = {}) { + if (flightTrackerConfig !== null) { this.__flightTrackerConfig = flightTrackerConfig; } for (const key in config) { - if (Object.prototype.hasOwnProperty.call(config, key)) { // guard-for-in - const value = config[key].toString(); - this.__flightTrackerConfig[key] = value; + if (Object.prototype.hasOwnProperty.call(config, key)) { + this.__flightTrackerConfig[key] = config[key].toString(); } } } diff --git a/nodejs/FlightRadar24/core.js b/nodejs/FlightRadar24/core.js index e176f09..296a8d7 100644 --- a/nodejs/FlightRadar24/core.js +++ b/nodejs/FlightRadar24/core.js @@ -1,117 +1,99 @@ const {staticZones} = require("./zones"); -String.prototype.format = function() { - const args = arguments; - let index = 0; +const FR24_BASE = "https://www.flightradar24.com"; +const API_FR24_BASE = "https://api.flightradar24.com/common/v1"; +const CDN_FR24_BASE = "https://cdn.flightradar24.com"; +const DATA_LIVE_BASE = "https://data-live.flightradar24.com"; +const DATA_CLOUD_BASE = "https://data-cloud.flightradar24.com"; - return this.replace(/{}/g, function(match, position) { - return (typeof args[index] == "undefined") ? match : args[index++]; - }); +const baseHeaders = { + "accept-encoding": "gzip, br", + "accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7", + "cache-control": "max-age=0", + "origin": FR24_BASE, + "referer": `${FR24_BASE}/`, + // eslint-disable-next-line quotes + "sec-ch-ua": '"Google Chrome";v="136", "Chromium";v="136", "Not-A.Brand";v="24"', + "sec-ch-ua-mobile": "?0", + // eslint-disable-next-line quotes + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-site", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", }; - /** - * Class which contains all URLs used by the package. + * Module containing all URLs, headers, and static data used by the package. */ -class Core { - /** - * Constructor of the Core class - */ - constructor() { - this.apiFlightRadarBaseUrl = "https://api.flightradar24.com/common/v1"; - this.cdnFlightRadarBaseUrl = "https://cdn.flightradar24.com"; - this.flightRadarBaseUrl = "https://www.flightradar24.com"; - this.dataLiveBaseUrl = "https://data-live.flightradar24.com"; - this.dataCloudBaseUrl = "https://data-cloud.flightradar24.com"; - - // User login URL. - this.userLoginUrl = this.flightRadarBaseUrl + "/user/login"; - this.userLogoutUrl = this.flightRadarBaseUrl + "/user/logout"; - - // Search data URL - this.searchDataUrl = this.flightRadarBaseUrl + "/v1/search/web/find?query={}&limit={}"; +const Core = { + apiFlightRadarBaseUrl: API_FR24_BASE, + cdnFlightRadarBaseUrl: CDN_FR24_BASE, + flightRadarBaseUrl: FR24_BASE, + dataLiveBaseUrl: DATA_LIVE_BASE, + dataCloudBaseUrl: DATA_CLOUD_BASE, - // Flights data URLs. - this.realTimeFlightTrackerDataUrl = this.dataCloudBaseUrl + "/zones/fcgi/feed.js"; - this.flightDataUrl = this.dataLiveBaseUrl + "/clickhandler/?flight={}"; + userLoginUrl: `${FR24_BASE}/user/login`, + userLogoutUrl: `${FR24_BASE}/user/logout`, - // Historical data URL. - this.historicalDataUrl = this.flightRadarBaseUrl + "/download/?flight={}&file={}&trailLimit=0&history={}"; + searchDataUrl: (query, limit) => `${FR24_BASE}/v1/search/web/find?query=${encodeURIComponent(query)}&limit=${limit}`, - // Airports data URLs. - this.apiAirportDataUrl = this.apiFlightRadarBaseUrl + "/airport.json"; - this.airportDataUrl = this.flightRadarBaseUrl + "/airports/traffic-stats/?airport={}"; - this.airportsDataUrl = this.flightRadarBaseUrl + "/data/airports"; + realTimeFlightTrackerDataUrl: `${DATA_CLOUD_BASE}/zones/fcgi/feed.js`, + flightDataUrl: (id) => `${DATA_LIVE_BASE}/clickhandler/?flight=${id}`, - // Airlines data URL. - this.airlinesDataUrl = this.flightRadarBaseUrl + "/data/airlines"; + historicalDataUrl: (id, fileType, timestamp) => + `${FR24_BASE}/download/?flight=${id}&file=${fileType}&trailLimit=0&history=${timestamp}`, - // Zones data URL. - this.zonesDataUrl = this.flightRadarBaseUrl + "/js/zones.js.php"; + apiAirportDataUrl: `${API_FR24_BASE}/airport.json`, + airportDataUrl: (code) => `${FR24_BASE}/airports/traffic-stats/?airport=${code}`, + airportsDataUrl: `${FR24_BASE}/data/airports`, - // Weather data URL. - this.volcanicEruptionDataUrl = this.flightRadarBaseUrl + "/weather/volcanic"; + airlinesDataUrl: `${FR24_BASE}/data/airlines`, - // Most tracked URL - this.mostTrackedUrl = this.flightRadarBaseUrl + "/flights/most-tracked"; + zonesDataUrl: `${FR24_BASE}/js/zones.js.php`, - // Airport disruptions URL. - this.airportDisruptionsUrl = this.flightRadarBaseUrl + "/webapi/v1/airport-disruptions"; + volcanicEruptionDataUrl: `${FR24_BASE}/weather/volcanic`, - // Bookmarks URL. - this.bookmarksUrl = this.flightRadarBaseUrl + "/webapi/v1/bookmarks"; + mostTrackedUrl: `${FR24_BASE}/flights/most-tracked`, - // Country flag image URL. - this.countryFlagUrl = this.flightRadarBaseUrl + "/static/images/data/flags-small/{}.svg"; + airportDisruptionsUrl: `${FR24_BASE}/webapi/v1/airport-disruptions`, - // Airline logo image URL. - this.airlineLogoUrl = this.cdnFlightRadarBaseUrl + "/assets/airlines/logotypes/{}_{}.png"; - this.alternativeAirlineLogoUrl = this.flightRadarBaseUrl + "/static/images/data/operators/{}_logo0.png"; + bookmarksUrl: `${FR24_BASE}/webapi/v1/bookmarks`, - this.staticZones = staticZones; + countryFlagUrl: (country) => `${FR24_BASE}/static/images/data/flags-small/${country}.svg`, - this.headers = { - "accept-encoding": "gzip, br", - "accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7", - "cache-control": "max-age=0", - "origin": "https://www.flightradar24.com", - "referer": "https://www.flightradar24.com/", - "sec-ch-ua": '"Google Chrome";v="136", "Chromium";v="136", "Not-A.Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', - "sec-fetch-dest": "empty", - "sec-fetch-mode": "cors", - "sec-fetch-site": "same-site", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", - }; + airlineLogoUrl: (iata, icao) => `${CDN_FR24_BASE}/assets/airlines/logotypes/${iata}_${icao}.png`, + alternativeAirlineLogoUrl: (icao) => `${FR24_BASE}/static/images/data/operators/${icao}_logo0.png`, - this.jsonHeaders = {accept: "application/json", ...this.headers}; + staticZones, - this.imageHeaders = {accept: "image/gif, image/jpg, image/jpeg, image/png", ...this.headers}; - - this.htmlHeaders = { - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", - "accept-encoding": "gzip, deflate, br", - "accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7", - "cache-control": "max-age=0", - "referer": "https://www.flightradar24.com/", - "sec-ch-ua": '"Google Chrome";v="136", "Chromium";v="136", "Not-A.Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Windows"', - "sec-fetch-dest": "document", - "sec-fetch-mode": "navigate", - "sec-fetch-site": "same-origin", - "sec-fetch-user": "?1", - "upgrade-insecure-requests": "1", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", - }; - } -} + headers: baseHeaders, + jsonHeaders: {accept: "application/json", ...baseHeaders}, + imageHeaders: {accept: "image/gif, image/jpg, image/jpeg, image/png", ...baseHeaders}, + htmlHeaders: { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "accept-encoding": "gzip, deflate, br", + "accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7", + "cache-control": "max-age=0", + "referer": `${FR24_BASE}/`, + // eslint-disable-next-line quotes + "sec-ch-ua": '"Google Chrome";v="136", "Chromium";v="136", "Not-A.Brand";v="24"', + "sec-ch-ua-mobile": "?0", + // eslint-disable-next-line quotes + "sec-ch-ua-platform": '"Windows"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + }, +}; /** * Enum mapping country names to their URL-friendly string representations. */ -const Countries = { +const Countries = Object.freeze({ AFGHANISTAN: "afghanistan", ALBANIA: "albania", ALGERIA: "algeria", @@ -339,8 +321,8 @@ const Countries = { WALLIS_AND_FUTUNA: "wallis-and-futuna", YEMEN: "yemen", ZAMBIA: "zambia", - ZIMBABWE: "zimbabwe" -}; + ZIMBABWE: "zimbabwe", +}); -module.exports = new Core(); +module.exports = Core; module.exports.Countries = Countries; diff --git a/nodejs/FlightRadar24/entities/airport.js b/nodejs/FlightRadar24/entities/airport.js index ff0df10..4f8632c 100644 --- a/nodejs/FlightRadar24/entities/airport.js +++ b/nodejs/FlightRadar24/entities/airport.js @@ -121,8 +121,8 @@ class Airport extends Entity { // Airport location. this.country = this.__getInfo(country?.["name"]); - this.country_code = this.__getInfo(country?.["code"]); - this.country_id = this.__getInfo(country?.["id"]); + this.countryCode = this.__getInfo(country?.["code"]); + this.countryId = this.__getInfo(country?.["id"]); this.city = this.__getInfo(region?.["city"]); // Airport timezone. @@ -132,11 +132,10 @@ class Airport extends Entity { this.timezoneOffset = this.__getInfo(timezone?.["offset"]); if (typeof this.timezoneOffset === "number") { - this.timezoneOffsetHours = Math.trunc(this.timezoneOffset / 60 / 60); - this.timezoneOffsetHours = this.timezoneOffsetHours + ":00"; + this.timezoneOffsetHours = Math.trunc(this.timezoneOffset / 60 / 60) + ":00"; } else { - this.timezoneOffsetHours = this.__getInfo(None); + this.timezoneOffsetHours = this.__getInfo(null); } // Airport reviews. diff --git a/nodejs/FlightRadar24/entities/entity.js b/nodejs/FlightRadar24/entities/entity.js index 9daceda..190634e 100644 --- a/nodejs/FlightRadar24/entities/entity.js +++ b/nodejs/FlightRadar24/entities/entity.js @@ -1,9 +1,11 @@ +const {radians} = require("../util"); + +const DEFAULT_TEXT = "N/A"; + /** * Representation of a real entity, at some location. */ class Entity { - static __defaultText = "N/A"; - /** * Constructor of Entity class. * @@ -14,14 +16,23 @@ class Entity { this.__setPosition(latitude, longitude); } + /** + * @param {number} latitude + * @param {number} longitude + */ __setPosition(latitude, longitude) { this.latitude = latitude; this.longitude = longitude; } - __getInfo(info, replaceBy = undefined) { - replaceBy = replaceBy === undefined ? this.__defaultText : replaceBy; - return (info || info === 0) && (info !== this.__defaultText) ? info : replaceBy; + /** + * @param {*} info + * @param {*} [replaceBy] + * @return {*} + */ + __getInfo(info, replaceBy = DEFAULT_TEXT) { + if (info === null || info === undefined || info === DEFAULT_TEXT) return replaceBy; + return info; } /** @@ -31,13 +42,15 @@ class Entity { * @return {number} */ getDistanceFrom(entity) { - Math.radians = (x) => x * (Math.PI / 180); - - const lat1 = Math.radians(this.latitude); - const lon1 = Math.radians(this.longitude); - - const lat2 = Math.radians(entity.latitude); - const lon2 = Math.radians(entity.longitude); + if (this.latitude == null || this.longitude == null || + entity.latitude == null || entity.longitude == null) { + throw new Error("Cannot calculate distance: one or both entities have no position."); + } + + const lat1 = radians(this.latitude); + const lon1 = radians(this.longitude); + const lat2 = radians(entity.latitude); + const lon2 = radians(entity.longitude); return Math.acos(Math.sin(lat1) * Math.sin(lat2) + Math.cos(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1)) * 6371; } diff --git a/nodejs/FlightRadar24/entities/flight.js b/nodejs/FlightRadar24/entities/flight.js index 2f5e67b..d44c14a 100644 --- a/nodejs/FlightRadar24/entities/flight.js +++ b/nodejs/FlightRadar24/entities/flight.js @@ -1,5 +1,29 @@ const Entity = require("./entity"); +// Positional field mapping for the raw array returned by the FlightRadar24 feed. +// If the upstream API adds or shifts a field, update only this map. +const FIELDS = Object.freeze({ + ICAO24BIT: 0, + LATITUDE: 1, + LONGITUDE: 2, + HEADING: 3, + ALTITUDE: 4, + GROUND_SPEED: 5, + SQUAWK: 6, + // index 7: unused + AIRCRAFT_CODE: 8, + REGISTRATION: 9, + TIME: 10, + ORIGIN_IATA: 11, + DESTINATION_IATA: 12, + FLIGHT_NUMBER: 13, + ON_GROUND: 14, + VERTICAL_SPEED: 15, + CALLSIGN: 16, + // index 17: unused + AIRLINE_ICAO: 18, +}); + /** * Flight representation. */ @@ -8,30 +32,30 @@ class Flight extends Entity { * Constructor of Flight class. * * @param {*} flightId - The flight ID specifically used by FlightRadar24 - * @param {*} info - Dictionary with received data from FlightRadar24 + * @param {*} info - Array with received data from FlightRadar24 */ constructor(flightId, info) { super(); - this.__setPosition(this.__getInfo(info[1]), this.__getInfo(info[2])); + this.__setPosition(this.__getInfo(info[FIELDS.LATITUDE]), this.__getInfo(info[FIELDS.LONGITUDE])); this.id = flightId; - this.icao24bit = this.__getInfo(info[0]); - this.heading = this.__getInfo(info[3]); - this.altitude = this.__getInfo(info[4]); - this.groundSpeed = this.__getInfo(info[5]); - this.squawk = this.__getInfo(info[6]); - this.aircraftCode = this.__getInfo(info[8]); - this.registration = this.__getInfo(info[9]); - this.time = this.__getInfo(info[10]); - this.originAirportIata = this.__getInfo(info[11]); - this.destinationAirportIata = this.__getInfo(info[12]); - this.number = this.__getInfo(info[13]); - this.airlineIata = this.__getInfo(info[13].slice(0, 2)); - this.onGround = this.__getInfo(info[14]); - this.verticalSpeed =this.__getInfo(info[15]); - this.callsign = this.__getInfo(info[16]); - this.airlineIcao = this.__getInfo(info[18]); + this.icao24bit = this.__getInfo(info[FIELDS.ICAO24BIT]); + this.heading = this.__getInfo(info[FIELDS.HEADING]); + this.altitude = this.__getInfo(info[FIELDS.ALTITUDE]); + this.groundSpeed = this.__getInfo(info[FIELDS.GROUND_SPEED]); + this.squawk = this.__getInfo(info[FIELDS.SQUAWK]); + this.aircraftCode = this.__getInfo(info[FIELDS.AIRCRAFT_CODE]); + this.registration = this.__getInfo(info[FIELDS.REGISTRATION]); + this.time = this.__getInfo(info[FIELDS.TIME]); + this.originAirportIata = this.__getInfo(info[FIELDS.ORIGIN_IATA]); + this.destinationAirportIata = this.__getInfo(info[FIELDS.DESTINATION_IATA]); + this.number = this.__getInfo(info[FIELDS.FLIGHT_NUMBER]); + this.airlineIata = this.__getInfo(info[FIELDS.FLIGHT_NUMBER]?.slice(0, 2)); + this.onGround = this.__getInfo(info[FIELDS.ON_GROUND]); + this.verticalSpeed = this.__getInfo(info[FIELDS.VERTICAL_SPEED]); + this.callsign = this.__getInfo(info[FIELDS.CALLSIGN]); + this.airlineIcao = this.__getInfo(info[FIELDS.AIRLINE_ICAO]); } /** @@ -49,14 +73,13 @@ class Flight extends Entity { const comparisonFunctions = {"max": Math.max, "min": Math.min}; for (let key in info) { - if (!Object.prototype.hasOwnProperty.call(info, key)) { // guard-for-in + if (!Object.prototype.hasOwnProperty.call(info, key)) { continue; } let prefix = key.slice(0, 3); const value = info[key]; - // Separate the comparison prefix if it exists. if ((prefix === "max") || (prefix === "min")) { key = key[3].toLowerCase() + key.slice(4, key.length); } @@ -64,15 +87,12 @@ class Flight extends Entity { prefix = null; } - // Check if the value is greater than or less than the attribute value. - if (this.hasOwnProperty(key) && prefix) { + if (Object.prototype.hasOwnProperty.call(this, key) && prefix) { if (comparisonFunctions[prefix](value, this[key]) !== value) { return false; } } - - // Check if the value is equal. - else if (this.hasOwnProperty(key) && value !== this[key]) { + else if (Object.prototype.hasOwnProperty.call(this, key) && value !== this[key]) { return false; } } @@ -106,8 +126,8 @@ class Flight extends Entity { * @return {string} */ getGroundSpeed() { - const sufix = this.groundSpeed > 1 ? "s" : ""; - return this.groundSpeed.toString() + " kt" + sufix; + const suffix = this.groundSpeed > 1 ? "s" : ""; + return this.groundSpeed.toString() + " kt" + suffix; } /** @@ -135,16 +155,10 @@ class Flight extends Entity { * @return {undefined} */ setFlightDetails(flightDetails) { - // Get aircraft data. const aircraft = flightDetails["aircraft"]; - - // Get airline data. const airline = flightDetails?.["airline"]; - - // Get airport data. const airport = flightDetails?.["airport"]; - // Get destination data. const destAirport = airport?.["destination"]; const destAirportCode = destAirport?.["code"]; const destAirportInfo = destAirport?.["info"]; @@ -152,7 +166,6 @@ class Flight extends Entity { const destAirportCountry = destAirportPosition?.["country"]; const destAirportTimezone = destAirport?.["timezone"]; - // Get origin data. const origAirport = airport?.["origin"]; const origAirportCode = origAirport?.["code"]; const origAirportInfo = origAirport?.["info"]; @@ -160,31 +173,23 @@ class Flight extends Entity { const origAirportCountry = origAirportPosition?.["country"]; const origAirportTimezone = origAirport?.["timezone"]; - // Get flight history. const history = flightDetails?.["flightHistory"]; - - // Get flight status. const status = flightDetails?.["status"]; - // Aircraft information. this.aircraftAge = this.__getInfo(aircraft?.["age"]); this.aircraftCountryId = this.__getInfo(aircraft?.["countryId"]); this.aircraftHistory = this.__getInfo(history?.["aircraft"], []); this.aircraftImages = this.__getInfo(aircraft?.["images"], []); this.aircraftModel = this.__getInfo(aircraft?.["model"]?.["text"]); - // Airline information. this.airlineName = this.__getInfo(airline?.["name"]); this.airlineShortName = this.__getInfo(airline?.["short"]); - // Destination airport position. this.destinationAirportAltitude = this.__getInfo(destAirportPosition?.["altitude"]); this.destinationAirportCountryCode = this.__getInfo(destAirportCountry?.["code"]); this.destinationAirportCountryName = this.__getInfo(destAirportCountry?.["name"]); this.destinationAirportLatitude = this.__getInfo(destAirportPosition?.["latitude"]); this.destinationAirportLongitude = this.__getInfo(destAirportPosition?.["longitude"]); - - // Destination airport information. this.destinationAirportIcao = this.__getInfo(destAirportCode?.["icao"]); this.destinationAirportBaggage = this.__getInfo(destAirportInfo?.["baggage"]); this.destinationAirportGate = this.__getInfo(destAirportInfo?.["gate"]); @@ -192,22 +197,17 @@ class Flight extends Entity { this.destinationAirportTerminal = this.__getInfo(destAirportInfo?.["terminal"]); this.destinationAirportVisible = this.__getInfo(destAirport?.["visible"]); this.destinationAirportWebsite = this.__getInfo(destAirport?.["website"]); - - // Destination airport timezone. this.destinationAirportTimezoneAbbr = this.__getInfo(destAirportTimezone?.["abbr"]); this.destinationAirportTimezoneAbbrName = this.__getInfo(destAirportTimezone?.["abbrName"]); this.destinationAirportTimezoneName = this.__getInfo(destAirportTimezone?.["name"]); this.destinationAirportTimezoneOffset = this.__getInfo(destAirportTimezone?.["offset"]); this.destinationAirportTimezoneOffsetHours = this.__getInfo(destAirportTimezone?.["offsetHours"]); - // Origin airport position. this.originAirportAltitude = this.__getInfo(origAirportPosition?.["altitude"]); this.originAirportCountryCode = this.__getInfo(origAirportCountry?.["code"]); this.originAirportCountryName = this.__getInfo(origAirportCountry?.["name"]); this.originAirportLatitude = this.__getInfo(origAirportPosition?.["latitude"]); this.originAirportLongitude = this.__getInfo(origAirportPosition?.["longitude"]); - - // Origin airport information. this.originAirportIcao = this.__getInfo(origAirportCode?.["icao"]); this.originAirportBaggage = this.__getInfo(origAirportInfo?.["baggage"]); this.originAirportGate = this.__getInfo(origAirportInfo?.["gate"]); @@ -215,22 +215,16 @@ class Flight extends Entity { this.originAirportTerminal = this.__getInfo(origAirportInfo?.["terminal"]); this.originAirportVisible = this.__getInfo(origAirport?.["visible"]); this.originAirportWebsite = this.__getInfo(origAirport?.["website"]); - - // Origin airport timezone. this.originAirportTimezoneAbbr = this.__getInfo(origAirportTimezone?.["abbr"]); this.originAirportTimezoneAbbrName = this.__getInfo(origAirportTimezone?.["abbrName"]); this.originAirportTimezoneName = this.__getInfo(origAirportTimezone?.["name"]); this.originAirportTimezoneOffset = this.__getInfo(origAirportTimezone?.["offset"]); this.originAirportTimezoneOffsetHours = this.__getInfo(origAirportTimezone?.["offsetHours"]); - // Flight status. this.statusIcon = this.__getInfo(status?.["icon"]); this.statusText = this.__getInfo(status?.["text"]); - // Time details. this.timeDetails = this.__getInfo(flightDetails?.["time"], {}); - - // Flight trail. this.trail = this.__getInfo(flightDetails?.["trail"], []); } } diff --git a/nodejs/FlightRadar24/errors.js b/nodejs/FlightRadar24/errors.js index 5efe381..0989b1d 100644 --- a/nodejs/FlightRadar24/errors.js +++ b/nodejs/FlightRadar24/errors.js @@ -1,32 +1,29 @@ -class AirportNotFoundError extends Error { +/** Base class for all FlightRadar24 errors. */ +class FlightRadarError extends Error { + /** @param {string} message */ constructor(message) { super(message); - this.name = this.constructor.name; - Error.captureStackTrace(this, this.constructor); } } -class CloudflareError extends Error { +/** Thrown when an airport cannot be found. */ +class AirportNotFoundError extends FlightRadarError {} + +/** Thrown when a Cloudflare-level error is returned. */ +class CloudflareError extends FlightRadarError { + /** + * @param {string} message + * @param {object} response + */ constructor(message, response) { super(message); - - this.name = this.constructor.name; this.response = response; - - Error.captureStackTrace(this, this.constructor); } } -class LoginError extends Error { - constructor(message) { - super(message); - - this.name = this.constructor.name; - - Error.captureStackTrace(this, this.constructor); - } -} +/** Thrown when login fails or an authenticated endpoint is accessed without login. */ +class LoginError extends FlightRadarError {} -module.exports = {AirportNotFoundError, CloudflareError, LoginError}; +module.exports = {FlightRadarError, AirportNotFoundError, CloudflareError, LoginError}; diff --git a/nodejs/FlightRadar24/flightTrackerConfig.js b/nodejs/FlightRadar24/flightTrackerConfig.js index b645f8f..869266a 100644 --- a/nodejs/FlightRadar24/flightTrackerConfig.js +++ b/nodejs/FlightRadar24/flightTrackerConfig.js @@ -3,13 +3,14 @@ const {isNumeric} = require("./util"); const proxyHandler = { set: function(target, key, value) { - if (!target.hasOwnProperty(key)) { + if (!Object.prototype.hasOwnProperty.call(target, key)) { throw new Error("Unknown option: '" + key + "'"); } if ((typeof value !== "number") && (!isNumeric(value))) { - throw new Error("Value must be a decimal. Got '" + key + "'"); + throw new Error("Value must be a number. Got '" + value + "' for key '" + key + "'"); } target[key] = value.toString(); + return true; }, }; @@ -33,18 +34,18 @@ class FlightTrackerConfig { limit = "5000"; /** - * Constructor of FlighTrackerConfig class. + * Constructor of FlightTrackerConfig class. * - * @param {object} data + * @param {object} [data={}] */ - constructor(data) { + constructor(data = {}) { for (const key in data) { if (!Object.prototype.hasOwnProperty.call(data, key)) { // guard-for-in continue; } const value = data[key]; - if (this.hasOwnProperty(key) && (typeof value === "number" || isNumeric(value))) { + if (Object.prototype.hasOwnProperty.call(this, key) && (typeof value === "number" || isNumeric(value))) { this[key] = value; } } diff --git a/nodejs/FlightRadar24/index.d.ts b/nodejs/FlightRadar24/index.d.ts index 55d123a..0335dd2 100644 --- a/nodejs/FlightRadar24/index.d.ts +++ b/nodejs/FlightRadar24/index.d.ts @@ -17,19 +17,12 @@ export class FlightRadar24API { private __flightTrackerConfig: FlightTrackerConfig; private __loginData: {userData: any; cookies: any;} | null; - /** - * Constructor of FlightRadar24API class - * - * @param {string} [user] - Your email (optional) - * @param {string} [password] - Your password (optional) - * @param {number} [timeout=10] - Request timeout in seconds - */ - constructor(user?: string, password?: string, timeout?: number); + constructor(); /** * Return a list with all airlines. */ - getAirlines(): Promise; + getAirlines(): Promise>; /** * Download the logo of an airline from FlightRadar24 and return it as bytes. @@ -40,7 +33,7 @@ export class FlightRadar24API { getAirlineLogo( iata: string, icao: string, - ): Promise<[object, string] | undefined>; + ): Promise<[object, string] | null>; /** * Return basic information about a specific airport. @@ -105,7 +98,7 @@ export class FlightRadar24API { * * @param {string} country - Country name */ - getCountryFlag(country: string): Promise<[object, string] | undefined>; + getCountryFlag(country: string): Promise<[object, string] | null>; /** * Return the flight details from Data Live FlightRadar24. @@ -167,7 +160,7 @@ export class FlightRadar24API { /** * Return all major zones on the globe. */ - getZones(): Promise; + getZones(): object; /** * Return the search result. @@ -175,7 +168,7 @@ export class FlightRadar24API { * @param {string} query * @param {number} [limit=50] */ - search(query: string, limit?: number): Promise; + search(query: string, limit?: number): Promise>; /** * Check if the user is logged into the FlightRadar24 account. @@ -204,7 +197,7 @@ export class FlightRadar24API { setFlightTrackerConfig( flightTrackerConfig: FlightTrackerConfig | null, config?: object, - ): Promise; + ): void; } /** @@ -228,7 +221,7 @@ export class FlightTrackerConfig { /** * Constructor of FlightTrackerConfig class. */ - constructor(data: object); + constructor(data?: object); } /** @@ -273,6 +266,29 @@ export class Airport extends Entity { icao: string; iata: string; country: string; + countryCode?: string; + countryId?: string; + city?: string | null; + timezoneName?: string | null; + timezoneOffset?: number | null; + timezoneOffsetHours?: string | null; + timezoneAbbr?: string | null; + timezoneAbbrName?: string | null; + visible?: any; + website?: string | null; + reviewsUrl?: string | null; + reviews?: any; + evaluation?: any; + averageRating?: any; + totalRating?: any; + weather?: object; + runways?: any[]; + aircraftOnGround?: number | null; + aircraftVisibleOnGround?: number | null; + arrivals?: object; + departures?: object; + wikipedia?: string | null; + images?: object; /** * Constructor of Airport class. @@ -330,6 +346,53 @@ export class Flight extends Entity { originAirportIata: string; onGround: number; verticalSpeed: number; + + // Set by setFlightDetails() + aircraftAge?: any; + aircraftCountryId?: any; + aircraftHistory?: any[]; + aircraftImages?: any[]; + aircraftModel?: string | null; + airlineName?: string | null; + airlineShortName?: string | null; + destinationAirportAltitude?: number | null; + destinationAirportCountryCode?: string | null; + destinationAirportCountryName?: string | null; + destinationAirportLatitude?: number | null; + destinationAirportLongitude?: number | null; + destinationAirportIcao?: string | null; + destinationAirportBaggage?: string | null; + destinationAirportGate?: string | null; + destinationAirportName?: string | null; + destinationAirportTerminal?: string | null; + destinationAirportVisible?: any; + destinationAirportWebsite?: string | null; + destinationAirportTimezoneAbbr?: string | null; + destinationAirportTimezoneAbbrName?: string | null; + destinationAirportTimezoneName?: string | null; + destinationAirportTimezoneOffset?: number | null; + destinationAirportTimezoneOffsetHours?: string | null; + originAirportAltitude?: number | null; + originAirportCountryCode?: string | null; + originAirportCountryName?: string | null; + originAirportLatitude?: number | null; + originAirportLongitude?: number | null; + originAirportIcao?: string | null; + originAirportBaggage?: string | null; + originAirportGate?: string | null; + originAirportName?: string | null; + originAirportTerminal?: string | null; + originAirportVisible?: any; + originAirportWebsite?: string | null; + originAirportTimezoneAbbr?: string | null; + originAirportTimezoneAbbrName?: string | null; + originAirportTimezoneName?: string | null; + originAirportTimezoneOffset?: number | null; + originAirportTimezoneOffsetHours?: string | null; + statusIcon?: string | null; + statusText?: string | null; + timeDetails?: object; + trail?: any[]; /** * Constructor of Flight class. @@ -384,15 +447,20 @@ export class Flight extends Entity { setFlightDetails(flightDetails: object): void; } -export class AirportNotFoundError extends Error { +export class FlightRadarError extends Error { constructor(message?: string); } -export class CloudflareError extends Error { +export class AirportNotFoundError extends FlightRadarError { constructor(message?: string); } -export class LoginError extends Error { +export class CloudflareError extends FlightRadarError { + response: any; + constructor(message?: string, response?: any); +} + +export class LoginError extends FlightRadarError { constructor(message?: string); } diff --git a/nodejs/FlightRadar24/index.js b/nodejs/FlightRadar24/index.js index ced2d26..4de8a5e 100644 --- a/nodejs/FlightRadar24/index.js +++ b/nodejs/FlightRadar24/index.js @@ -2,14 +2,14 @@ * Unofficial SDK for FlightRadar24. * * This SDK provides flight and airport data available to the public - * on the FlightRadar24 website. + * on the FlightRadar24 website. * * See more information at: * https://www.flightradar24.com/premium/ * https://www.flightradar24.com/terms-and-conditions */ -const {AirportNotFoundError, CloudflareError, LoginError} = require("./errors"); +const {FlightRadarError, AirportNotFoundError, CloudflareError, LoginError} = require("./errors"); const FlightRadar24API = require("./api"); const FlightTrackerConfig = require("./flightTrackerConfig"); const Airport = require("./entities/airport"); @@ -17,14 +17,13 @@ const Entity = require("./entities/entity"); const Flight = require("./entities/flight"); const {Countries} = require("./core"); -const author = "Jean Loui Bernard Silva de Jesus"; -const version = "1.4.1"; +const {version, author} = require("../package.json"); module.exports = { FlightRadar24API, FlightTrackerConfig, Countries, Airport, Entity, Flight, - AirportNotFoundError, CloudflareError, LoginError, + FlightRadarError, AirportNotFoundError, CloudflareError, LoginError, author, version, }; diff --git a/nodejs/FlightRadar24/parsers.js b/nodejs/FlightRadar24/parsers.js new file mode 100644 index 0000000..dd65c99 --- /dev/null +++ b/nodejs/FlightRadar24/parsers.js @@ -0,0 +1,155 @@ +const {JSDOM} = require("jsdom"); +const Airport = require("./entities/airport"); + +/** + * Parse the airlines listing HTML page into a list of airline objects. + * + * @param {string} html + * @return {Array} + */ +function parseAirlinesHtml(html) { + const dom = new JSDOM(html); + const tbody = dom.window.document.querySelector("tbody"); + + if (!tbody) { + return []; + } + + const airlines = []; + + for (const tr of tbody.querySelectorAll("tr")) { + const tdNotranslate = tr.querySelector("td.notranslate"); + + if (!tdNotranslate) { + continue; + } + + const aElement = tdNotranslate.querySelector("a[href^='/data/airlines']"); + + if (!aElement) { + continue; + } + + const airlineName = aElement.textContent.trim(); + + if (airlineName.length < 2) { + continue; + } + + const tdElements = tr.querySelectorAll("td"); + let iata = null; + let icao = null; + + if (tdElements.length >= 4) { + const codesText = tdElements[3].textContent.trim(); + + if (codesText.includes(" / ")) { + const parts = codesText.split(" / "); + + if (parts.length === 2) { + iata = parts[0].trim(); + icao = parts[1].trim(); + } + } + else if (codesText.length === 2) { + iata = codesText; + } + else if (codesText.length === 3) { + icao = codesText; + } + } + + let nAircrafts = null; + + if (tdElements.length >= 5) { + const aircraftsText = tdElements[4].textContent.trim(); + + if (aircraftsText) { + nAircrafts = parseInt(aircraftsText.split(" ")[0].trim(), 10); + } + } + + airlines.push({"Name": airlineName, "ICAO": icao, "IATA": iata, "n_aircrafts": nAircrafts}); + } + + return airlines; +} + +/** + * Parse the airports listing HTML page for a country into a list of Airport instances. + * + * @param {string} html + * @param {string} countryHref - Full URL used to fetch the page (used to derive the display name) + * @return {Array} + */ +function parseAirportsHtml(html, countryHref) { + const dom = new JSDOM(html); + const tbody = dom.window.document.querySelector("tbody"); + + if (!tbody) { + return []; + } + + const countryDisplayName = countryHref.split("/").pop().replace(/-/g, " ") + .split(" ").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); + + const airports = []; + + for (const tr of tbody.querySelectorAll("tr")) { + const aElements = tr.querySelectorAll("a[data-iata][data-lat][data-lon]"); + + if (aElements.length === 0) { + continue; + } + + const aElement = aElements[0]; + let icao = ""; + let iata = aElement.getAttribute("data-iata") || ""; + const latitude = aElement.getAttribute("data-lat") || ""; + const longitude = aElement.getAttribute("data-lon") || ""; + let namePart = aElement.textContent.trim(); + + const smallElement = aElement.querySelector("small"); + + if (smallElement) { + let codesText = smallElement.textContent.trim(); + codesText = codesText.replace(/^\(/, "").replace(/\)$/, "").trim(); + namePart = namePart.replace(smallElement.textContent, "").replace(/\(\)/, "").trim(); + + if (codesText.includes("/")) { + const codes = codesText.split("/"); + const code1 = codes[0].trim(); + const code2 = codes[1].trim(); + + 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; + } + } + + airports.push(new Airport({ + "name": namePart, + "icao": icao, + "iata": iata, + "lat": latitude ? parseFloat(latitude) : 0.0, + "lon": longitude ? parseFloat(longitude) : 0.0, + "alt": null, + "country": countryDisplayName, + })); + } + + return airports; +} + +module.exports = {parseAirlinesHtml, parseAirportsHtml}; diff --git a/nodejs/FlightRadar24/request.js b/nodejs/FlightRadar24/request.js index 08a4f62..ba9bb45 100644 --- a/nodejs/FlightRadar24/request.js +++ b/nodejs/FlightRadar24/request.js @@ -1,5 +1,4 @@ const {CloudflareError} = require("./errors"); - const {fetch, Agent} = require("undici"); // Chrome 136 TLS cipher suites to approximate its JA3 fingerprint @@ -42,160 +41,102 @@ const chromeAgent = new Agent({ }, }); +const DEFAULT_TIMEOUT_MS = 15_000; /** - * Class to make requests to the FlightRadar24. + * Make an HTTP request to the FlightRadar24 API. + * + * @param {string} url + * @param {object} [options={}] + * @param {object} [options.params] - Query string parameters appended to the URL + * @param {object} [options.headers] - Request headers + * @param {object} [options.data] - POST body fields (presence triggers POST method) + * @param {object} [options.cookies] - Cookies to include in the request + * @param {Array} [options.allowedErrorCodes=[]] - Status codes that should not throw + * @param {number} [options.timeout=15000] - Request timeout in milliseconds + * @return {Promise<{content: *, statusCode: number, cookies: object}>} */ -class APIRequest { - /** - * Constructor of the APIRequest class. - * - * @param {string} [url] - * @param {object} [params] - * @param {object} [headers] - * @param {object} [data] - * @param {object} [cookies] - * @param {object} [excludeStatusCodes=[]] - */ - constructor(url, params = null, headers = null, data = null, cookies = null, excludeStatusCodes = []) { - this.requestParams = { - "params": params, - "headers": headers, - "data": data, - "cookies": cookies, - }; - - this.requestMethod = data == null ? "GET" : "POST"; - this.__excludeStatusCodes = excludeStatusCodes; - - if (params != null && Object.keys(params).length > 0) { - url += "?"; - - for (const key in params) { - if (Object.prototype.hasOwnProperty.call(params, key)) { // guard-for-in - url += key + "=" + params[key] + "&"; - } - } - url = url.slice(0, -1); - } - - this.url = url; - - this.__response = {}; - this.__content = null; +async function request(url, { + params = null, + headers = null, + data = null, + cookies = null, + allowedErrorCodes = [], + timeout = DEFAULT_TIMEOUT_MS, +} = {}) { + if (params !== null && Object.keys(params).length > 0) { + url += "?" + new URLSearchParams(params).toString(); } - /** - * Send the request and receive a response. - * - * @return {this} - */ - async receive() { - const settings = { - method: this.requestMethod, - headers: this.requestParams["headers"], - dispatcher: chromeAgent, - }; - - if (settings["method"] == "POST") { - const formData = new URLSearchParams(); - - Object.entries(this.requestParams["data"]).forEach(([key, value]) => { - formData.append(key, value); - }); - - settings["body"] = formData; - } + const requestHeaders = Object.assign({}, headers); - this.__response = await fetch(this.url, settings); + if (cookies !== null && Object.keys(cookies).length > 0) { + requestHeaders["Cookie"] = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join("; "); + } - if (this.getStatusCode() == 520) { - throw new CloudflareError( - "An unexpected error has occurred. Perhaps you are making too many calls?", - this.__response, - ); - } + const method = data === null ? "GET" : "POST"; + const settings = {method, headers: requestHeaders, dispatcher: chromeAgent}; - if (!this.__excludeStatusCodes.includes(this.getStatusCode())) { - if (![200, 201, 202].includes(this.getStatusCode())) { - throw new Error( - "Received status code '" + - this.getStatusCode() + ": " + - this.__response.statusText + "' for the URL " + - this.url, - ); - } - } - return this; + if (method === "POST") { + const formData = new URLSearchParams(); + Object.entries(data).forEach(([key, value]) => formData.append(key, value)); + settings.body = formData; } - /** - * Return the received content from the request. - */ - async getContent() { - if (this.__content !== null) { - return this.__content; - } - - let contentType = this.getHeaders()["content-type"]; - contentType = contentType == null ? "" : contentType; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + settings.signal = controller.signal; - if (contentType.includes("application/json")) { - this.__content = await this.__response.json(); - } - else if (contentType.includes("text")) { - this.__content = await this.__response.text(); - } - else { - this.__content = await this.__response.arrayBuffer(); + let response; + try { + response = await fetch(url, settings); + } + catch (err) { + if (err.name === "AbortError") { + throw new Error(`Request timed out after ${timeout}ms for URL ${url}`); } - return this.__content; + throw err; + } + finally { + clearTimeout(timer); } + const statusCode = response.status; - /** - * Return the received cookies from the request. - */ - getCookies() { - const rawCookies = this.__response.headers.getSetCookie(); - const cookies = {}; + if (statusCode === 520) { + throw new CloudflareError( + "An unexpected error has occurred. Perhaps you are making too many calls?", + response, + ); + } - if (rawCookies == null || rawCookies.length === 0) { - return {}; - } + if (!allowedErrorCodes.includes(statusCode) && (statusCode < 200 || statusCode >= 300)) { + throw new Error(`Received status code '${statusCode}: ${response.statusText}' for the URL ${url}`); + } - rawCookies.forEach((string) => { - const keyAndValue = string.split(";")[0].split("="); - cookies[keyAndValue[0]] = keyAndValue[1]; - }); + const contentType = response.headers.get("content-type") ?? ""; + let content; - return cookies; + if (contentType.includes("application/json")) { + content = await response.json(); + } + else if (contentType.includes("text")) { + content = await response.text(); + } + else { + content = await response.arrayBuffer(); } - /** - * Return the headers of the response. - */ - getHeaders() { - const headersAsDict = {}; + const rawCookies = response.headers.getSetCookie(); + const responseCookies = {}; - this.__response.headers.forEach((value, key) => { - headersAsDict[key] = value; + if (rawCookies?.length > 0) { + rawCookies.forEach((string) => { + const keyAndValue = string.split(";")[0].split("="); + responseCookies[keyAndValue[0]] = keyAndValue[1]; }); - return headersAsDict; } - /** - * Return the received response object. - */ - getResponseObject() { - return this.__response; - } - - /** - * Return the status code of the response. - */ - getStatusCode() { - return this.__response.status; - } + return {content, statusCode, cookies: responseCookies}; } -module.exports = APIRequest; +module.exports = {request}; diff --git a/nodejs/FlightRadar24/util.js b/nodejs/FlightRadar24/util.js index 52fe6ff..50893ae 100644 --- a/nodejs/FlightRadar24/util.js +++ b/nodejs/FlightRadar24/util.js @@ -1,20 +1,27 @@ /** - * Check if the string is an integer + * Check if a string represents a non-negative integer. * * @param {string} text * @return {boolean} */ function isNumeric(text) { - if (text.length === 0) { - return false; - } - - for (let index = 0; index < text.length; index++) { - if (!"0123456789".includes(text[index])) { - return false; - } - } - return true; + return text.length > 0 && /^\d+$/.test(text); } -module.exports = {isNumeric}; +/** + * Convert degrees to radians. + * + * @param {number} x + * @return {number} + */ +const radians = (x) => x * (Math.PI / 180); + +/** + * Convert radians to degrees. + * + * @param {number} x + * @return {number} + */ +const rad2deg = (x) => x * (180 / Math.PI); + +module.exports = {isNumeric, radians, rad2deg}; diff --git a/nodejs/FlightRadar24/zones.js b/nodejs/FlightRadar24/zones.js index caa6ea9..35ca6d9 100644 --- a/nodejs/FlightRadar24/zones.js +++ b/nodejs/FlightRadar24/zones.js @@ -1,206 +1,206 @@ staticZones = { - "europe": { - "tl_y": 72.57, - "tl_x": -16.96, - "br_y": 33.57, - "br_x": 53.05, - "subzones": { - "poland": { - "tl_y": 56.86, - "tl_x": 11.06, - "br_y": 48.22, - "br_x": 28.26 - }, - "germany": { - "tl_y": 57.92, - "tl_x": 1.81, - "br_y": 45.81, - "br_x": 16.83 - }, - "uk": { - "tl_y": 62.61, - "tl_x": -13.07, - "br_y": 49.71, - "br_x": 3.46, + "europe": { + "tl_y": 72.57, + "tl_x": -16.96, + "br_y": 33.57, + "br_x": 53.05, "subzones": { - "london": { - "tl_y": 53.06, - "tl_x": -2.87, - "br_y": 50.07, - "br_x": 3.26 - }, - "ireland": { - "tl_y": 56.22, - "tl_x": -11.71, - "br_y": 50.91, - "br_x": -4.4 - } - } - }, - "spain": { - "tl_y": 44.36, - "tl_x": -11.06, - "br_y": 35.76, - "br_x": 4.04 - }, - "france": { - "tl_y": 51.07, - "tl_x": -5.18, - "br_y": 42.17, - "br_x": 8.9 - }, - "ceur": { - "tl_y": 51.39, - "tl_x": 11.25, - "br_y": 39.72, - "br_x": 32.55 - }, - "scandinavia": { - "tl_y": 72.12, - "tl_x": -0.73, - "br_y": 53.82, - "br_x": 40.67 - }, - "italy": { - "tl_y": 47.67, - "tl_x": 5.26, - "br_y": 36.27, - "br_x": 20.64 - } - } - }, - "northamerica": { - "tl_y": 75, - "tl_x": -180, - "br_y": 3, - "br_x": -52, - "subzones": { - "na_n": { - "tl_y": 72.82, - "tl_x": -177.97, - "br_y": 41.92, - "br_x": -52.48 - }, - "na_c": { - "tl_y": 54.66, - "tl_x": -134.68, - "br_y": 22.16, - "br_x": -56.91, + "poland": { + "tl_y": 56.86, + "tl_x": 11.06, + "br_y": 48.22, + "br_x": 28.26, + }, + "germany": { + "tl_y": 57.92, + "tl_x": 1.81, + "br_y": 45.81, + "br_x": 16.83, + }, + "uk": { + "tl_y": 62.61, + "tl_x": -13.07, + "br_y": 49.71, + "br_x": 3.46, + "subzones": { + "london": { + "tl_y": 53.06, + "tl_x": -2.87, + "br_y": 50.07, + "br_x": 3.26, + }, + "ireland": { + "tl_y": 56.22, + "tl_x": -11.71, + "br_y": 50.91, + "br_x": -4.4, + }, + }, + }, + "spain": { + "tl_y": 44.36, + "tl_x": -11.06, + "br_y": 35.76, + "br_x": 4.04, + }, + "france": { + "tl_y": 51.07, + "tl_x": -5.18, + "br_y": 42.17, + "br_x": 8.9, + }, + "ceur": { + "tl_y": 51.39, + "tl_x": 11.25, + "br_y": 39.72, + "br_x": 32.55, + }, + "scandinavia": { + "tl_y": 72.12, + "tl_x": -0.73, + "br_y": 53.82, + "br_x": 40.67, + }, + "italy": { + "tl_y": 47.67, + "tl_x": 5.26, + "br_y": 36.27, + "br_x": 20.64, + }, + }, + }, + "northamerica": { + "tl_y": 75, + "tl_x": -180, + "br_y": 3, + "br_x": -52, "subzones": { - "na_cny": { - "tl_y": 45.06, - "tl_x": -83.69, - "br_y": 35.96, - "br_x": -64.29 - }, - "na_cla": { - "tl_y": 37.91, - "tl_x": -126.12, - "br_y": 30.21, - "br_x": -110.02 - }, - "na_cat": { - "tl_y": 35.86, - "tl_x": -92.61, - "br_y": 22.56, - "br_x": -71.19 - }, - "na_cse": { - "tl_y": 49.12, - "tl_x": -126.15, - "br_y": 42.97, - "br_x": -111.92 - }, - "na_nw": { - "tl_y": 54.12, - "tl_x": -134.13, - "br_y": 38.32, - "br_x": -96.75 - }, - "na_ne": { - "tl_y": 53.72, - "tl_x": -98.76, - "br_y": 38.22, - "br_x": -57.36 - }, - "na_sw": { - "tl_y": 38.92, - "tl_x": -133.98, - "br_y": 22.62, - "br_x": -96.75 - }, - "na_se": { - "tl_y": 38.52, - "tl_x": -98.62, - "br_y": 22.52, - "br_x": -57.36 - }, - "na_cc": { - "tl_y": 45.92, - "tl_x": -116.88, - "br_y": 27.62, - "br_x": -75.91 - } - } - }, - "na_s": { - "tl_y": 41.92, - "tl_x": -177.83, - "br_y": 3.82, - "br_x": -52.48 - } - } - }, - "southamerica": { - "tl_y": 16, - "tl_x": -96, - "br_y": -57, - "br_x": -31 - }, - "oceania": { - "tl_y": 19.62, - "tl_x": 88.4, - "br_y": -55.08, - "br_x": 180 - }, - "asia": { - "tl_y": 79.98, - "tl_x": 40.91, - "br_y": 12.48, - "br_x": 179.77, - "subzones": { - "japan": { - "tl_y": 60.38, - "tl_x": 113.5, - "br_y": 22.58, - "br_x": 176.47 - } - } - }, - "africa": { - "tl_y": 39, - "tl_x": -29, - "br_y": -39, - "br_x": 55 - }, - "atlantic": { - "tl_y": 52.62, - "tl_x": -50.9, - "br_y": 15.62, - "br_x": -4.75 - }, - "maldives": { - "tl_y": 10.72, - "tl_x": 63.1, - "br_y": -6.08, - "br_x": 86.53 - }, - "northatlantic": { - "tl_y": 82.62, - "tl_x": -84.53, - "br_y": 59.02, - "br_x": 4.45 - } + "na_n": { + "tl_y": 72.82, + "tl_x": -177.97, + "br_y": 41.92, + "br_x": -52.48, + }, + "na_c": { + "tl_y": 54.66, + "tl_x": -134.68, + "br_y": 22.16, + "br_x": -56.91, + "subzones": { + "na_cny": { + "tl_y": 45.06, + "tl_x": -83.69, + "br_y": 35.96, + "br_x": -64.29, + }, + "na_cla": { + "tl_y": 37.91, + "tl_x": -126.12, + "br_y": 30.21, + "br_x": -110.02, + }, + "na_cat": { + "tl_y": 35.86, + "tl_x": -92.61, + "br_y": 22.56, + "br_x": -71.19, + }, + "na_cse": { + "tl_y": 49.12, + "tl_x": -126.15, + "br_y": 42.97, + "br_x": -111.92, + }, + "na_nw": { + "tl_y": 54.12, + "tl_x": -134.13, + "br_y": 38.32, + "br_x": -96.75, + }, + "na_ne": { + "tl_y": 53.72, + "tl_x": -98.76, + "br_y": 38.22, + "br_x": -57.36, + }, + "na_sw": { + "tl_y": 38.92, + "tl_x": -133.98, + "br_y": 22.62, + "br_x": -96.75, + }, + "na_se": { + "tl_y": 38.52, + "tl_x": -98.62, + "br_y": 22.52, + "br_x": -57.36, + }, + "na_cc": { + "tl_y": 45.92, + "tl_x": -116.88, + "br_y": 27.62, + "br_x": -75.91, + }, + }, + }, + "na_s": { + "tl_y": 41.92, + "tl_x": -177.83, + "br_y": 3.82, + "br_x": -52.48, + }, + }, + }, + "southamerica": { + "tl_y": 16, + "tl_x": -96, + "br_y": -57, + "br_x": -31, + }, + "oceania": { + "tl_y": 19.62, + "tl_x": 88.4, + "br_y": -55.08, + "br_x": 180, + }, + "asia": { + "tl_y": 79.98, + "tl_x": 40.91, + "br_y": 12.48, + "br_x": 179.77, + "subzones": { + "japan": { + "tl_y": 60.38, + "tl_x": 113.5, + "br_y": 22.58, + "br_x": 176.47, + }, + }, + }, + "africa": { + "tl_y": 39, + "tl_x": -29, + "br_y": -39, + "br_x": 55, + }, + "atlantic": { + "tl_y": 52.62, + "tl_x": -50.9, + "br_y": 15.62, + "br_x": -4.75, + }, + "maldives": { + "tl_y": 10.72, + "tl_x": 63.1, + "br_y": -6.08, + "br_x": 86.53, + }, + "northatlantic": { + "tl_y": 82.62, + "tl_x": -84.53, + "br_y": 59.02, + "br_x": 4.45, + }, }; -module.exports = {staticZones}; \ No newline at end of file +module.exports = {staticZones}; diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index a2b4eab..de5e70b 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "flightradarapi", - "version": "1.4.1", + "version": "1.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "flightradarapi", - "version": "1.4.1", + "version": "1.5.0", "license": "MIT", "dependencies": { "jsdom": "^24.0.0", diff --git a/nodejs/package.json b/nodejs/package.json index c3657d9..ff1094b 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "flightradarapi", - "version": "1.4.1", + "version": "1.5.0", "description": "SDK for FlightRadar24", "main": "./FlightRadar24/index.js", "scripts": { diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index 720b1c9..15ddb0a 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -1,4 +1,4 @@ -const {FlightRadar24API, Countries, version} = require(".."); +const {FlightRadar24API, Flight, Countries, version} = require(".."); const expect = require("chai").expect; @@ -13,15 +13,8 @@ describe("Testing FlightRadarAPI version " + version, function() { const results = await frApi.getAirlines(); expect(results.length).to.be.above(expected - 1); - const found = []; - - for (const airline of results) { - if (airlines.includes(airline.ICAO) && !found.includes(airline)) { - found.push(airline); - } - } - - expect(found.length).to.be.equal(airlines.length); + const foundIcaos = new Set(results.filter(a => airlines.includes(a.ICAO)).map(a => a.ICAO)); + expect(foundIcaos.size).to.equal(airlines.length); }); }); @@ -63,8 +56,8 @@ describe("Testing FlightRadarAPI version " + version, function() { const expected = 5; const targetKeys = ["tl_y", "tl_x", "br_y", "br_x"]; - it("Expected at least " + expected + " zones.", async function() { - const results = await frApi.getZones(); + it("Expected at least " + expected + " zones.", function() { + const results = frApi.getZones(); expect(Object.entries(results).length).to.be.above(expected - 1); for (const key in results) { @@ -90,14 +83,9 @@ describe("Testing FlightRadarAPI version " + version, function() { it("Expected getting the following information: " + targetKeys.join(", ") + ".", async function() { const flights = await frApi.getFlights(); - const middle = Math.trunc(flights.length / 2); - - const someFlights = flights.slice(middle - 2, middle + 2); - - for (const flight of someFlights) { - const details = await frApi.getFlightDetails(flight); - expect(details).to.include.all.keys(targetKeys); - } + const flight = flights[Math.trunc(flights.length / 2)]; + const details = await frApi.getFlightDetails(flight); + expect(details).to.include.all.keys(targetKeys); }); }); @@ -118,7 +106,7 @@ describe("Testing FlightRadarAPI version " + version, function() { expect(flight.airlineIcao).to.be.equal(airline); } - count = flights.length > 0 ? count + 1 : count; + if (flights.length) count++; } expect(count).to.be.above(expected - 1); }); @@ -129,7 +117,7 @@ describe("Testing FlightRadarAPI version " + version, function() { const targetZones = ["northamerica", "southamerica"]; it("Expected at least " + expected + " flights at: " + targetZones.join(", ") + ".", async function() { - const zones = await frApi.getZones(); + const zones = frApi.getZones(); for (const zoneName of targetZones) { const zone = zones[zoneName]; @@ -142,7 +130,7 @@ describe("Testing FlightRadarAPI version " + version, function() { expect(flight.latitude).to.be.above(zone["br_y"]); expect(flight.longitude).to.be.below(zone["br_x"]); - expect(flight.latitude).to.be.above(zone["tl_x"]); + expect(flight.longitude).to.be.above(zone["tl_x"]); } expect(flights.length).to.be.above(expected - 1); } @@ -153,11 +141,7 @@ describe("Testing FlightRadarAPI version " + version, function() { const targetAirlines = [["WN", "SWA"], ["G3", "GLO"], ["AD", "AZU"], ["AA", "AAL"], ["TK", "THY"]]; const expected = targetAirlines.length * 0.8; - const icao = []; - - for (const airline of targetAirlines) { - icao.push(airline[1]); - } + const icao = targetAirlines.map(a => a[1]); let message = "Expected getting logos from at least " + Math.trunc(expected); message += " of the following airlines: " + icao.join(", ") + "."; @@ -167,7 +151,7 @@ describe("Testing FlightRadarAPI version " + version, function() { for (const airline of targetAirlines) { const result = await frApi.getAirlineLogo(airline[0], airline[1]); - found = result != null && result[0].byteLength > 512 ? found + 1 : found; + if (result != null && result[0].byteLength > 512) found++; } expect(found).to.be.above(expected - 1); }); @@ -185,7 +169,7 @@ describe("Testing FlightRadarAPI version " + version, function() { for (const country of targetCountries) { const result = await frApi.getCountryFlag(country); - found = result != null && result[0].byteLength > 512 ? found + 1 : found; + if (result != null && result[0].byteLength > 512) found++; } expect(found).to.be.above(expected - 1); }); @@ -194,9 +178,79 @@ describe("Testing FlightRadarAPI version " + version, function() { describe("Getting Bounds by Point", function() { const expected = "52.58594974202871,52.54997688140807,13.253064418048115,13.3122478541492"; - it("Formula for calculating bounds is correct.", async function() { + it("Formula for calculating bounds is correct.", function() { const bounds = frApi.getBoundsByPoint(52.567967, 13.282644, 2000); expect(bounds).to.be.equal(expected); }); }); + + // --- Unit tests (no network) --- + + describe("getBounds()", function() { + it("Converts zone dict to coordinate string.", function() { + const zone = {"tl_y": 75.78, "br_y": -75.78, "tl_x": -427.56, "br_x": 427.56}; + expect(frApi.getBounds(zone)).to.equal("75.78,-75.78,-427.56,427.56"); + }); + }); + + describe("getAirport() — invalid code", function() { + it("Throws when airport code is too short.", async function() { + try { + await frApi.getAirport("X"); + expect.fail("Expected an error to be thrown."); + } + catch (err) { + expect(err).to.be.instanceof(Error); + } + }); + }); + + describe("setFlightTrackerConfig() — invalid key", function() { + it("Throws when an unknown config key is set.", function() { + expect(() => frApi.setFlightTrackerConfig(null, {unknownKey: "1"})).to.throw(); + }); + }); + + describe("setFlightTrackerConfig() — invalid value", function() { + it("Throws when a non-numeric value is set.", function() { + expect(() => frApi.setFlightTrackerConfig(null, {limit: "not_a_number"})).to.throw(); + }); + }); + + describe("Flight.checkInfo()", function() { + const info = [ + "ABC123", -23.5, -46.6, 180, 35000, 450, + "1234", null, "B738", "PR-ABC", 1620000000, + "GRU", "GIG", "G31234", 0, 0, "GLO1234", null, "GLO", + ]; + const flight = new Flight("123456789", info); + + it("Exact match returns true.", function() { + expect(flight.checkInfo({altitude: 35000})).to.be.true; + }); + + it("Min/max range within bounds returns true.", function() { + expect(flight.checkInfo({minAltitude: 30000, maxAltitude: 40000})).to.be.true; + }); + + it("Exact mismatch returns false.", function() { + expect(flight.checkInfo({altitude: 40000})).to.be.false; + }); + + it("Max exceeded returns false.", function() { + expect(flight.checkInfo({maxAltitude: 30000})).to.be.false; + }); + + it("String field match returns true.", function() { + expect(flight.checkInfo({airlineIcao: "GLO"})).to.be.true; + }); + + it("String field mismatch returns false.", function() { + expect(flight.checkInfo({airlineIcao: "TAM"})).to.be.false; + }); + + it("Combined conditions all matching returns true.", function() { + expect(flight.checkInfo({minAltitude: 30000, maxAltitude: 40000, airlineIcao: "GLO"})).to.be.true; + }); + }); }); diff --git a/nodejs/tests/testSnapshots.js b/nodejs/tests/testSnapshots.js new file mode 100644 index 0000000..f86949d --- /dev/null +++ b/nodejs/tests/testSnapshots.js @@ -0,0 +1,254 @@ +const {FlightRadar24API, Countries} = require(".."); +const expect = require("chai").expect; + + +/** + * Recursively asserts that `actual` contains all keys described in `shape`. + * + * Shape leaf values: + * null — key must exist; value may be anything including null + * "string" — key must exist and typeof value must equal "string" + * {} — key must be a non-null object with at least these nested keys + * [] — key must be an array; [elementShape] also checks first element + * + * @param {*} actual + * @param {*} shape + * @param {string} path + */ +function assertShape(actual, shape, path = "root") { + if (shape === null) { + expect(actual, `key missing at "${path}"`).to.not.equal(undefined); + return; + } + if (typeof shape === "string") { + expect(typeof actual, `type mismatch at "${path}": expected "${shape}", got "${typeof actual}"`).to.equal(shape); + return; + } + if (Array.isArray(shape)) { + expect(actual, `"${path}" must be an array`).to.be.an("array"); + if (shape.length > 0 && actual.length > 0) { + assertShape(actual[0], shape[0], `${path}[0]`); + } + return; + } + expect(actual, `"${path}" must be a non-null object`).to.be.an("object").and.not.equal(null); + for (const key of Object.keys(shape)) { + expect(actual, `missing key "${key}" at "${path}"`).to.have.property(key); + assertShape(actual[key], shape[key], `${path}.${key}`); + } +} + + +// --- Shape descriptors --- +// Leaf null → key must exist, any value +// Leaf type → key must exist with that typeof +// Nested {} → recurse +// [] → array; [s] also checks first element against s + +const FLIGHT_SHAPE = { + id: "string", + icao24bit: null, + latitude: null, + longitude: null, + heading: null, + altitude: null, + groundSpeed: null, + squawk: null, + aircraftCode: null, + registration: null, + time: null, + originAirportIata: null, + destinationAirportIata: null, + number: null, + airlineIata: null, + onGround: null, + verticalSpeed: null, + callsign: null, + airlineIcao: null, +}; + +const FLIGHT_DETAILS_SHAPE = { + aircraft: null, + airline: null, + airport: { + destination: null, + origin: null, + }, + status: { + icon: null, + text: null, + }, + time: null, + trail: [], +}; + +const AIRPORT_SHAPE = { + name: null, + icao: null, + iata: null, + country: null, + latitude: null, + longitude: null, + altitude: null, +}; + +const AIRPORT_DETAILS_SHAPE = { + airport: { + pluginData: { + details: { + code: { + iata: null, + icao: null, + }, + name: null, + position: { + country: {name: null}, + latitude: null, + longitude: null, + }, + timezone: { + name: null, + offset: null, + }, + }, + }, + }, + airlines: null, + aircraftImages: null, +}; + +const AIRLINE_SHAPE = { + Name: null, + ICAO: null, + IATA: null, + n_aircrafts: null, +}; + +const ZONE_SHAPE = { + tl_y: "number", + tl_x: "number", + br_y: "number", + br_x: "number", +}; + + +// --- Tests --- + +describe("Snapshot Tests", function() { + const frApi = new FlightRadar24API(); + let flights; + + before(async function() { + flights = await frApi.getFlights(); + expect(flights.length, "getFlights() returned no flights — subsequent tests will be meaningless").to.be.above(0); + }); + + describe("getFlights()", function() { + it("Flight instances match expected shape.", function() { + expect(flights.length).to.be.above(0); + assertShape(flights[0], FLIGHT_SHAPE); + }); + }); + + describe("getFlightDetails()", function() { + it("Flight details response matches expected shape.", async function() { + const flight = flights[Math.trunc(flights.length / 2)]; + const details = await frApi.getFlightDetails(flight); + assertShape(details, FLIGHT_DETAILS_SHAPE); + }); + }); + + describe("getAirport()", function() { + it("Airport instance matches expected shape.", async function() { + const airport = await frApi.getAirport("ATL"); + assertShape(airport, AIRPORT_SHAPE); + }); + }); + + describe("getAirportDetails()", function() { + it("Airport details response matches expected shape.", async function() { + const details = await frApi.getAirportDetails("ATL", 1); + assertShape(details, AIRPORT_DETAILS_SHAPE); + }); + }); + + describe("getAirlines()", function() { + it("Airline objects match expected shape.", async function() { + const airlines = await frApi.getAirlines(); + expect(airlines.length).to.be.above(0); + assertShape(airlines[0], AIRLINE_SHAPE); + }); + }); + + describe("getAirlineLogo()", function() { + it("Airline logo response is [ArrayBuffer, string] or null.", async function() { + const result = await frApi.getAirlineLogo("G3", "GLO"); + if (result !== null) { + expect(result).to.be.an("array").with.lengthOf(2); + expect(result[0]).to.be.instanceof(ArrayBuffer); + expect(result[1]).to.be.a("string").with.length.above(0); + } + }); + }); + + describe("getAirports()", function() { + it("Airport list items match expected shape.", async function() { + const airports = await frApi.getAirports([Countries.BRAZIL]); + expect(airports.length).to.be.above(0); + assertShape(airports[0], AIRPORT_SHAPE); + }); + }); + + describe("getZones()", function() { + it("Zone objects match expected shape.", function() { + const zones = frApi.getZones(); + const zoneValues = Object.values(zones); + expect(zoneValues.length).to.be.above(0); + for (const zone of zoneValues) { + assertShape(zone, ZONE_SHAPE); + } + }); + }); + + describe("getCountryFlag()", function() { + it("Country flag response is [ArrayBuffer, string] or null.", async function() { + const result = await frApi.getCountryFlag("Brazil"); + if (result !== null) { + expect(result).to.be.an("array").with.lengthOf(2); + expect(result[0]).to.be.instanceof(ArrayBuffer); + expect(result[1]).to.be.a("string").with.length.above(0); + } + }); + }); + + describe("getMostTracked()", function() { + it("Most tracked response is a non-null object.", async function() { + const result = await frApi.getMostTracked(); + expect(result).to.be.an("object").and.not.equal(null); + }); + }); + + describe("getAirportDisruptions()", function() { + it("Airport disruptions response is a non-null object.", async function() { + const result = await frApi.getAirportDisruptions(); + expect(result).to.be.an("object").and.not.equal(null); + }); + }); + + describe("getVolcanicEruptions()", function() { + it("Volcanic eruptions response is a non-null object.", async function() { + const result = await frApi.getVolcanicEruptions(); + expect(result).to.be.an("object").and.not.equal(null); + }); + }); + + describe("search()", function() { + it("Search response is a dictionary of result arrays.", async function() { + const result = await frApi.search("Guarulhos"); + expect(result).to.be.an("object").and.not.equal(null); + for (const key of Object.keys(result)) { + expect(result[key], `search key "${key}" must be an array`).to.be.an("array"); + } + }); + }); +}); diff --git a/python/FlightRadar24/__init__.py b/python/FlightRadar24/__init__.py index 9c7fca2..9610b5a 100644 --- a/python/FlightRadar24/__init__.py +++ b/python/FlightRadar24/__init__.py @@ -14,5 +14,21 @@ __author__ = "Jean Loui Bernard Silva de Jesus" __version__ = "1.4.1" -from .api import Countries, FlightRadar24API, FlightTrackerConfig +from .api import FlightRadar24API +from .core import Countries from .entities import Airport, Entity, Flight +from .errors import AirportNotFoundError, CloudflareError, FlightRadarError, LoginError +from .flight_tracker_config import FlightTrackerConfig + +__all__ = [ + "FlightRadar24API", + "Countries", + "Airport", + "Entity", + "Flight", + "AirportNotFoundError", + "CloudflareError", + "FlightRadarError", + "LoginError", + "FlightTrackerConfig", +] diff --git a/python/FlightRadar24/api.py b/python/FlightRadar24/api.py index 5089578..0b1eab4 100644 --- a/python/FlightRadar24/api.py +++ b/python/FlightRadar24/api.py @@ -1,39 +1,21 @@ # -*- coding: utf-8 -*- -from typing import Any, Dict, List, Optional, Tuple, Union -from bs4 import BeautifulSoup - import dataclasses import math +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, List, Optional, Tuple, Union +from urllib.parse import quote from .core import Core, Countries from .entities.airport import Airport 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 .request import APIRequest -@dataclasses.dataclass -class FlightTrackerConfig(object): - """ - Data class with settings of the Real Time Flight Tracker. - """ - faa: str = "1" - satellite: str = "1" - mlat: str = "1" - flarm: str = "1" - adsb: str = "1" - gnd: str = "1" - air: str = "1" - vehicles: str = "1" - estimated: str = "1" - maxage: str = "14400" - gliders: str = "1" - stats: str = "1" - limit: str = "5000" - - -class FlightRadar24API(object): +class FlightRadar24API: """ Main class of the FlightRadarAPI """ @@ -58,75 +40,7 @@ def get_airlines(self) -> List[Dict]: Return a list with all airlines. """ response = APIRequest(Core.airlines_data_url, headers=Core.html_headers, timeout=self.timeout) - html_content: bytes = response.get_content() - airlines_data = [] - - # Parse HTML content. - soup = BeautifulSoup(html_content, "html.parser") - - tbody = soup.find("tbody") - - if not tbody: - return [] - - # Extract data from HTML content. - tr_elements = tbody.find_all("tr") - - for tr in tr_elements: - td_notranslate = tr.find("td", class_="notranslate") - - if td_notranslate: - a_element = td_notranslate.find("a", href=lambda href: href and href.startswith("/data/airlines")) - - if a_element: - td_elements = tr.find_all("td") - - # Extract airline name. - airline_name = a_element.get_text(strip=True) - - if len(airline_name) < 2: - continue - - # Extract IATA / ICAO codes. - iata = None - icao = None - - if len(td_elements) >= 4: - codes_text = td_elements[3].get_text(strip=True) - - if " / " in codes_text: - parts = codes_text.split(" / ") - - if len(parts) == 2: - iata = parts[0].strip() - icao = parts[1].strip() - - elif len(codes_text) == 2: - iata = codes_text - - elif len(codes_text) == 3: - icao = codes_text - - # Extract number of aircrafts. - n_aircrafts = None - - if len(td_elements) >= 5: - aircrafts_text = td_elements[4].get_text(strip=True) - - if aircrafts_text: - n_aircrafts = aircrafts_text.split(" ", maxsplit=1)[0].strip() - n_aircrafts = int(n_aircrafts) - - airline_data = { - "Name": airline_name, - "ICAO": icao, - "IATA": iata, - "n_aircrafts": n_aircrafts - } - - airlines_data.append(airline_data) - - return airlines_data + return parse_airlines_html(response.get_content()) def get_airline_logo(self, iata: str, icao: str) -> Optional[Tuple[bytes, str]]: """ @@ -140,16 +54,16 @@ def get_airline_logo(self, iata: str, icao: str) -> Optional[Tuple[bytes, str]]: response = APIRequest(first_logo_url, headers=Core.image_headers, exclude_status_codes=[403,], timeout=self.timeout) status_code = response.get_status_code() - if not str(status_code).startswith("4"): + if not (400 <= status_code < 500): return response.get_content(), first_logo_url.split(".")[-1] # Get the image by the second airline logo URL. second_logo_url = Core.alternative_airline_logo_url.format(icao) - response = APIRequest(second_logo_url, headers=Core.image_headers, timeout=self.timeout) + response = APIRequest(second_logo_url, headers=Core.image_headers, exclude_status_codes=[403, 404], timeout=self.timeout) status_code = response.get_status_code() - if not str(status_code).startswith("4"): + if not (400 <= status_code < 500): return response.get_content(), second_logo_url.split(".")[-1] def get_airport(self, code: str, *, details: bool = False) -> Airport: @@ -159,15 +73,12 @@ def get_airport(self, code: str, *, details: bool = False) -> Airport: :param code: ICAO or IATA of the airport :param details: If True, it returns an Airport instance with detailed information. """ - if 4 < len(code) or len(code) < 3: + if not (3 <= len(code) <= 4): raise ValueError(f"The code '{code}' is invalid. It must be the IATA or ICAO of the airport.") if details: airport = Airport() - - airport_details = self.get_airport_details(code) - airport.set_airport_details(airport_details) - + airport.set_airport_details(self.get_airport_details(code)) return airport response = APIRequest(Core.airport_data_url.format(code), headers=Core.json_headers, timeout=self.timeout) @@ -186,7 +97,7 @@ def get_airport_details(self, code: str, flight_limit: int = 100, page: int = 1) :param flight_limit: Limit of flights related to the airport :param page: Page of result to display """ - if 4 < len(code) or len(code) < 3: + if not (3 <= len(code) <= 4): raise ValueError(f"The code '{code}' is invalid. It must be the IATA or ICAO of the airport.") request_params = {"format": "json"} @@ -235,95 +146,15 @@ def get_airports(self, countries: List[Countries]) -> List[Airport]: :param countries: List of country names from Countries enum. """ - airports = [] - - for country_name in countries: - country_href = Core.airports_data_url + "/" + country_name.value - - response = APIRequest(country_href, headers=Core.html_headers, timeout=self.timeout) + def _fetch(country): + href = Core.airports_data_url + "/" + country.value + response = APIRequest(href, headers=Core.html_headers, timeout=self.timeout) + return parse_airports_html(response.get_content(), href) - html_content: bytes = response.get_content() - - soup = BeautifulSoup(html_content, "html.parser") - - tbody = soup.find("tbody") - - if not tbody: - continue - - # Extract country name from the URL - country_name = country_href.split("/")[-1].replace("-", " ").title() - - tr_elements = tbody.find_all("tr") - - for tr in tr_elements: - a_elements = tr.find_all("a", attrs={"data-iata": True, "data-lat": True, "data-lon": True}) - - if a_elements: - a_element = a_elements[0] - - icao = "" - iata = a_element.get("data-iata", "").strip() - latitude = a_element.get("data-lat", "").strip() - longitude = a_element.get("data-lon", "").strip() - - airport_text = a_element.get_text(strip=True) - name_part = airport_text - - # Get IATA / ICAO from airport text. - small_element = a_element.find("small") - - if small_element: - codes_text = small_element.get_text(strip=True) - codes_text = codes_text.lstrip("(") - codes_text = codes_text.rstrip(")") - codes_text = codes_text.strip() - - # Remove IATA / ICAO from name part. - name_part = name_part.replace(codes_text, "") - name_part = name_part.replace("()", "").strip() - - # Parse codes (can be "IATA/ICAO", "IATA", or "ICAO") - if "/" in codes_text: - codes = codes_text.split("/") - - code1 = codes[0].strip() - code2 = codes[1].strip() - - iata = code1 if len(code1) == 3 else code2 - icao = code1 if len(code1) == 4 else code2 - - elif len(codes_text) == 3: - iata = codes_text - - elif len(codes_text) == 4: - icao = codes_text - - # Convert latitude and longitude to float - try: - lat_float = float(latitude) if latitude else 0.0 - lon_float = float(longitude) if longitude else 0.0 - - except ValueError: - lat_float = 0.0 - lon_float = 0.0 - - # Create Airport instance with basic_info format - airport_data = { - "name": name_part, - "icao": icao, - "iata": iata, - "lat": lat_float, - "lon": lon_float, - "alt": None, # Altitude not available in this format - "country": country_name - } - - airport = Airport(basic_info=airport_data) - airports.append(airport) - - return airports + with ThreadPoolExecutor() as executor: + results = executor.map(_fetch, countries) + return [airport for result in results for airport in result] def get_bookmarks(self) -> Dict: """ @@ -332,8 +163,7 @@ def get_bookmarks(self) -> Dict: if not self.is_logged_in(): raise LoginError("You must log in to your account.") - headers = Core.json_headers.copy() - headers["accesstoken"] = self.get_login_data()["accessToken"] + headers = {**Core.json_headers, "accesstoken": self.get_login_data()["accessToken"]} cookies = self.__login_data["cookies"] @@ -346,7 +176,7 @@ def get_bounds(self, zone: Dict[str, float]) -> str: :param zone: Dictionary containing the following keys: tl_y, tl_x, br_y, br_x """ - return "{},{},{},{}".format(zone["tl_y"], zone["br_y"], zone["tl_x"], zone["br_x"]) + return f"{zone['tl_y']},{zone['br_y']},{zone['tl_x']},{zone['br_x']}" def get_bounds_by_point(self, latitude: float, longitude: float, radius: float) -> str: """ @@ -411,13 +241,12 @@ def get_country_flag(self, country: str) -> Optional[Tuple[bytes, str]]: flag_url = Core.country_flag_url.format(country.lower().replace(" ", "-")) headers = Core.image_headers.copy() - if "origin" in headers: - headers.pop("origin") # Does not work for this request. + headers.pop("origin", None) # Does not work for this request. - response = APIRequest(flag_url, headers=headers, timeout=self.timeout) + response = APIRequest(flag_url, headers=headers, exclude_status_codes=[403, 404], timeout=self.timeout) status_code = response.get_status_code() - if not str(status_code).startswith("4"): + if not (400 <= status_code < 500): return response.get_content(), flag_url.split(".")[-1] def get_flight_details(self, flight: Flight) -> Dict[Any, Any]: @@ -454,28 +283,28 @@ def get_flights( # Insert the method parameters into the dictionary for the request. if airline: request_params["airline"] = airline - if bounds: request_params["bounds"] = bounds.replace(",", "%2C") + if bounds: request_params["bounds"] = bounds if registration: request_params["reg"] = registration if aircraft_type: request_params["type"] = aircraft_type # Get all flights from Data Live FlightRadar24. response = APIRequest(Core.real_time_flight_tracker_data_url, request_params, Core.json_headers, timeout=self.timeout) - response = response.get_content() + content = response.get_content() flights: List[Flight] = list() - for flight_id, flight_info in response.items(): + for flight_id, flight_info in content.items(): # Get flights only. if not flight_id[0].isnumeric(): continue - flight = Flight(flight_id, flight_info) - flights.append(flight) + flights.append(Flight(flight_id, flight_info)) - # Set flight details. - if details: - flight_details = self.get_flight_details(flight) + if details: + with ThreadPoolExecutor() as executor: + all_details = list(executor.map(self.get_flight_details, flights)) + for flight, flight_details in zip(flights, all_details): flight.set_flight_details(flight_details) return flights @@ -486,7 +315,7 @@ def get_flight_tracker_config(self) -> FlightTrackerConfig: """ return dataclasses.replace(self.__flight_tracker_config) - def get_history_data(self, flight: Flight, file_type: str, timestamp: int) -> Dict: + def get_history_data(self, flight: Flight, file_type: str, timestamp: int) -> str: """ Download historical data of a flight. @@ -502,14 +331,16 @@ def get_history_data(self, flight: Flight, file_type: str, timestamp: int) -> Di if file_type not in ["csv", "kml"]: raise ValueError(f"File type '{file_type}' is not supported. Only CSV and KML are supported.") + headers = {**Core.json_headers, "accesstoken": self.get_login_data()["accessToken"]} + response = APIRequest( Core.historical_data_url.format(flight.id, file_type, timestamp), - headers=Core.json_headers, cookies=self.__login_data["cookies"], + headers=headers, cookies=self.__login_data["cookies"], timeout=self.timeout ) content = response.get_content() - return str(content.decode("utf-8")) + return content.decode("utf-8") def get_login_data(self) -> Dict[Any, Any]: """ @@ -538,33 +369,24 @@ def get_zones(self) -> Dict[str, Dict]: """ Return all major zones on the globe. """ - # [Deprecated Code] - # response = APIRequest(Core.zones_data_url, headers=Core.json_headers, timeout=self.timeout) - # zones = response.get_content() - zones = Core.static_zones - - if "version" in zones: - zones.pop("version") - + zones = Core.static_zones.copy() + zones.pop("version", None) return zones def search(self, query: str, limit: int = 50) -> Dict: """ Return the search result. """ - response = APIRequest(Core.search_data_url.format(query, limit), headers=Core.json_headers, timeout=self.timeout) - results = response.get_content().get("results", []) - stats = response.get_content().get("stats", {}) + response = APIRequest(Core.search_data_url.format(quote(query), limit), headers=Core.json_headers, timeout=self.timeout) + content = response.get_content() + results = content.get("results", []) + stats = content.get("stats", {}) i = 0 - counted_total = 0 data = {} for name, count in stats.get("count", {}).items(): - data[name] = [] - while i < counted_total + count and i < len(results): - data[name].append(results[i]) - i += 1 - counted_total += count + data[name] = results[i:i + count] + i += count return data def is_logged_in(self) -> bool: @@ -591,9 +413,11 @@ def login(self, user: str, password: str) -> None: status_code = response.get_status_code() content = response.get_content() - if not str(status_code).startswith("2") or not content.get("success"): - if isinstance(content, dict): raise LoginError(content["message"]) - else: raise LoginError("Your email or password is incorrect") + if not (200 <= status_code < 300) or not content.get("success"): + if isinstance(content, dict): + raise LoginError(content["message"]) + else: + raise LoginError("Your email or password is incorrect") self.__login_data = { "userData": content["userData"], @@ -606,13 +430,14 @@ def logout(self) -> bool: Return a boolean indicating that it successfully logged out of the server. """ - if self.__login_data is None: return True + if self.__login_data is None: + return True cookies = self.__login_data["cookies"] self.__login_data = None - response = APIRequest(Core.user_login_url, headers=Core.json_headers, cookies=cookies, timeout=self.timeout) - return str(response.get_status_code()).startswith("2") + response = APIRequest(Core.user_logout_url, headers=Core.json_headers, cookies=cookies, timeout=self.timeout) + return 200 <= response.get_status_code() < 300 def set_flight_tracker_config( self, @@ -634,6 +459,6 @@ def set_flight_tracker_config( raise KeyError(f"Unknown option: '{key}'") if not value.isdecimal(): - raise TypeError(f"Value must be a decimal. Got '{key}'") + raise TypeError(f"Value must be a number. Got '{value}' for key '{key}'") setattr(self.__flight_tracker_config, key, value) diff --git a/python/FlightRadar24/core.py b/python/FlightRadar24/core.py index a38e58f..b2da153 100644 --- a/python/FlightRadar24/core.py +++ b/python/FlightRadar24/core.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- -from abc import ABC from enum import Enum from .zones import static_zones -class Core(ABC): +class Core: # Base URLs. api_flightradar_base_url = "https://api.flightradar24.com/common/v1" diff --git a/python/FlightRadar24/entities/airport.py b/python/FlightRadar24/entities/airport.py index ac6841d..6fe7464 100644 --- a/python/FlightRadar24/entities/airport.py +++ b/python/FlightRadar24/entities/airport.py @@ -8,7 +8,7 @@ class Airport(Entity): """ Airport representation. """ - def __init__(self, basic_info: Dict = dict(), info: Dict = dict()): + def __init__(self, basic_info: Optional[Dict] = None, info: Optional[Dict] = None): """ Constructor of the Airport class. @@ -18,8 +18,12 @@ def __init__(self, basic_info: Dict = dict(), info: Dict = dict()): :param basic_info: Basic information about the airport received from FlightRadar24 :param info: Dictionary with more information about the airport received from FlightRadar24 """ - if basic_info: self.__initialize_with_basic_info(basic_info) - if info: self.__initialize_with_info(info) + super().__init__(latitude=None, longitude=None) + + if basic_info is not None: + self.__initialize_with_basic_info(basic_info) + if info is not None: + self.__initialize_with_info(info) def __repr__(self) -> str: template = "<({}) {} - Altitude: {} - Latitude: {} - Longitude: {}>" @@ -36,10 +40,7 @@ def __initialize_with_basic_info(self, basic_info: Dict): """ Initialize instance with basic information about the airport. """ - super().__init__( - latitude=basic_info["lat"], - longitude=basic_info["lon"] - ) + self._set_position(basic_info["lat"], basic_info["lon"]) self.altitude = basic_info["alt"] self.name = basic_info["name"] @@ -52,10 +53,7 @@ def __initialize_with_info(self, info: Dict): """ Initialize instance with extra information about the airport. """ - super().__init__( - latitude=info["position"]["latitude"], - longitude=info["position"]["longitude"] - ) + self._set_position(info["position"]["latitude"], info["position"]["longitude"]) self.altitude = info["position"]["altitude"] self.name = info["name"] @@ -66,11 +64,11 @@ def __initialize_with_info(self, info: Dict): position = info["position"] self.country = position["country"]["name"] - self.country_code = self.__get_info(position.get("country", dict()).get("code")) - self.city = self.__get_info(position.get("region", dict())).get("city") + self.country_code = self.__get_info(position.get("country", {}).get("code")) + self.city = self.__get_info((position.get("region") or {}).get("city")) # Timezone information. - timezone = info.get("timezone", dict()) + timezone = info.get("timezone", {}) self.timezone_name = self.__get_info(timezone.get("name")) self.timezone_offset = self.__get_info(timezone.get("offset")) @@ -87,34 +85,34 @@ def set_airport_details(self, airport_details: Dict) -> None: Set airport details to the instance. Use FlightRadar24API.get_airport_details(...) method to get it. """ # Get airport data. - airport = self.__get_info(airport_details.get("airport"), dict()) - airport = self.__get_info(airport.get("pluginData"), dict()) + airport = self.__get_info(airport_details.get("airport"), {}) + airport = self.__get_info(airport.get("pluginData"), {}) # Get information about the airport. - details = self.__get_info(airport.get("details"), dict()) + details = self.__get_info(airport.get("details"), {}) # Get location information. - position = self.__get_info(details.get("position"), dict()) - code = self.__get_info(details.get("code"), dict()) - country = self.__get_info(position.get("country"), dict()) - region = self.__get_info(position.get("region"), dict()) + position = self.__get_info(details.get("position"), {}) + code = self.__get_info(details.get("code"), {}) + country = self.__get_info(position.get("country"), {}) + region = self.__get_info(position.get("region"), {}) # Get reviews of the airport. - flight_diary = self.__get_info(airport.get("flightdiary"), dict()) - ratings = self.__get_info(flight_diary.get("ratings"), dict()) + flight_diary = self.__get_info(airport.get("flightdiary"), {}) + ratings = self.__get_info(flight_diary.get("ratings"), {}) # Get schedule information. - schedule = self.__get_info(airport.get("schedule"), dict()) + schedule = self.__get_info(airport.get("schedule"), {}) # Get timezone information. - timezone = self.__get_info(details.get("timezone"), dict()) + timezone = self.__get_info(details.get("timezone"), {}) # Get aircraft count. - aircraft_count = self.__get_info(airport.get("aircraftCount"), dict()) - aircraft_on_ground = self.__get_info(aircraft_count.get("onGround"), dict()) + aircraft_count = self.__get_info(airport.get("aircraftCount"), {}) + aircraft_on_ground = self.__get_info(aircraft_count.get("onGround"), {}) # Get URLs for more information about the airport. - urls = self.__get_info(details.get("url"), dict()) + urls = self.__get_info(details.get("url"), {}) # Basic airport information. self.name = self.__get_info(details.get("name")) @@ -139,7 +137,8 @@ def set_airport_details(self, airport_details: Dict) -> None: if isinstance(self.timezone_offset, int): self.timezone_offset_hours = int(self.timezone_offset / 60 / 60) self.timezone_offset_hours = f"{self.timezone_offset_hours}:00" - else: self.timezone_offset_hours = self.__get_info(None) + else: + self.timezone_offset_hours = self.__get_info(None) # Airport reviews. self.reviews_url = flight_diary.get("url") @@ -156,18 +155,18 @@ def set_airport_details(self, airport_details: Dict) -> None: self.total_rating = self.__get_info(ratings.get("total")) # Weather information. - self.weather = self.__get_info(airport.get("weather"), dict()) + self.weather = self.__get_info(airport.get("weather"), {}) # Runway information. - self.runways = airport.get("runways", list()) + self.runways = airport.get("runways", []) # Aircraft count information. self.aircraft_on_ground = self.__get_info(aircraft_on_ground.get("total")) self.aircraft_visible_on_ground = self.__get_info(aircraft_on_ground.get("visible")) # Schedule information. - self.arrivals = self.__get_info(schedule.get("arrivals"), dict()) - self.departures = self.__get_info(schedule.get("departures"), dict()) + self.arrivals = self.__get_info(schedule.get("arrivals"), {}) + self.departures = self.__get_info(schedule.get("departures"), {}) # Link for the homepage and more information self.website = self.__get_info(urls.get("homepage")) @@ -175,4 +174,4 @@ def set_airport_details(self, airport_details: Dict) -> None: # Other information. self.visible = self.__get_info(details.get("visible")) - self.images = self.__get_info(details.get("airportImages"), dict()) + self.images = self.__get_info(details.get("airportImages"), {}) diff --git a/python/FlightRadar24/entities/entity.py b/python/FlightRadar24/entities/entity.py index e7fa606..2287820 100644 --- a/python/FlightRadar24/entities/entity.py +++ b/python/FlightRadar24/entities/entity.py @@ -2,6 +2,7 @@ from abc import ABC from math import acos, cos, radians, sin +from typing import Optional class Entity(ABC): @@ -11,10 +12,13 @@ class Entity(ABC): _default_text = "N/A" - def __init__(self, latitude: float, longitude: float): + def __init__(self, latitude: Optional[float], longitude: Optional[float]): """ Constructor of the Entity class. """ + self._set_position(latitude, longitude) + + def _set_position(self, latitude: Optional[float], longitude: Optional[float]) -> None: self.latitude = latitude self.longitude = longitude @@ -22,10 +26,11 @@ def get_distance_from(self, entity: "Entity") -> float: """ Return the distance from another entity (in kilometers). """ - lat1, lon1 = self.latitude, self.longitude - lat2, lon2 = entity.latitude, entity.longitude + if (self.latitude is None or self.longitude is None + or entity.latitude is None or entity.longitude is None): + raise ValueError("Cannot calculate distance: one or both entities have no position.") - lat1, lon1 = radians(lat1), radians(lon1) - lat2, lon2 = radians(lat2), radians(lon2) + lat1, lon1 = radians(self.latitude), radians(self.longitude) + lat2, lon2 = radians(entity.latitude), radians(entity.longitude) return acos(sin(lat1) * sin(lat2) + cos(lat1) * cos(lat2) * cos(lon2 - lon1)) * 6371 diff --git a/python/FlightRadar24/entities/flight.py b/python/FlightRadar24/entities/flight.py index af462ee..5cc93ad 100644 --- a/python/FlightRadar24/entities/flight.py +++ b/python/FlightRadar24/entities/flight.py @@ -1,9 +1,33 @@ # -*- coding: utf-8 -*- +from enum import IntEnum from typing import Any, Dict, List, Optional + from .entity import Entity +class _Field(IntEnum): + ICAO24BIT = 0 + LATITUDE = 1 + LONGITUDE = 2 + HEADING = 3 + ALTITUDE = 4 + GROUND_SPEED = 5 + SQUAWK = 6 + # index 7: unused + AIRCRAFT_CODE = 8 + REGISTRATION = 9 + TIME = 10 + ORIGIN_IATA = 11 + DESTINATION_IATA = 12 + FLIGHT_NUMBER = 13 + ON_GROUND = 14 + VERTICAL_SPEED = 15 + CALLSIGN = 16 + # index 17: unused + AIRLINE_ICAO = 18 + + class Flight(Entity): """ Flight representation. @@ -16,27 +40,27 @@ def __init__(self, flight_id: str, info: List[Any]): :param info: Dictionary with received data from FlightRadar24 """ super().__init__( - latitude=self.__get_info(info[1]), - longitude=self.__get_info(info[2]) + latitude=self.__get_info(info[_Field.LATITUDE]), + longitude=self.__get_info(info[_Field.LONGITUDE]), ) self.id = flight_id - self.icao_24bit = self.__get_info(info[0]) - self.heading = self.__get_info(info[3]) - self.altitude = self.__get_info(info[4]) - self.ground_speed = self.__get_info(info[5]) - self.squawk = self.__get_info(info[6]) - self.aircraft_code = self.__get_info(info[8]) - self.registration = self.__get_info(info[9]) - self.time = self.__get_info(info[10]) - self.origin_airport_iata = self.__get_info(info[11]) - self.destination_airport_iata = self.__get_info(info[12]) - self.number = self.__get_info(info[13]) - self.airline_iata = self.__get_info(info[13][:2]) - self.on_ground = self.__get_info(info[14]) - self.vertical_speed = self.__get_info(info[15]) - self.callsign = self.__get_info(info[16]) - self.airline_icao = self.__get_info(info[18]) + self.icao_24bit = self.__get_info(info[_Field.ICAO24BIT]) + self.heading = self.__get_info(info[_Field.HEADING]) + self.altitude = self.__get_info(info[_Field.ALTITUDE]) + self.ground_speed = self.__get_info(info[_Field.GROUND_SPEED]) + self.squawk = self.__get_info(info[_Field.SQUAWK]) + self.aircraft_code = self.__get_info(info[_Field.AIRCRAFT_CODE]) + self.registration = self.__get_info(info[_Field.REGISTRATION]) + self.time = self.__get_info(info[_Field.TIME]) + self.origin_airport_iata = self.__get_info(info[_Field.ORIGIN_IATA]) + self.destination_airport_iata = self.__get_info(info[_Field.DESTINATION_IATA]) + self.number = self.__get_info(info[_Field.FLIGHT_NUMBER]) + self.airline_iata = self.__get_info(info[_Field.FLIGHT_NUMBER][:2] if info[_Field.FLIGHT_NUMBER] else None) + self.on_ground = self.__get_info(info[_Field.ON_GROUND]) + self.vertical_speed = self.__get_info(info[_Field.VERTICAL_SPEED]) + self.callsign = self.__get_info(info[_Field.CALLSIGN]) + self.airline_icao = self.__get_info(info[_Field.AIRLINE_ICAO]) def __repr__(self) -> str: return self.__str__() @@ -68,10 +92,12 @@ def check_info(self, **info: Any) -> bool: # Check if the value is greater than or less than the attribute value. if prefix and key in self.__dict__: - if comparison_functions[prefix](value, self.__dict__[key]) != value: return False + if comparison_functions[prefix](value, self.__dict__[key]) != value: + return False # Check if the value is equal. - elif key in self.__dict__ and value != self.__dict__[key]: return False + elif key in self.__dict__ and value != self.__dict__[key]: + return False return True @@ -79,73 +105,73 @@ def get_altitude(self) -> str: """ Return the formatted altitude, with the unit of measure. """ - return "{} ft".format(self.altitude) + return f"{self.altitude} ft" def get_flight_level(self) -> str: """ Return the formatted flight level, with the unit of measure. """ - return str(self.altitude)[:3] + " FL" if self.altitude >= 10000 else self.get_altitude() + return f"{str(self.altitude)[:3]} FL" if self.altitude >= 10000 else self.get_altitude() def get_ground_speed(self) -> str: """ Return the formatted ground speed, with the unit of measure. """ - return "{} kt".format(self.ground_speed) + ("s" if self.ground_speed > 1 else "") + return f"{self.ground_speed} kt{'s' if self.ground_speed > 1 else ''}" def get_heading(self) -> str: """ Return the formatted heading, with the unit of measure. """ - return str(self.heading) + "°" + return f"{self.heading}°" def get_vertical_speed(self) -> str: """ Return the formatted vertical speed, with the unit of measure. """ - return "{} fpm".format(self.vertical_speed) + return f"{self.vertical_speed} fpm" def set_flight_details(self, flight_details: Dict) -> None: """ Set flight details to the instance. Use FlightRadar24API.get_flight_details(...) method to get it. """ # Get aircraft data. - aircraft = self.__get_info(flight_details.get("aircraft"), dict()) + aircraft = self.__get_info(flight_details.get("aircraft"), {}) # Get airline data. - airline = self.__get_info(flight_details.get("airline"), dict()) + airline = self.__get_info(flight_details.get("airline"), {}) # Get airport data. - airport = self.__get_info(flight_details.get("airport"), dict()) + airport = self.__get_info(flight_details.get("airport"), {}) # Get destination data. - dest_airport = self.__get_info(airport.get("destination"), dict()) - dest_airport_code = self.__get_info(dest_airport.get("code"), dict()) - dest_airport_info = self.__get_info(dest_airport.get("info"), dict()) - dest_airport_position = self.__get_info(dest_airport.get("position"), dict()) - dest_airport_country = self.__get_info(dest_airport_position.get("country"), dict()) - dest_airport_timezone = self.__get_info(dest_airport.get("timezone"), dict()) + dest_airport = self.__get_info(airport.get("destination"), {}) + dest_airport_code = self.__get_info(dest_airport.get("code"), {}) + dest_airport_info = self.__get_info(dest_airport.get("info"), {}) + dest_airport_position = self.__get_info(dest_airport.get("position"), {}) + dest_airport_country = self.__get_info(dest_airport_position.get("country"), {}) + dest_airport_timezone = self.__get_info(dest_airport.get("timezone"), {}) # Get origin data. - orig_airport = self.__get_info(airport.get("origin"), dict()) - orig_airport_code = self.__get_info(orig_airport.get("code"), dict()) - orig_airport_info = self.__get_info(orig_airport.get("info"), dict()) - orig_airport_position = self.__get_info(orig_airport.get("position"), dict()) - orig_airport_country = self.__get_info(orig_airport_position.get("country"), dict()) - orig_airport_timezone = self.__get_info(orig_airport.get("timezone"), dict()) + orig_airport = self.__get_info(airport.get("origin"), {}) + orig_airport_code = self.__get_info(orig_airport.get("code"), {}) + orig_airport_info = self.__get_info(orig_airport.get("info"), {}) + orig_airport_position = self.__get_info(orig_airport.get("position"), {}) + orig_airport_country = self.__get_info(orig_airport_position.get("country"), {}) + orig_airport_timezone = self.__get_info(orig_airport.get("timezone"), {}) # Get flight history. - history = self.__get_info(flight_details.get("flightHistory"), dict()) + history = self.__get_info(flight_details.get("flightHistory"), {}) # Get flight status. - status = self.__get_info(flight_details.get("status"), dict()) + status = self.__get_info(flight_details.get("status"), {}) # Aircraft information. self.aircraft_age = self.__get_info(aircraft.get("age")) self.aircraft_country_id = self.__get_info(aircraft.get("countryId")) - self.aircraft_history = history.get("aircraft", list()) - self.aircraft_images = aircraft.get("images", list()) - self.aircraft_model = self.__get_info(self.__get_info(aircraft.get("model"), dict()).get("text")) + self.aircraft_history = history.get("aircraft", []) + self.aircraft_images = aircraft.get("images", []) + self.aircraft_model = self.__get_info(self.__get_info(aircraft.get("model"), {}).get("text")) # Airline information. self.airline_name = self.__get_info(airline.get("name")) @@ -202,7 +228,7 @@ def set_flight_details(self, flight_details: Dict) -> None: self.status_text = self.__get_info(status.get("text")) # Time details. - self.time_details = self.__get_info(flight_details.get("time"), dict()) + self.time_details = self.__get_info(flight_details.get("time"), {}) # Flight trail. - self.trail = flight_details.get("trail", list()) + self.trail = flight_details.get("trail", []) diff --git a/python/FlightRadar24/errors.py b/python/FlightRadar24/errors.py index ead853c..1dd414d 100644 --- a/python/FlightRadar24/errors.py +++ b/python/FlightRadar24/errors.py @@ -1,17 +1,20 @@ # -*- coding: utf-8 -*- -class AirportNotFoundError(Exception): + +class FlightRadarError(Exception): + """Base class for all FlightRadar24 exceptions.""" pass -class CloudflareError(Exception): - def __init__(self, message, response): - self.message = message - self.response = response +class AirportNotFoundError(FlightRadarError): + pass - def __str__(self): - return self.message + +class CloudflareError(FlightRadarError): + def __init__(self, message: str, response): + super().__init__(message) + self.response = response -class LoginError(Exception): +class LoginError(FlightRadarError): pass diff --git a/python/FlightRadar24/flight_tracker_config.py b/python/FlightRadar24/flight_tracker_config.py new file mode 100644 index 0000000..d9f6bc0 --- /dev/null +++ b/python/FlightRadar24/flight_tracker_config.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- + +import dataclasses + + +@dataclasses.dataclass +class FlightTrackerConfig: + """ + Data class with settings of the Real Time Flight Tracker. + """ + faa: str = "1" + satellite: str = "1" + mlat: str = "1" + flarm: str = "1" + adsb: str = "1" + gnd: str = "1" + air: str = "1" + vehicles: str = "1" + estimated: str = "1" + maxage: str = "14400" + gliders: str = "1" + stats: str = "1" + limit: str = "5000" diff --git a/python/FlightRadar24/parsers.py b/python/FlightRadar24/parsers.py new file mode 100644 index 0000000..779d796 --- /dev/null +++ b/python/FlightRadar24/parsers.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- + +from typing import Dict, List + +from bs4 import BeautifulSoup + +from .entities.airport import Airport + + +def parse_airlines_html(html: bytes) -> List[Dict]: + """ + Parse the airlines listing HTML page into a list of airline dicts. + """ + soup = BeautifulSoup(html, "html.parser") + tbody = soup.find("tbody") + + if not tbody: + return [] + + airlines = [] + + for tr in tbody.find_all("tr"): + td_notranslate = tr.find("td", class_="notranslate") + + if not td_notranslate: + continue + + a_element = td_notranslate.find("a", href=lambda href: href and href.startswith("/data/airlines")) + + if not a_element: + continue + + airline_name = a_element.get_text(strip=True) + + if len(airline_name) < 2: + continue + + td_elements = tr.find_all("td") + iata = None + icao = None + + if len(td_elements) >= 4: + codes_text = td_elements[3].get_text(strip=True) + + if " / " in codes_text: + parts = codes_text.split(" / ") + if len(parts) == 2: + iata = parts[0].strip() + icao = parts[1].strip() + elif len(codes_text) == 2: + iata = codes_text + elif len(codes_text) == 3: + icao = codes_text + + n_aircrafts = None + + if len(td_elements) >= 5: + aircrafts_text = td_elements[4].get_text(strip=True) + if aircrafts_text: + n_aircrafts = int(aircrafts_text.split(" ", maxsplit=1)[0].strip()) + + airlines.append({"Name": airline_name, "ICAO": icao, "IATA": iata, "n_aircrafts": n_aircrafts}) + + return airlines + + +def parse_airports_html(html: bytes, country_href: str) -> List[Airport]: + """ + Parse the airports listing HTML page for a country into a list of Airport instances. + """ + soup = BeautifulSoup(html, "html.parser") + tbody = soup.find("tbody") + + if not tbody: + 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 + + a_element = a_elements[0] + + icao = "" + iata = a_element.get("data-iata", "").strip() + latitude = a_element.get("data-lat", "").strip() + longitude = a_element.get("data-lon", "").strip() + name_part = a_element.get_text(strip=True) + + small_element = a_element.find("small") + + 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 + + try: + lat_float = float(latitude) if latitude else 0.0 + lon_float = float(longitude) if longitude else 0.0 + except ValueError: + lat_float, lon_float = 0.0, 0.0 + + airports.append(Airport(basic_info={ + "name": name_part, + "icao": icao, + "iata": iata, + "lat": lat_float, + "lon": lon_float, + "alt": None, + "country": country_name, + })) + + return airports diff --git a/python/FlightRadar24/py.typed b/python/FlightRadar24/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/python/FlightRadar24/request.py b/python/FlightRadar24/request.py index 7416165..cf20e8f 100644 --- a/python/FlightRadar24/request.py +++ b/python/FlightRadar24/request.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- +import gzip +import json from typing import Any, Dict, List, Optional, Union +from urllib.parse import urlencode import brotli -import json -import gzip - from curl_cffi import requests from .errors import CloudflareError @@ -13,7 +13,7 @@ _IMPERSONATE = "chrome136" -class APIRequest(object): +class APIRequest: """ Class to make requests to the FlightRadar24. """ @@ -31,7 +31,7 @@ def __init__( timeout: int = 30, data: Optional[Dict] = None, cookies: Optional[Dict] = None, - exclude_status_codes: List[int] = list() + exclude_status_codes: Optional[List[int]] = None ): """ Constructor of the APIRequest class. @@ -45,17 +45,9 @@ def __init__( """ self.url = url - self.request_params = { - "params": params, - "headers": headers, - "timeout": timeout, - "data": data, - "cookies": cookies - } - request_method = requests.get if data is None else requests.post - if params: url += "?" + "&".join(["{}={}".format(k, v) for k, v in params.items()]) + if params: url += "?" + urlencode(params) self.__response = request_method( url, headers=headers, cookies=cookies, data=data, timeout=timeout, impersonate=_IMPERSONATE @@ -67,7 +59,7 @@ def __init__( response=self.__response ) - if self.get_status_code() not in exclude_status_codes: + if self.get_status_code() not in (exclude_status_codes or []): self.__response.raise_for_status() def get_content(self) -> Union[Dict, bytes]: @@ -77,11 +69,15 @@ def get_content(self) -> Union[Dict, bytes]: content = self.__response.content content_encoding = self.__response.headers.get("Content-Encoding", "") - content_type = self.__response.headers["Content-Type"] - - # Try to decode the content. - try: content = self.__content_encodings[content_encoding](content) - except Exception: pass + content_type = self.__response.headers.get("Content-Type", "") + + # Decompress the content if a known encoding was used; fall back to raw bytes otherwise. + # curl_cffi may already decompress content automatically — ignore decompression failures. + decode = self.__content_encodings.get(content_encoding, self.__content_encodings[""]) + try: + content = decode(content) + except Exception: + pass # Return a dictionary if the content type is JSON. if "application/json" in content_type: diff --git a/python/Makefile b/python/Makefile index 9ddfda3..f92854f 100644 --- a/python/Makefile +++ b/python/Makefile @@ -66,15 +66,15 @@ venv-activate: .PHONY: install-deps install-deps: @echo "$(GREEN)Installing dependencies...$(NC)" - $(PIP) install -r requirements.txt + $(PIP) install -e . @echo "$(GREEN)Dependencies installed successfully!$(NC)" # Install development dependencies .PHONY: install-dev install-dev: @echo "$(GREEN)Installing development dependencies...$(NC)" - $(PIP) install -r requirements.txt - $(PIP) install pytest pytest-cov flake8 black mypy twine build hatch + $(PIP) install -e ".[tests]" + $(PIP) install pytest-cov flake8 black mypy twine build hatch @echo "$(GREEN)Development dependencies installed successfully!$(NC)" # Install package in development mode @@ -204,7 +204,7 @@ check-deps: .PHONY: update-deps update-deps: @echo "$(GREEN)Updating dependencies...$(NC)" - $(PIP) install --upgrade -r requirements.txt + $(PIP) install --upgrade -e ".[tests]" @echo "$(GREEN)Dependencies updated!$(NC)" # Security audit diff --git a/python/pyproject.toml b/python/pyproject.toml index f2a2834..2a01863 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -15,13 +15,16 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12" ] -exclude = ["tests", ".flake8"] requires-python = ">=3.10" dependencies = [ "Brotli", - "requests", + "beautifulsoup4", + "curl_cffi", ] +[tool.hatch.build] +exclude = ["tests", ".flake8"] + [tool.hatch.build.targets.wheel] packages = ["FlightRadar24"] @@ -39,6 +42,18 @@ tests = [ [tool.hatch.version] path = "FlightRadar24/__init__.py" +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short --strict-markers --disable-warnings --color=yes" +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "unit: marks tests as unit tests", + "integration: marks tests as integration tests", +] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/python/pytest.ini b/python/pytest.ini deleted file mode 100644 index cd73c3f..0000000 --- a/python/pytest.ini +++ /dev/null @@ -1,15 +0,0 @@ -[tool:pytest] -testpaths = tests -python_files = test_*.py -python_classes = Test* -python_functions = test_* -addopts = - -v - --tb=short - --strict-markers - --disable-warnings - --color=yes -markers = - slow: marks tests as slow (deselect with '-m "not slow"') - unit: marks tests as unit tests - integration: marks tests as integration tests diff --git a/python/requirements.txt b/python/requirements.txt deleted file mode 100644 index 22a7242..0000000 --- a/python/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -Brotli -pytest -curl_cffi -beautifulsoup4 diff --git a/python/tests/test_api.py b/python/tests/test_api.py index 46af758..3bcbe67 100644 --- a/python/tests/test_api.py +++ b/python/tests/test_api.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- -from package import CloudflareError, Countries, FlightRadar24API, version +import pytest + +from FlightRadar24 import Flight +from FlightRadar24.errors import CloudflareError +from package import Countries, FlightRadar24API, version from util import repeat_test print("Testing FlightRadarAPI version %s." % version) @@ -19,14 +23,8 @@ def test_get_airlines(expect=100, airlines=["LAN", "GLO", "DAL", "AZU", "UAE"]): results = fr_api.get_airlines() assert len(results) >= expect - found = [] - - for airline in results: - if airline["ICAO"] in airlines and airline not in found: - found.append(airline) - - assert len(found) == len(airlines) - + found_icaos = {airline["ICAO"] for airline in results if airline["ICAO"] in airlines} + assert len(found_icaos) == len(airlines) @repeat_test(**repeat_test_config) @@ -41,7 +39,7 @@ def test_get_airport_details(airports=["ATL", "LAX", "DXB", "DFW"]): for airport in airports: details = fr_api.get_airport_details(airport, flight_limit=1) - assert all([key in details for key in data]) and details["airport"]["pluginData"]["details"] + assert all(key in details for key in data) and details["airport"]["pluginData"]["details"] @repeat_test(**repeat_test_config) @@ -57,7 +55,7 @@ def test_get_zones(expect=5): assert len(results) >= expect for zone, data in results.items(): - assert all([key in data for key in ["tl_y", "tl_x", "br_y", "br_x"]]) + assert all(key in data for key in ["tl_y", "tl_x", "br_y", "br_x"]) @repeat_test(**repeat_test_config) @@ -71,13 +69,9 @@ def test_get_flight_details(): data = ["airport", "airline", "aircraft", "time", "status", "trail"] flights = fr_api.get_flights() - middle = len(flights) // 2 - - flights = flights[middle - 2: middle + 2] - - for flight in flights: - details = fr_api.get_flight_details(flight) - assert all([key in details for key in data]) and details["aircraft"] + flight = flights[len(flights) // 2] + details = fr_api.get_flight_details(flight) + assert all(key in details for key in data) and details["aircraft"] @repeat_test(**repeat_test_config) @@ -90,7 +84,7 @@ def test_get_flights_by_airline(airlines=["SWA", "GLO", "AZU", "UAL", "THY"], ex for flight in flights: assert flight.airline_icao == airline - if len(flights) > 0: count += 1 + if flights: count += 1 assert count >= expect @@ -99,8 +93,8 @@ def test_get_flights_by_airline(airlines=["SWA", "GLO", "AZU", "UAL", "THY"], ex def test_get_flights_by_bounds(target_zones=["northamerica", "southamerica"], expect=30): zones = fr_api.get_zones() - for zone in target_zones: - zone = zones[zone] + for zone_name in target_zones: + zone = zones[zone_name] bounds = fr_api.get_bounds(zone) flights = fr_api.get_flights(bounds=bounds) @@ -136,8 +130,88 @@ def test_get_country_flag(countries=["United States", "Brazil", "Egypt", "Japan" assert found >= expected +@repeat_test(**repeat_test_config) +def test_get_most_tracked(): + result = fr_api.get_most_tracked() + assert isinstance(result, dict) + + +@repeat_test(**repeat_test_config) +def test_get_airport_disruptions(): + result = fr_api.get_airport_disruptions() + assert isinstance(result, dict) + + +@repeat_test(**repeat_test_config) +def test_get_volcanic_eruptions(): + result = fr_api.get_volcanic_eruptions() + assert isinstance(result, dict) + + +@repeat_test(**repeat_test_config) +def test_search(): + result = fr_api.search("Guarulhos") + assert isinstance(result, dict) + for value in result.values(): + assert isinstance(value, list) + + +def test_get_bounds(): + zone = {"tl_y": 75.78, "br_y": -75.78, "tl_x": -427.56, "br_x": 427.56} + assert fr_api.get_bounds(zone) == "75.78,-75.78,-427.56,427.56" + + def test_get_bounds_by_point(): expected = "52.58594974202871,52.54997688140807,13.253064418048115,13.3122478541492" actual = fr_api.get_bounds_by_point(52.567967, 13.282644, 2000) - assert actual == expected + + +def test_get_airport_invalid_code(): + with pytest.raises(ValueError): + fr_api.get_airport("X") + + +def test_set_flight_tracker_config_invalid_key(): + with pytest.raises(KeyError): + fr_api.set_flight_tracker_config(unknown_key="1") + + +def test_set_flight_tracker_config_invalid_value(): + with pytest.raises(TypeError): + fr_api.set_flight_tracker_config(limit="not_a_number") + + +_check_info_flight = Flight("123456789", [ + "ABC123", -23.5, -46.6, 180, 35000, 450, + "1234", None, "B738", "PR-ABC", 1620000000, + "GRU", "GIG", "G31234", 0, 0, "GLO1234", None, "GLO", +]) + + +def test_check_info_exact_match(): + assert _check_info_flight.check_info(altitude=35000) + + +def test_check_info_range_within_bounds(): + assert _check_info_flight.check_info(min_altitude=30000, max_altitude=40000) + + +def test_check_info_exact_mismatch(): + assert not _check_info_flight.check_info(altitude=40000) + + +def test_check_info_max_exceeded(): + assert not _check_info_flight.check_info(max_altitude=30000) + + +def test_check_info_string_match(): + assert _check_info_flight.check_info(airline_icao="GLO") + + +def test_check_info_string_mismatch(): + assert not _check_info_flight.check_info(airline_icao="TAM") + + +def test_check_info_combined(): + assert _check_info_flight.check_info(min_altitude=30000, max_altitude=40000, airline_icao="GLO") diff --git a/python/tests/test_snapshots.py b/python/tests/test_snapshots.py new file mode 100644 index 0000000..9daea94 --- /dev/null +++ b/python/tests/test_snapshots.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- + +from FlightRadar24 import Airport, Flight +from FlightRadar24.errors import CloudflareError +from package import Countries, FlightRadar24API +from util import repeat_test + +repeat_test_config = { + "attempts": 2, + "after": 5, + "errors": [CloudflareError], +} + +fr_api = FlightRadar24API() +_flights = None + + +def _get_flights(): + global _flights + if _flights is None: + _flights = fr_api.get_flights() + assert len(_flights) > 0, "getFlights() returned no flights — subsequent tests will be meaningless" + return _flights + + +# --- assertShape utility --- +# +# Shape leaf values: +# None → key/attr must exist; value may be anything including None +# type → isinstance check (e.g. str, int, float) +# list → must be a list; [shape] also checks first element +# dict → recurse into nested keys/attributes + +_MISSING = object() + + +def _get(obj, key): + if isinstance(obj, dict): + return obj.get(key, _MISSING) + return getattr(obj, key, _MISSING) + + +def assert_shape(actual, shape, path="root"): + if shape is None: + assert actual is not _MISSING, f"key missing at '{path}'" + return + if isinstance(shape, type): + assert isinstance(actual, shape), ( + f"type mismatch at '{path}': expected {shape.__name__}, got {type(actual).__name__}" + ) + return + if isinstance(shape, list): + assert isinstance(actual, list), f"'{path}' must be a list" + if shape and actual: + assert_shape(actual[0], shape[0], f"{path}[0]") + return + assert actual is not None, f"'{path}' must be a non-null object" + for key, sub_shape in shape.items(): + value = _get(actual, key) + assert value is not _MISSING, f"missing key '{key}' at '{path}'" + assert_shape(value, sub_shape, f"{path}.{key}") + + +# --- Shape descriptors --- + +FLIGHT_SHAPE = { + "id": str, + "icao_24bit": None, + "latitude": None, + "longitude": None, + "heading": None, + "altitude": None, + "ground_speed": None, + "squawk": None, + "aircraft_code": None, + "registration": None, + "time": None, + "origin_airport_iata": None, + "destination_airport_iata": None, + "number": None, + "airline_iata": None, + "on_ground": None, + "vertical_speed": None, + "callsign": None, + "airline_icao": None, +} + +FLIGHT_DETAILS_SHAPE = { + "aircraft": None, + "airline": None, + "airport": { + "destination": None, + "origin": None, + }, + "status": { + "icon": None, + "text": None, + }, + "time": None, + "trail": list, +} + +AIRPORT_SHAPE = { + "name": None, + "icao": None, + "iata": None, + "country": None, + "latitude": None, + "longitude": None, + "altitude": None, +} + +AIRPORT_DETAILS_SHAPE = { + "airport": { + "pluginData": { + "details": { + "code": { + "iata": None, + "icao": None, + }, + "name": None, + "position": { + "country": {"name": None}, + "latitude": None, + "longitude": None, + }, + "timezone": { + "name": None, + "offset": None, + }, + }, + }, + }, + "airlines": None, + "aircraftImages": None, +} + +AIRLINE_SHAPE = { + "Name": None, + "ICAO": None, + "IATA": None, + "n_aircrafts": None, +} + +ZONE_SHAPE = { + "tl_y": None, + "tl_x": None, + "br_y": None, + "br_x": None, +} + + +# --- Tests --- + +@repeat_test(**repeat_test_config) +def test_get_flights_shape(): + flights = _get_flights() + assert_shape(flights[0], FLIGHT_SHAPE) + + +@repeat_test(**repeat_test_config) +def test_get_flight_details_shape(): + flights = _get_flights() + details = fr_api.get_flight_details(flights[len(flights) // 2]) + assert_shape(details, FLIGHT_DETAILS_SHAPE) + + +@repeat_test(**repeat_test_config) +def test_get_airport_shape(): + assert_shape(fr_api.get_airport("ATL"), AIRPORT_SHAPE) + + +@repeat_test(**repeat_test_config) +def test_get_airport_details_shape(): + assert_shape(fr_api.get_airport_details("ATL", flight_limit=1), AIRPORT_DETAILS_SHAPE) + + +@repeat_test(**repeat_test_config) +def test_get_airlines_shape(): + airlines = fr_api.get_airlines() + assert len(airlines) > 0 + assert_shape(airlines[0], AIRLINE_SHAPE) + + +@repeat_test(**repeat_test_config) +def test_get_airports_shape(): + airports = fr_api.get_airports([Countries.BRAZIL]) + assert len(airports) > 0 + assert_shape(airports[0], AIRPORT_SHAPE) + + +def test_get_zones_shape(): + zones = fr_api.get_zones() + assert len(zones) > 0 + for zone in zones.values(): + assert_shape(zone, ZONE_SHAPE) + + +@repeat_test(**repeat_test_config) +def test_get_airline_logo_shape(): + result = fr_api.get_airline_logo("G3", "GLO") + if result is not None: + assert isinstance(result, tuple) and len(result) == 2 + assert isinstance(result[0], bytes) + assert isinstance(result[1], str) and len(result[1]) > 0 + + +@repeat_test(**repeat_test_config) +def test_get_country_flag_shape(): + result = fr_api.get_country_flag("Brazil") + if result is not None: + assert isinstance(result, tuple) and len(result) == 2 + assert isinstance(result[0], bytes) + assert isinstance(result[1], str) and len(result[1]) > 0 diff --git a/python/tests/util.py b/python/tests/util.py index f4bdc2e..21cab45 100644 --- a/python/tests/util.py +++ b/python/tests/util.py @@ -22,8 +22,6 @@ def repeat_test(attempts: int, after: int, errors: Optional[List[Exception]] = N """ def _repeat_test(test_function: Callable) -> Callable: def wrapper(*args, **kwargs): - nonlocal attempts, errors - error_list: List[Exception] = list() for attempt in range(attempts): @@ -31,11 +29,11 @@ def wrapper(*args, **kwargs): return test_function(*args, **kwargs) except Exception as error: - if errors is not None and error not in errors: raise error + if errors is not None and not isinstance(error, tuple(errors)): raise error if after is not None: time.sleep(after) error_list.append(error) - raise raise_multiple(error_list) + raise_multiple(error_list) return wrapper return _repeat_test From b0f68e4837856bc3709af7c20db385517d2ec83d Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 11 May 2026 04:05:18 -0300 Subject: [PATCH 2/8] fix(ci): replace requirements.txt with pyproject.toml extras install --- .github/workflows/python-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 636ee0b..0d06fc2 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -25,7 +25,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r python/requirements.txt + pip install -e "python/[tests]" - name: Test with pytest run: | cd python From 9cea262ae84b30a9f71773b40150a390e0500853 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 11 May 2026 12:00:09 -0300 Subject: [PATCH 3/8] refactor: harden concurrency, fix bugs and align Python/Node.js interfaces Python: - cap ThreadPoolExecutor in get_airports() with max_workers - switch get_flights() details to submit+as_completed to reduce peak memory - rename exclude_status_codes -> allowed_error_codes; make APIRequest params keyword-only - fix get_flights() filter params to use `is not None` instead of truthiness - add explicit `return None` in get_airline_logo() and get_country_flag() - fix unused loop variable `attempt` -> `_` in util.py - align repeat_test() annotation to Sequence[Type[BaseException]] with validation - remove unused Airport/Flight imports from test_snapshots.py - fix CI: pip install -e "./python[tests]" (was python/[tests]) Node.js: - add mapConcurrent() to cap concurrent getFlights(details) and getAirports() requests - add timeout and maxWorkers options to FlightRadar24API constructor - propagate this.timeout to all request() calls - fix typeof null === "object" bug in getAirportDetails pluginData/runways check - add category comments to setFlightDetails() matching Python - add JSDoc to mapConcurrent and worker - update index.d.ts constructor signature; add types field to package.json - add tsd and index.test-d.ts for type definition testing - add test-types target to Makefile; wire into build, all and ci --- .github/pull_request_template.md | 2 +- .github/workflows/pr-closed.yml | 2 +- .github/workflows/python-package.yml | 2 +- nodejs/FlightRadar24/api.js | 71 +- nodejs/FlightRadar24/entities/flight.js | 26 + nodejs/FlightRadar24/index.d.ts | 4 +- nodejs/Makefile | 13 +- nodejs/package-lock.json | 1765 ++++++++++++++++++++++- nodejs/package.json | 8 +- nodejs/tests/index.test-d.ts | 69 + nodejs/tests/testApi.js | 6 +- python/.tool-versions | 1 + python/FlightRadar24/api.py | 43 +- python/FlightRadar24/request.py | 7 +- python/tests/test_snapshots.py | 1 - python/tests/util.py | 13 +- 16 files changed, 1954 insertions(+), 79 deletions(-) create mode 100644 nodejs/tests/index.test-d.ts create mode 100644 python/.tool-versions diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 24896ca..090686c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,7 +8,7 @@ problem the pull request solves or what new features it implements. **Checklist (complete all items)**: - [ ] Added tests as necessary. -- [ ] There is no break change for existing features. +- [ ] There is no breaking change for existing features. **References:** diff --git a/.github/workflows/pr-closed.yml b/.github/workflows/pr-closed.yml index 8d04cb5..cd8e6bf 100644 --- a/.github/workflows/pr-closed.yml +++ b/.github/workflows/pr-closed.yml @@ -13,4 +13,4 @@ jobs: steps: # Checkout - name: Checkout - uses: actions/checkout@v2 \ No newline at end of file + uses: actions/checkout@v4 \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 0d06fc2..b7a825b 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -25,7 +25,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e "python/[tests]" + pip install -e "./python[tests]" - name: Test with pytest run: | cd python diff --git a/nodejs/FlightRadar24/api.js b/nodejs/FlightRadar24/api.js index 3999da6..4aa3720 100644 --- a/nodejs/FlightRadar24/api.js +++ b/nodejs/FlightRadar24/api.js @@ -8,16 +8,41 @@ const {isNumeric, radians, rad2deg} = require("./util"); const {parseAirlinesHtml, parseAirportsHtml} = require("./parsers"); +/** + * Run fn over every item with at most concurrency tasks in flight at once. + * + * @param {Array} items + * @param {number} concurrency + * @param {Function} fn + * @return {Promise} + */ +async function mapConcurrent(items, concurrency, fn) { + let i = 0; + /** @return {Promise} */ + async function worker() { + while (i < items.length) { + await fn(items[i++]); + } + } + await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, worker)); +} + /** * Main class of the FlightRadarAPI */ class FlightRadar24API { /** * Constructor of FlightRadar24API class + * + * @param {object} [options={}] + * @param {number} [options.timeout=10000] - Request timeout in milliseconds + * @param {number} [options.maxWorkers=8] - Maximum concurrent requests when fetching flight details */ - constructor() { + constructor({timeout = 10000, maxWorkers = 8} = {}) { this.__flightTrackerConfig = new FlightTrackerConfig(); this.__loginData = null; + this.timeout = timeout; + this.maxWorkers = maxWorkers; } /** @@ -26,7 +51,7 @@ class FlightRadar24API { * @return {Promise>} */ async getAirlines() { - const {content} = await request(Core.airlinesDataUrl, {headers: Core.htmlHeaders}); + const {content} = await request(Core.airlinesDataUrl, {headers: Core.htmlHeaders, timeout: this.timeout}); return parseAirlinesHtml(content); } @@ -48,6 +73,7 @@ class FlightRadar24API { let {content, statusCode} = await request(firstLogoUrl, { headers: Core.imageHeaders, allowedErrorCodes: notFound, + timeout: this.timeout, }); if (statusCode < 400) { @@ -58,6 +84,7 @@ class FlightRadar24API { ({content, statusCode} = await request(secondLogoUrl, { headers: Core.imageHeaders, allowedErrorCodes: notFound, + timeout: this.timeout, })); if (statusCode < 400) { @@ -85,7 +112,7 @@ class FlightRadar24API { return airport; } - const {content} = await request(Core.airportDataUrl(code), {headers: Core.jsonHeaders}); + const {content} = await request(Core.airportDataUrl(code), {headers: Core.jsonHeaders, timeout: this.timeout}); const info = content["details"]; if (info === undefined) { @@ -117,6 +144,7 @@ class FlightRadar24API { params, headers: Core.jsonHeaders, allowedErrorCodes: [400], + timeout: this.timeout, }); if (statusCode === 400 && content?.["errors"] !== undefined) { @@ -131,10 +159,10 @@ class FlightRadar24API { const result = content["result"]["response"]; const data = result?.["airport"]?.["pluginData"]; - const dataCount = typeof data === "object" ? Object.entries(data).length : 0; + const dataCount = data !== null && typeof data === "object" ? Object.entries(data).length : 0; const runways = data?.["runways"]; - const runwaysCount = typeof runways === "object" ? Object.entries(runways).length : 0; + const runwaysCount = runways !== null && typeof runways === "object" ? Object.entries(runways).length : 0; if (data?.["details"] === undefined && runwaysCount === 0 && dataCount <= 3) { throw new AirportNotFoundError("Could not find an airport by the code '" + code + "'."); @@ -149,7 +177,7 @@ class FlightRadar24API { * @return {Promise} */ async getAirportDisruptions() { - const {content} = await request(Core.airportDisruptionsUrl, {headers: Core.jsonHeaders}); + const {content} = await request(Core.airportDisruptionsUrl, {headers: Core.jsonHeaders, timeout: this.timeout}); return content; } @@ -160,13 +188,13 @@ class FlightRadar24API { * @return {Promise>} */ async getAirports(countries) { - const results = await Promise.all(countries.map(async (countryName) => { + const airports = []; + await mapConcurrent(countries, this.maxWorkers, async (countryName) => { const countryHref = Core.airportsDataUrl + "/" + countryName; - const {content} = await request(countryHref, {headers: Core.htmlHeaders}); - return parseAirportsHtml(content, countryHref); - })); - - return results.flat(); + const {content} = await request(countryHref, {headers: Core.htmlHeaders, timeout: this.timeout}); + airports.push(...parseAirportsHtml(content, countryHref)); + }); + return airports; } /** @@ -183,6 +211,7 @@ class FlightRadar24API { const {content} = await request(Core.bookmarksUrl, { headers, cookies: this.__loginData["cookies"], + timeout: this.timeout, }); return content; @@ -267,6 +296,7 @@ class FlightRadar24API { const {content, statusCode} = await request(flagUrl, { headers, allowedErrorCodes: [403, 404], + timeout: this.timeout, }); if (statusCode < 400) { @@ -283,7 +313,7 @@ class FlightRadar24API { * @return {Promise} */ async getFlightDetails(flight) { - const {content} = await request(Core.flightDataUrl(flight.id), {headers: Core.jsonHeaders}); + const {content} = await request(Core.flightDataUrl(flight.id), {headers: Core.jsonHeaders, timeout: this.timeout}); return content; } @@ -311,6 +341,7 @@ class FlightRadar24API { const {content} = await request(Core.realTimeFlightTrackerDataUrl, { params, headers: Core.jsonHeaders, + timeout: this.timeout, }); const flights = []; @@ -326,9 +357,9 @@ class FlightRadar24API { } if (details) { - await Promise.all(flights.map(async (flight) => { + await mapConcurrent(flights, this.maxWorkers, async (flight) => { flight.setFlightDetails(await this.getFlightDetails(flight)); - })); + }); } return flights; @@ -365,6 +396,7 @@ class FlightRadar24API { const {content} = await request(Core.historicalDataUrl(flight.id, fileType, timestamp), { headers, cookies: this.__loginData["cookies"], + timeout: this.timeout, }); return content; @@ -388,7 +420,7 @@ class FlightRadar24API { * @return {Promise} */ async getMostTracked() { - const {content} = await request(Core.mostTrackedUrl, {headers: Core.jsonHeaders}); + const {content} = await request(Core.mostTrackedUrl, {headers: Core.jsonHeaders, timeout: this.timeout}); return content; } @@ -398,7 +430,7 @@ class FlightRadar24API { * @return {Promise} */ async getVolcanicEruptions() { - const {content} = await request(Core.volcanicEruptionDataUrl, {headers: Core.jsonHeaders}); + const {content} = await request(Core.volcanicEruptionDataUrl, {headers: Core.jsonHeaders, timeout: this.timeout}); return content; } @@ -421,7 +453,7 @@ class FlightRadar24API { * @return {Promise} */ async search(query, limit = 50) { - const {content} = await request(Core.searchDataUrl(query, limit), {headers: Core.jsonHeaders}); + const {content} = await request(Core.searchDataUrl(query, limit), {headers: Core.jsonHeaders, timeout: this.timeout}); const results = content["results"] ?? []; const countDict = content["stats"]?.["count"] ?? {}; @@ -462,6 +494,7 @@ class FlightRadar24API { const {content, statusCode, cookies} = await request(Core.userLoginUrl, { headers: Core.jsonHeaders, data: {"email": user, "password": password, "remember": "true", "type": "web"}, + timeout: this.timeout, }); if (statusCode < 200 || statusCode >= 300 || !content["success"]) { @@ -486,7 +519,7 @@ class FlightRadar24API { const cookies = this.__loginData["cookies"]; this.__loginData = null; - const {statusCode} = await request(Core.userLogoutUrl, {headers: Core.jsonHeaders, cookies}); + const {statusCode} = await request(Core.userLogoutUrl, {headers: Core.jsonHeaders, cookies, timeout: this.timeout}); return statusCode >= 200 && statusCode < 300; } diff --git a/nodejs/FlightRadar24/entities/flight.js b/nodejs/FlightRadar24/entities/flight.js index d44c14a..7f17cc6 100644 --- a/nodejs/FlightRadar24/entities/flight.js +++ b/nodejs/FlightRadar24/entities/flight.js @@ -155,10 +155,16 @@ class Flight extends Entity { * @return {undefined} */ setFlightDetails(flightDetails) { + // Get aircraft data. const aircraft = flightDetails["aircraft"]; + + // Get airline data. const airline = flightDetails?.["airline"]; + + // Get airport data. const airport = flightDetails?.["airport"]; + // Get destination data. const destAirport = airport?.["destination"]; const destAirportCode = destAirport?.["code"]; const destAirportInfo = destAirport?.["info"]; @@ -166,6 +172,7 @@ class Flight extends Entity { const destAirportCountry = destAirportPosition?.["country"]; const destAirportTimezone = destAirport?.["timezone"]; + // Get origin data. const origAirport = airport?.["origin"]; const origAirportCode = origAirport?.["code"]; const origAirportInfo = origAirport?.["info"]; @@ -173,23 +180,31 @@ class Flight extends Entity { const origAirportCountry = origAirportPosition?.["country"]; const origAirportTimezone = origAirport?.["timezone"]; + // Get flight history. const history = flightDetails?.["flightHistory"]; + + // Get flight status. const status = flightDetails?.["status"]; + // Aircraft information. this.aircraftAge = this.__getInfo(aircraft?.["age"]); this.aircraftCountryId = this.__getInfo(aircraft?.["countryId"]); this.aircraftHistory = this.__getInfo(history?.["aircraft"], []); this.aircraftImages = this.__getInfo(aircraft?.["images"], []); this.aircraftModel = this.__getInfo(aircraft?.["model"]?.["text"]); + // Airline information. this.airlineName = this.__getInfo(airline?.["name"]); this.airlineShortName = this.__getInfo(airline?.["short"]); + // Destination airport position. this.destinationAirportAltitude = this.__getInfo(destAirportPosition?.["altitude"]); this.destinationAirportCountryCode = this.__getInfo(destAirportCountry?.["code"]); this.destinationAirportCountryName = this.__getInfo(destAirportCountry?.["name"]); this.destinationAirportLatitude = this.__getInfo(destAirportPosition?.["latitude"]); this.destinationAirportLongitude = this.__getInfo(destAirportPosition?.["longitude"]); + + // Destination airport information. this.destinationAirportIcao = this.__getInfo(destAirportCode?.["icao"]); this.destinationAirportBaggage = this.__getInfo(destAirportInfo?.["baggage"]); this.destinationAirportGate = this.__getInfo(destAirportInfo?.["gate"]); @@ -197,17 +212,22 @@ class Flight extends Entity { this.destinationAirportTerminal = this.__getInfo(destAirportInfo?.["terminal"]); this.destinationAirportVisible = this.__getInfo(destAirport?.["visible"]); this.destinationAirportWebsite = this.__getInfo(destAirport?.["website"]); + + // Destination airport timezone. this.destinationAirportTimezoneAbbr = this.__getInfo(destAirportTimezone?.["abbr"]); this.destinationAirportTimezoneAbbrName = this.__getInfo(destAirportTimezone?.["abbrName"]); this.destinationAirportTimezoneName = this.__getInfo(destAirportTimezone?.["name"]); this.destinationAirportTimezoneOffset = this.__getInfo(destAirportTimezone?.["offset"]); this.destinationAirportTimezoneOffsetHours = this.__getInfo(destAirportTimezone?.["offsetHours"]); + // Origin airport position. this.originAirportAltitude = this.__getInfo(origAirportPosition?.["altitude"]); this.originAirportCountryCode = this.__getInfo(origAirportCountry?.["code"]); this.originAirportCountryName = this.__getInfo(origAirportCountry?.["name"]); this.originAirportLatitude = this.__getInfo(origAirportPosition?.["latitude"]); this.originAirportLongitude = this.__getInfo(origAirportPosition?.["longitude"]); + + // Origin airport information. this.originAirportIcao = this.__getInfo(origAirportCode?.["icao"]); this.originAirportBaggage = this.__getInfo(origAirportInfo?.["baggage"]); this.originAirportGate = this.__getInfo(origAirportInfo?.["gate"]); @@ -215,16 +235,22 @@ class Flight extends Entity { this.originAirportTerminal = this.__getInfo(origAirportInfo?.["terminal"]); this.originAirportVisible = this.__getInfo(origAirport?.["visible"]); this.originAirportWebsite = this.__getInfo(origAirport?.["website"]); + + // Origin airport timezone. this.originAirportTimezoneAbbr = this.__getInfo(origAirportTimezone?.["abbr"]); this.originAirportTimezoneAbbrName = this.__getInfo(origAirportTimezone?.["abbrName"]); this.originAirportTimezoneName = this.__getInfo(origAirportTimezone?.["name"]); this.originAirportTimezoneOffset = this.__getInfo(origAirportTimezone?.["offset"]); this.originAirportTimezoneOffsetHours = this.__getInfo(origAirportTimezone?.["offsetHours"]); + // Flight status. this.statusIcon = this.__getInfo(status?.["icon"]); this.statusText = this.__getInfo(status?.["text"]); + // Time details. this.timeDetails = this.__getInfo(flightDetails?.["time"], {}); + + // Flight trail. this.trail = this.__getInfo(flightDetails?.["trail"], []); } } diff --git a/nodejs/FlightRadar24/index.d.ts b/nodejs/FlightRadar24/index.d.ts index 0335dd2..c1befc2 100644 --- a/nodejs/FlightRadar24/index.d.ts +++ b/nodejs/FlightRadar24/index.d.ts @@ -17,8 +17,8 @@ export class FlightRadar24API { private __flightTrackerConfig: FlightTrackerConfig; private __loginData: {userData: any; cookies: any;} | null; - constructor(); - + constructor(options?: {timeout?: number; maxWorkers?: number}); + /** * Return a list with all airlines. */ diff --git a/nodejs/Makefile b/nodejs/Makefile index 84820b8..723d31f 100644 --- a/nodejs/Makefile +++ b/nodejs/Makefile @@ -49,6 +49,13 @@ test: npm test @echo "$(GREEN)Tests completed!$(NC)" +# Check TypeScript type definitions +.PHONY: test-types +test-types: + @echo "$(GREEN)Checking type definitions...$(NC)" + npm run test:types + @echo "$(GREEN)Type definitions OK!$(NC)" + # Run linter .PHONY: lint lint: @@ -76,7 +83,7 @@ clean: # Build package (prepare for publishing) .PHONY: build -build: install lint test +build: install lint test test-types @echo "$(GREEN)Building package...$(NC)" npm pack @echo "$(GREEN)Package built successfully!$(NC)" @@ -151,7 +158,7 @@ security-fix: # Full build pipeline .PHONY: all -all: install lint test validate +all: install lint test test-types validate @echo "$(GREEN)Full build pipeline completed successfully!$(NC)" # Development workflow targets @@ -169,7 +176,7 @@ pre-publish: all security # CI/CD targets .PHONY: ci -ci: install lint test validate +ci: install lint test test-types validate @echo "$(GREEN)CI pipeline completed!$(NC)" # Show package info diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index de5e70b..c4438fc 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -16,7 +16,8 @@ "chai": "^4.3.10", "eslint": "^8.56.0", "eslint-config-google": "^0.14.0", - "mocha": "^10.2.0" + "mocha": "^10.2.0", + "tsd": "^0.31.0" }, "engines": { "node": ">=18" @@ -43,6 +44,29 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -237,6 +261,18 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -272,6 +308,55 @@ "node": ">= 8" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true + }, + "node_modules/@tsd/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-saiCxzHRhUrRxQV2JhH580aQUZiKQUXI38FcAcikcfOomAil4G4lxT0RfrrKywoAYP/rqAdYXYmNRLppcd+hQQ==", + "dev": true, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@types/eslint": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz", + "integrity": "sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -332,6 +417,33 @@ "node": ">=6" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -375,6 +487,24 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -415,12 +545,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -453,6 +583,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/chai": { "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", @@ -655,6 +811,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", @@ -695,6 +885,27 @@ "node": ">=0.3.1" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -724,6 +935,24 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -812,6 +1041,34 @@ "eslint": ">=5.16.0" } }, + "node_modules/eslint-formatter-pretty": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-formatter-pretty/-/eslint-formatter-pretty-4.1.0.tgz", + "integrity": "sha512-IsUTtGxF1hrH6lMWiSl1WbGaiP01eT6kzywdY1U+zLc0MP+nwEnUiS9UI8IaOTUhTeQJLlCEWIbXINBH4YJbBQ==", + "dev": true, + "dependencies": { + "@types/eslint": "^7.2.13", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "eslint-rule-docs": "^1.1.5", + "log-symbols": "^4.0.0", + "plur": "^4.0.0", + "string-width": "^4.2.0", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-rule-docs": { + "version": "1.1.235", + "resolved": "https://registry.npmjs.org/eslint-rule-docs/-/eslint-rule-docs-1.1.235.tgz", + "integrity": "sha512-+TQ+x4JdTnDoFEXXb3fDvfGOwnyNV7duH8fXWTPD1ieaBmB8omj7Gw/pMBBu4uI2uJCCU8APDaQJzWuXnTsH4A==", + "dev": true + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -905,6 +1162,34 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -939,9 +1224,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -1028,6 +1313,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1093,12 +1387,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1108,6 +1431,18 @@ "node": ">=8" } }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -1117,6 +1452,30 @@ "he": "bin/he" } }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -1197,6 +1556,15 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1213,6 +1581,21 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/irregular-plurals": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", + "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1225,6 +1608,21 @@ "node": ">=8" } }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1305,6 +1703,36 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -1362,6 +1790,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -1383,6 +1817,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -1396,6 +1839,12 @@ "node": ">= 0.8.0" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1447,14 +1896,95 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, "engines": { - "node": ">= 0.6" - } - }, + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", + "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", + "dev": true, + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize": "^1.2.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", @@ -1466,6 +1996,15 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1478,6 +2017,29 @@ "node": "*" } }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/minimist-options/node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/mocha": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", @@ -1615,6 +2177,21 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1685,6 +2262,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -1697,6 +2283,24 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -1735,6 +2339,21 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/pathval": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", @@ -1744,6 +2363,12 @@ "node": "*" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1756,6 +2381,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/plur": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", + "integrity": "sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==", + "dev": true, + "dependencies": { + "irregular-plurals": "^3.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1765,6 +2405,32 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -1809,6 +2475,15 @@ } ] }, + "node_modules/quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1818,6 +2493,141 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1830,6 +2640,19 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1844,6 +2667,27 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -1942,6 +2786,18 @@ "node": ">=v12.22.7" } }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -1972,6 +2828,47 @@ "node": ">=8" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1998,6 +2895,18 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2022,6 +2931,31 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2070,6 +3004,36 @@ "node": ">=18" } }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsd": { + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.31.2.tgz", + "integrity": "sha512-VplBAQwvYrHzVihtzXiUVXu5bGcr7uH1juQZ1lmKgkuGNGT+FechUCqmx9/zk7wibcqR2xaNEwCkDyKh+VVZnQ==", + "dev": true, + "dependencies": { + "@tsd/typescript": "~5.4.3", + "eslint-formatter-pretty": "^4.1.0", + "globby": "^11.0.1", + "jest-diff": "^29.0.3", + "meow": "^9.0.0", + "path-exists": "^4.0.0", + "read-pkg-up": "^7.0.0" + }, + "bin": { + "tsd": "dist/cli.js" + }, + "engines": { + "node": ">=14.16" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -2137,6 +3101,16 @@ "requires-port": "^1.0.0" } }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -2273,6 +3247,12 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -2347,6 +3327,23 @@ "lru-cache": "^10.4.3" } }, + "@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true + }, "@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -2439,6 +3436,15 @@ "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", "dev": true }, + "@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.27.8" + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2465,6 +3471,52 @@ "fastq": "^1.6.0" } }, + "@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true + }, + "@tsd/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@tsd/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-saiCxzHRhUrRxQV2JhH580aQUZiKQUXI38FcAcikcfOomAil4G4lxT0RfrrKywoAYP/rqAdYXYmNRLppcd+hQQ==", + "dev": true + }, + "@types/eslint": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz", + "integrity": "sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true + }, + "@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true + }, "@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -2507,6 +3559,23 @@ "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", "dev": true }, + "ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "requires": { + "type-fest": "^0.21.3" + }, + "dependencies": { + "type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true + } + } + }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2538,6 +3607,18 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true + }, "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -2572,12 +3653,12 @@ } }, "braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "requires": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" } }, "browser-stdout": { @@ -2598,6 +3679,25 @@ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true }, + "camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } + } + }, "chai": { "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", @@ -2749,6 +3849,30 @@ "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true }, + "decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "requires": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "dependencies": { + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true + } + } + }, "decimal.js": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", @@ -2780,6 +3904,21 @@ "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", "dev": true }, + "diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true + }, + "dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "requires": { + "path-type": "^4.0.0" + } + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -2800,6 +3939,21 @@ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" }, + "error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true + }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -2865,6 +4019,28 @@ "dev": true, "requires": {} }, + "eslint-formatter-pretty": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-formatter-pretty/-/eslint-formatter-pretty-4.1.0.tgz", + "integrity": "sha512-IsUTtGxF1hrH6lMWiSl1WbGaiP01eT6kzywdY1U+zLc0MP+nwEnUiS9UI8IaOTUhTeQJLlCEWIbXINBH4YJbBQ==", + "dev": true, + "requires": { + "@types/eslint": "^7.2.13", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "eslint-rule-docs": "^1.1.5", + "log-symbols": "^4.0.0", + "plur": "^4.0.0", + "string-width": "^4.2.0", + "supports-hyperlinks": "^2.0.0" + } + }, + "eslint-rule-docs": { + "version": "1.1.235", + "resolved": "https://registry.npmjs.org/eslint-rule-docs/-/eslint-rule-docs-1.1.235.tgz", + "integrity": "sha512-+TQ+x4JdTnDoFEXXb3fDvfGOwnyNV7duH8fXWTPD1ieaBmB8omj7Gw/pMBBu4uI2uJCCU8APDaQJzWuXnTsH4A==", + "dev": true + }, "eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -2928,6 +4104,30 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2959,9 +4159,9 @@ } }, "fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "requires": { "to-regex-range": "^5.0.1" @@ -3023,6 +4223,12 @@ "dev": true, "optional": true }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3067,24 +4273,73 @@ "type-fest": "^0.20.2" } }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, "graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + } + } + }, "html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -3141,6 +4396,12 @@ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true + }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3157,6 +4418,18 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "irregular-plurals": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/irregular-plurals/-/irregular-plurals-3.5.0.tgz", + "integrity": "sha512-1ANGLZ+Nkv1ptFb2pa8oG8Lem4krflKuX/gINiHJHjJUKaJHk/SXk5x6K3J+39/p0h1RQ2saROclJJ+QLvETCQ==", + "dev": true + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3166,6 +4439,15 @@ "binary-extensions": "^2.0.0" } }, + "is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "requires": { + "hasown": "^2.0.3" + } + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3222,6 +4504,30 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + } + }, + "jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, "js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -3265,6 +4571,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -3286,6 +4598,12 @@ "json-buffer": "3.0.1" } }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3296,6 +4614,12 @@ "type-check": "~0.4.0" } }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3335,6 +4659,62 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, + "map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true + }, + "meow": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", + "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==", + "dev": true, + "requires": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize": "^1.2.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "dependencies": { + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + }, + "type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "dev": true + } + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "requires": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + } + }, "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -3348,6 +4728,12 @@ "mime-db": "1.52.0" } }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3357,6 +4743,25 @@ "brace-expansion": "^1.1.7" } }, + "minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "dependencies": { + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true + } + } + }, "mocha": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", @@ -3465,6 +4870,18 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "requires": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + } + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3517,6 +4934,12 @@ "p-limit": "^3.0.2" } }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3526,6 +4949,18 @@ "callsites": "^3.0.0" } }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, "parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -3552,24 +4987,70 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true + }, "pathval": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", "dev": true }, + "picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "plur": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/plur/-/plur-4.0.0.tgz", + "integrity": "sha512-4UGewrYgqDFw9vV6zNV+ADmPAUAfJPKtGvb/VdpQAx25X5f3xXdGdyOEVFwkl8Hl/tl7+xbeHqSEM+D5/TirUg==", + "dev": true, + "requires": { + "irregular-plurals": "^3.2.0" + } + }, "prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true }, + "pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "requires": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + } + } + }, "psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -3594,6 +5075,12 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -3603,6 +5090,112 @@ "safe-buffer": "^5.1.0" } }, + "react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + }, + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, "readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -3612,6 +5205,16 @@ "picomatch": "^2.2.1" } }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3623,6 +5226,18 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "requires": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, "resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3677,6 +5292,12 @@ "xmlchars": "^2.2.0" } }, + "semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true + }, "serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -3701,6 +5322,44 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true + }, "string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3721,6 +5380,15 @@ "ansi-regex": "^5.0.1" } }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3736,6 +5404,22 @@ "has-flag": "^4.0.0" } }, + "supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "requires": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, "symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -3775,6 +5459,27 @@ "punycode": "^2.3.1" } }, + "trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "dev": true + }, + "tsd": { + "version": "0.31.2", + "resolved": "https://registry.npmjs.org/tsd/-/tsd-0.31.2.tgz", + "integrity": "sha512-VplBAQwvYrHzVihtzXiUVXu5bGcr7uH1juQZ1lmKgkuGNGT+FechUCqmx9/zk7wibcqR2xaNEwCkDyKh+VVZnQ==", + "dev": true, + "requires": { + "@tsd/typescript": "~5.4.3", + "eslint-formatter-pretty": "^4.1.0", + "globby": "^11.0.1", + "jest-diff": "^29.0.3", + "meow": "^9.0.0", + "path-exists": "^4.0.0", + "read-pkg-up": "^7.0.0" + } + }, "type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3824,6 +5529,16 @@ "requires-port": "^1.0.0" } }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -3913,6 +5628,12 @@ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, "yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index ff1094b..171368b 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -3,8 +3,10 @@ "version": "1.5.0", "description": "SDK for FlightRadar24", "main": "./FlightRadar24/index.js", + "types": "./FlightRadar24/index.d.ts", "scripts": { "test": "mocha tests --timeout 10000", + "test:types": "tsd", "lint": "eslint ." }, "repository": { @@ -38,6 +40,10 @@ "chai": "^4.3.10", "eslint": "^8.56.0", "eslint-config-google": "^0.14.0", - "mocha": "^10.2.0" + "mocha": "^10.2.0", + "tsd": "^0.31.0" + }, + "tsd": { + "directory": "tests" } } diff --git a/nodejs/tests/index.test-d.ts b/nodejs/tests/index.test-d.ts new file mode 100644 index 0000000..eeece41 --- /dev/null +++ b/nodejs/tests/index.test-d.ts @@ -0,0 +1,69 @@ +import {expectType, expectError} from "tsd"; +import { + FlightRadar24API, + Flight, + Airport, + FlightTrackerConfig, + Zone, +} from "../FlightRadar24/index"; + +const api = new FlightRadar24API(); +expectType(new FlightRadar24API({timeout: 5000, maxWorkers: 4})); + +// getFlights +expectType>(api.getFlights()); +expectType>(api.getFlights("DAL")); +expectType>(api.getFlights(null, null, null, null, true)); + +// getFlightDetails +declare const flight: Flight; +expectType>(api.getFlightDetails(flight)); + +// getAirport / getAirportDetails +expectType>(api.getAirport("ATL")); +expectType>(api.getAirport("ATL", true)); +expectType>(api.getAirportDetails("ATL", 10, 1)); + +// getAirports +expectType>(api.getAirports(["Brazil"])); + +// getAirlines +expectType>>(api.getAirlines()); + +// getBounds / getBoundsByPoint +const zone: Zone = {tl_y: 1, br_y: 0, tl_x: 0, br_x: 1}; +expectType(api.getBounds(zone)); +expectType(api.getBoundsByPoint(0, 0, 1000)); + +// getZones +expectType(api.getZones()); + +// search +expectType>>(api.search("TAM")); + +// isLoggedIn / login / logout +expectType(api.isLoggedIn()); +expectType>(api.login("user@email.com", "password")); +expectType>(api.logout()); + +// FlightTrackerConfig +expectType(api.getFlightTrackerConfig()); + +// Flight properties +expectType(flight.id); +expectType(flight.altitude); +expectType(flight.groundSpeed); +expectType(flight.registration); + +// Flight methods +expectType(flight.getAltitude()); +expectType(flight.getFlightLevel()); +expectType(flight.getGroundSpeed()); +expectType(flight.getHeading()); +expectType(flight.getVerticalSpeed()); +expectType(flight.checkInfo({airlineIcao: "TAM"})); + +// Wrong types should error +expectError(api.getFlights(123)); +expectError(api.getBounds({tl_y: 1})); +expectError(new FlightRadar24API({timeout: "5000"})); diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index 15ddb0a..ac63e25 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -12,8 +12,8 @@ describe("Testing FlightRadarAPI version " + version, function() { it("Expected at least " + expected + " airlines.", async function() { const results = await frApi.getAirlines(); expect(results.length).to.be.above(expected - 1); - - const foundIcaos = new Set(results.filter(a => airlines.includes(a.ICAO)).map(a => a.ICAO)); + + const foundIcaos = new Set(results.filter((a) => airlines.includes(a.ICAO)).map((a) => a.ICAO)); expect(foundIcaos.size).to.equal(airlines.length); }); }); @@ -141,7 +141,7 @@ describe("Testing FlightRadarAPI version " + version, function() { const targetAirlines = [["WN", "SWA"], ["G3", "GLO"], ["AD", "AZU"], ["AA", "AAL"], ["TK", "THY"]]; const expected = targetAirlines.length * 0.8; - const icao = targetAirlines.map(a => a[1]); + const icao = targetAirlines.map((a) => a[1]); let message = "Expected getting logos from at least " + Math.trunc(expected); message += " of the following airlines: " + icao.join(", ") + "."; diff --git a/python/.tool-versions b/python/.tool-versions new file mode 100644 index 0000000..30b467f --- /dev/null +++ b/python/.tool-versions @@ -0,0 +1 @@ +python 3.11.0 \ No newline at end of file diff --git a/python/FlightRadar24/api.py b/python/FlightRadar24/api.py index 0b1eab4..1cc9134 100644 --- a/python/FlightRadar24/api.py +++ b/python/FlightRadar24/api.py @@ -2,7 +2,7 @@ import dataclasses import math -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Dict, List, Optional, Tuple, Union from urllib.parse import quote @@ -20,17 +20,20 @@ class FlightRadar24API: Main class of the FlightRadarAPI """ - def __init__(self, user: Optional[str] = None, password: Optional[str] = None, timeout: int = 10): + def __init__(self, user: Optional[str] = None, password: Optional[str] = None, timeout: int = 10, max_workers: int = 8): """ Constructor of the FlightRadar24API class. - :param user: Your email (optional) - :param password: Your password (optional) + :param user: Your email + :param password: Your password + :param timeout: Request timeout in seconds + :param max_workers: Maximum threads used when fetching flight details concurrently """ self.__flight_tracker_config = FlightTrackerConfig() self.__login_data: Optional[Dict] = None self.timeout: int = timeout + self.max_workers: int = max_workers if user is not None and password is not None: self.login(user, password) @@ -51,7 +54,7 @@ def get_airline_logo(self, iata: str, icao: str) -> Optional[Tuple[bytes, str]]: first_logo_url = Core.airline_logo_url.format(iata, icao) # Try to get the image by the first URL option. - response = APIRequest(first_logo_url, headers=Core.image_headers, exclude_status_codes=[403,], timeout=self.timeout) + response = APIRequest(first_logo_url, headers=Core.image_headers, allowed_error_codes=[403, 404], timeout=self.timeout) status_code = response.get_status_code() if not (400 <= status_code < 500): @@ -60,12 +63,14 @@ def get_airline_logo(self, iata: str, icao: str) -> Optional[Tuple[bytes, str]]: # Get the image by the second airline logo URL. second_logo_url = Core.alternative_airline_logo_url.format(icao) - response = APIRequest(second_logo_url, headers=Core.image_headers, exclude_status_codes=[403, 404], timeout=self.timeout) + response = APIRequest(second_logo_url, headers=Core.image_headers, allowed_error_codes=[403, 404], timeout=self.timeout) status_code = response.get_status_code() if not (400 <= status_code < 500): return response.get_content(), second_logo_url.split(".")[-1] + return None + def get_airport(self, code: str, *, details: bool = False) -> Airport: """ Return basic information about a specific airport. @@ -111,7 +116,7 @@ def get_airport_details(self, code: str, flight_limit: int = 100, page: int = 1) request_params["page"] = page # Request details from the FlightRadar24. - response = APIRequest(Core.api_airport_data_url, request_params, Core.json_headers, exclude_status_codes=[400,], timeout=self.timeout) + response = APIRequest(Core.api_airport_data_url, params=request_params, headers=Core.json_headers, allowed_error_codes=[400], timeout=self.timeout) content: Dict = response.get_content() if response.get_status_code() == 400 and content.get("errors"): @@ -151,7 +156,7 @@ def _fetch(country): response = APIRequest(href, headers=Core.html_headers, timeout=self.timeout) return parse_airports_html(response.get_content(), href) - with ThreadPoolExecutor() as executor: + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: results = executor.map(_fetch, countries) return [airport for result in results for airport in result] @@ -243,12 +248,14 @@ def get_country_flag(self, country: str) -> Optional[Tuple[bytes, str]]: headers.pop("origin", None) # Does not work for this request. - response = APIRequest(flag_url, headers=headers, exclude_status_codes=[403, 404], timeout=self.timeout) + response = APIRequest(flag_url, headers=headers, allowed_error_codes=[403, 404], timeout=self.timeout) status_code = response.get_status_code() if not (400 <= status_code < 500): return response.get_content(), flag_url.split(".")[-1] + return None + def get_flight_details(self, flight: Flight) -> Dict[Any, Any]: """ Return the flight details from Data Live FlightRadar24. @@ -282,13 +289,13 @@ def get_flights( request_params["enc"] = self.__login_data["cookies"]["_frPl"] # Insert the method parameters into the dictionary for the request. - if airline: request_params["airline"] = airline - if bounds: request_params["bounds"] = bounds - if registration: request_params["reg"] = registration - if aircraft_type: request_params["type"] = aircraft_type + if airline is not None: request_params["airline"] = airline + if bounds is not None: request_params["bounds"] = bounds + if registration is not None: request_params["reg"] = registration + if aircraft_type is not None: request_params["type"] = aircraft_type # Get all flights from Data Live FlightRadar24. - response = APIRequest(Core.real_time_flight_tracker_data_url, request_params, Core.json_headers, timeout=self.timeout) + response = APIRequest(Core.real_time_flight_tracker_data_url, params=request_params, headers=Core.json_headers, timeout=self.timeout) content = response.get_content() flights: List[Flight] = list() @@ -302,10 +309,10 @@ def get_flights( flights.append(Flight(flight_id, flight_info)) if details: - with ThreadPoolExecutor() as executor: - all_details = list(executor.map(self.get_flight_details, flights)) - for flight, flight_details in zip(flights, all_details): - flight.set_flight_details(flight_details) + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + futures = {executor.submit(self.get_flight_details, f): f for f in flights} + for future in as_completed(futures): + futures[future].set_flight_details(future.result()) return flights diff --git a/python/FlightRadar24/request.py b/python/FlightRadar24/request.py index cf20e8f..beb45f2 100644 --- a/python/FlightRadar24/request.py +++ b/python/FlightRadar24/request.py @@ -26,12 +26,13 @@ class APIRequest: def __init__( self, url: str, + *, params: Optional[Dict] = None, headers: Optional[Dict] = None, timeout: int = 30, data: Optional[Dict] = None, cookies: Optional[Dict] = None, - exclude_status_codes: Optional[List[int]] = None + allowed_error_codes: Optional[List[int]] = None ): """ Constructor of the APIRequest class. @@ -41,7 +42,7 @@ def __init__( :param headers: headers for the request :param data: data for the request. If "data" is None, request will be a GET. Otherwise, it will be a POST :param cookies: cookies for the request - :param exclude_status_codes: raise for status code except those on the excluded list + :param allowed_error_codes: status codes that should not raise an error """ self.url = url @@ -59,7 +60,7 @@ def __init__( response=self.__response ) - if self.get_status_code() not in (exclude_status_codes or []): + if self.get_status_code() not in (allowed_error_codes or []): self.__response.raise_for_status() def get_content(self) -> Union[Dict, bytes]: diff --git a/python/tests/test_snapshots.py b/python/tests/test_snapshots.py index 9daea94..2b6943f 100644 --- a/python/tests/test_snapshots.py +++ b/python/tests/test_snapshots.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -from FlightRadar24 import Airport, Flight from FlightRadar24.errors import CloudflareError from package import Countries, FlightRadar24API from util import repeat_test diff --git a/python/tests/util.py b/python/tests/util.py index 21cab45..3c03b51 100644 --- a/python/tests/util.py +++ b/python/tests/util.py @@ -1,4 +1,4 @@ -from typing import Callable, List, Optional +from typing import Callable, List, Optional, Sequence, Type import time @@ -12,19 +12,24 @@ def raise_multiple(errors: List) -> None: finally: raise_multiple(errors) -def repeat_test(attempts: int, after: int, errors: Optional[List[Exception]] = None) -> Callable: +def repeat_test(attempts: int, after: int, errors: Optional[Sequence[Type[BaseException]]] = None) -> Callable: """ Decorator to repeat a test N times for specific errors. :param attempts: Number of attempts for testing :param after: Time in seconds to wait for each attempt - :param errors: If None, repeat test for any error + :param errors: Sequence of exception classes to retry on; if None, retry on any error """ + if errors is not None: + invalid = [e for e in errors if not (isinstance(e, type) and issubclass(e, BaseException))] + if invalid: + raise TypeError(f"errors must contain exception classes, got: {invalid}") + def _repeat_test(test_function: Callable) -> Callable: def wrapper(*args, **kwargs): error_list: List[Exception] = list() - for attempt in range(attempts): + for _ in range(attempts): try: return test_function(*args, **kwargs) From 99b9b6f2b36a053bec642c705537eedb42c53435 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 11 May 2026 13:04:10 -0300 Subject: [PATCH 4/8] refactor: improve CI, type safety, linting and test parity across Python and Node.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite CI workflows: update action versions, add paths filters, fail-fast: false, Python 3.13 support, lint/type-check/build-verify steps, fix wrong matrix variable name - Remove empty pr-closed.yml workflow - Add typed helpers get_json_content()/get_bytes_content() to APIRequest with proper ValueError on type mismatch (replaces unsafe assert and unguarded Union usage) - Fix all 29 mypy errors across api.py, parsers.py and request.py - Fix flake8 E501/W292/E121 in api.py, core.py and zones.py - Fix Makefile PACKAGE_NAME (FlightRadarAPI → FlightRadar24) - Align Python/Node.js test suites: same 33 tests in test_api/testApi and 13 tests in test_snapshots/testSnapshots; add unit tests for Entity, FlightTrackerConfig and auth-guard paths; fix ESLint brace-style warnings in testApi.js - Expand .gitignore with Python build artifacts, coverage, editor and OS files --- .github/workflows/node-package.yml | 26 +++++--- .github/workflows/pr-closed.yml | 16 ----- .github/workflows/python-package.yml | 51 +++++++++------ .gitignore | 32 +++++++-- nodejs/tests/testApi.js | 95 ++++++++++++++++++++++++++- python/.flake8 | 5 +- python/FlightRadar24/api.py | 65 +++++++++++-------- python/FlightRadar24/core.py | 18 ++++-- python/FlightRadar24/parsers.py | 6 +- python/FlightRadar24/request.py | 20 +++++- python/Makefile | 2 +- python/pyproject.toml | 6 +- python/tests/test_api.py | 97 ++++++++++++++++++++-------- python/tests/test_snapshots.py | 26 ++++++++ 14 files changed, 346 insertions(+), 119 deletions(-) delete mode 100644 .github/workflows/pr-closed.yml diff --git a/.github/workflows/node-package.yml b/.github/workflows/node-package.yml index 09b4304..a01120f 100644 --- a/.github/workflows/node-package.yml +++ b/.github/workflows/node-package.yml @@ -1,32 +1,40 @@ -# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created -# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages - name: Node.js Package on: push: + paths: + - 'nodejs/**' + - '.github/workflows/node-package.yml' pull_request: + paths: + - 'nodejs/**' + - '.github/workflows/node-package.yml' schedule: - - cron: '0 0 */7 * *' + - cron: '0 0 */7 * *' workflow_dispatch: defaults: - run: - working-directory: ./nodejs + run: + working-directory: ./nodejs jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: node-version: ['18.x', '20.x', '22.x'] steps: - - uses: actions/checkout@v3 - - name: Set up NodeJS ${{ matrix.python-version }} - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - name: Set up NodeJS ${{ matrix.node-version }} + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - name: Install dependencies run: npm ci + - name: Lint + run: npm run lint + - name: Type check + run: npm run test:types - name: Test package run: npm test diff --git a/.github/workflows/pr-closed.yml b/.github/workflows/pr-closed.yml deleted file mode 100644 index cd8e6bf..0000000 --- a/.github/workflows/pr-closed.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: PR Closed - -on: - pull_request: - types: - - closed - -jobs: - pr-closed: - name: PR Closed - runs-on: ubuntu-latest - - steps: - # Checkout - - name: Checkout - uses: actions/checkout@v4 \ No newline at end of file diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index b7a825b..1fb1609 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -1,35 +1,44 @@ -# This workflow will install Python dependencies, run tests and lint with a variety of Python versions -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - name: Python Package on: push: + paths: + - 'python/**' + - '.github/workflows/python-package.yml' pull_request: + paths: + - 'python/**' + - '.github/workflows/python-package.yml' schedule: - - cron: '0 0 */7 * *' + - cron: '0 0 */7 * *' workflow_dispatch: jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e "./python[tests]" - - name: Test with pytest - run: | - cd python - pytest tests -vv -s - - name: Install package - run: | - pip install FlightRadar24 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e "./python[tests]" + pip install flake8 mypy build + - name: Lint + run: cd python && python -m flake8 FlightRadar24 tests + - name: Type check + run: cd python && python -m mypy FlightRadar24 --ignore-missing-imports + - name: Test with pytest + run: cd python && pytest tests -vv -s + - name: Build and verify install + run: | + python -m build ./python + pip install python/dist/*.whl --force-reinstall + python -c "from FlightRadar24 import FlightRadar24API; api = FlightRadar24API(); print('Install OK')" diff --git a/.gitignore b/.gitignore index f2db4de..6e9af86 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,31 @@ +# Python __pycache__ -dist -*.pytest_cache -*.egg-info -.idea/ +*.pyc +*.pyo +*.pyd +*.so +dist/ +build/ +*.egg-info/ +.eggs/ +.venv/ venv/ +.mypy_cache/ +.pytest_cache/ +htmlcov/ +.coverage +coverage.xml + +# Node.js node_modules/ + +# Editors / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Misc .env -.cache \ No newline at end of file +.cache +*.log \ No newline at end of file diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index ac63e25..f8832f2 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -1,4 +1,4 @@ -const {FlightRadar24API, Flight, Countries, version} = require(".."); +const {FlightRadar24API, Flight, Entity, FlightTrackerConfig, Countries, version} = require(".."); const expect = require("chai").expect; @@ -253,4 +253,97 @@ describe("Testing FlightRadarAPI version " + version, function() { expect(flight.checkInfo({minAltitude: 30000, maxAltitude: 40000, airlineIcao: "GLO"})).to.be.true; }); }); + + // --- Entity --- + + describe("Entity.getDistanceFrom()", function() { + it("GRU to GIG is between 340 and 380 km.", function() { + const gru = new Entity(-23.4356, -46.4731); + const gig = new Entity(-22.8099, -43.2505); + const dist = gru.getDistanceFrom(gig); + expect(dist).to.be.above(336).and.below(337); + }); + + it("Distance from self is approximately zero.", function() { + const e = new Entity(0, 0); + expect(e.getDistanceFrom(e)).to.be.closeTo(0, 1e-9); + }); + + it("Throws when entity has no position.", function() { + const e1 = new Entity(null, null); + const e2 = new Entity(-23.0, -46.0); + expect(() => e1.getDistanceFrom(e2)).to.throw(); + }); + }); + + // --- FlightTrackerConfig --- + + describe("FlightTrackerConfig defaults", function() { + it("Has expected default values.", function() { + const config = new FlightTrackerConfig(); + expect(config.limit).to.equal("5000"); + expect(config.faa).to.equal("1"); + expect(config.maxage).to.equal("14400"); + }); + }); + + describe("getFlightTrackerConfig() — independent copy", function() { + it("Mutating the returned copy does not affect internal state.", function() { + const c1 = frApi.getFlightTrackerConfig(); + const c2 = frApi.getFlightTrackerConfig(); + c1.limit = "999"; + expect(c2.limit).to.not.equal("999"); + }); + }); + + // --- Auth state guards (no network) --- + + describe("isLoggedIn() — initial state", function() { + it("Returns false before any login.", function() { + expect(new FlightRadar24API().isLoggedIn()).to.be.false; + }); + }); + + describe("getLoginData() — not logged in", function() { + it("Throws when not authenticated.", function() { + expect(() => new FlightRadar24API().getLoginData()).to.throw(); + }); + }); + + describe("logout() — not logged in", function() { + it("Returns true when already logged out.", async function() { + const result = await new FlightRadar24API().logout(); + expect(result).to.be.true; + }); + }); + + describe("getHistoryData() — not logged in", function() { + it("Throws when not authenticated.", async function() { + const api = new FlightRadar24API(); + const flight = new Flight("123", ["ABC", 0, 0, 0, 0, 0, "0", null, "B738", "PR-X", 0, "GRU", "GIG", "G1", 0, 0, "GLO1", null, "GLO"]); + try { + await api.getHistoryData(flight, "CSV", 0); + expect.fail("Expected an error to be thrown."); + } + catch (err) { + expect(err).to.be.instanceof(Error); + } + }); + }); + + describe("getHistoryData() — invalid file type", function() { + it("Throws for unsupported file type.", async function() { + const api = new FlightRadar24API(); + api.__loginData = {userData: {accessToken: "fake"}, cookies: {"_frPl": "fake"}}; + const flight = new Flight("123", ["ABC", 0, 0, 0, 0, 0, "0", null, "B738", "PR-X", 0, "GRU", "GIG", "G1", 0, 0, "GLO1", null, "GLO"]); + try { + await api.getHistoryData(flight, "PDF", 0); + expect.fail("Expected an error to be thrown."); + } + catch (err) { + expect(err).to.be.instanceof(Error); + expect(err.message).to.include("not supported"); + } + }); + }); }); diff --git a/python/.flake8 b/python/.flake8 index b37db53..704b941 100644 --- a/python/.flake8 +++ b/python/.flake8 @@ -8,4 +8,7 @@ per-file-ignores = */__init__.py:F401 # Unused imports are allowed in the tests/package.py module and they must come after setting the current working directory. - tests/package.py:F401, E402 \ No newline at end of file + tests/package.py:F401, E402 + + # zones.py is a data file with 2-space indentation; E121 (hanging indent) does not apply. + FlightRadar24/zones.py:E121 \ No newline at end of file diff --git a/python/FlightRadar24/api.py b/python/FlightRadar24/api.py index 1cc9134..aecaa7c 100644 --- a/python/FlightRadar24/api.py +++ b/python/FlightRadar24/api.py @@ -43,7 +43,7 @@ def get_airlines(self) -> List[Dict]: Return a list with all airlines. """ response = APIRequest(Core.airlines_data_url, headers=Core.html_headers, timeout=self.timeout) - return parse_airlines_html(response.get_content()) + return parse_airlines_html(response.get_bytes_content()) def get_airline_logo(self, iata: str, icao: str) -> Optional[Tuple[bytes, str]]: """ @@ -58,7 +58,7 @@ def get_airline_logo(self, iata: str, icao: str) -> Optional[Tuple[bytes, str]]: status_code = response.get_status_code() if not (400 <= status_code < 500): - return response.get_content(), first_logo_url.split(".")[-1] + return response.get_bytes_content(), first_logo_url.split(".")[-1] # Get the image by the second airline logo URL. second_logo_url = Core.alternative_airline_logo_url.format(icao) @@ -67,7 +67,7 @@ def get_airline_logo(self, iata: str, icao: str) -> Optional[Tuple[bytes, str]]: status_code = response.get_status_code() if not (400 <= status_code < 500): - return response.get_content(), second_logo_url.split(".")[-1] + return response.get_bytes_content(), second_logo_url.split(".")[-1] return None @@ -87,9 +87,9 @@ def get_airport(self, code: str, *, details: bool = False) -> Airport: return airport response = APIRequest(Core.airport_data_url.format(code), headers=Core.json_headers, timeout=self.timeout) - content = response.get_content() + content = response.get_json_content() - if not content or not isinstance(content, dict) or not content.get("details"): + if not content or not content.get("details"): raise AirportNotFoundError(f"Could not find an airport by the code '{code}'.") return Airport(info=content["details"]) @@ -105,7 +105,7 @@ def get_airport_details(self, code: str, flight_limit: int = 100, page: int = 1) if not (3 <= len(code) <= 4): raise ValueError(f"The code '{code}' is invalid. It must be the IATA or ICAO of the airport.") - request_params = {"format": "json"} + request_params: Dict[str, Any] = {"format": "json"} if self.__login_data is not None: request_params["token"] = self.__login_data["cookies"]["_frPl"] @@ -116,8 +116,14 @@ def get_airport_details(self, code: str, flight_limit: int = 100, page: int = 1) request_params["page"] = page # Request details from the FlightRadar24. - response = APIRequest(Core.api_airport_data_url, params=request_params, headers=Core.json_headers, allowed_error_codes=[400], timeout=self.timeout) - content: Dict = response.get_content() + response = APIRequest( + Core.api_airport_data_url, + params=request_params, + headers=Core.json_headers, + allowed_error_codes=[400], + timeout=self.timeout, + ) + content = response.get_json_content() if response.get_status_code() == 400 and content.get("errors"): errors = content["errors"]["errors"]["parameters"] @@ -143,7 +149,7 @@ def get_airport_disruptions(self) -> Dict: Return airport disruptions. """ response = APIRequest(Core.airport_disruptions_url, headers=Core.json_headers, timeout=self.timeout) - return response.get_content() + return response.get_json_content() def get_airports(self, countries: List[Countries]) -> List[Airport]: """ @@ -154,7 +160,7 @@ def get_airports(self, countries: List[Countries]) -> List[Airport]: def _fetch(country): href = Core.airports_data_url + "/" + country.value response = APIRequest(href, headers=Core.html_headers, timeout=self.timeout) - return parse_airports_html(response.get_content(), href) + return parse_airports_html(response.get_bytes_content(), href) with ThreadPoolExecutor(max_workers=self.max_workers) as executor: results = executor.map(_fetch, countries) @@ -168,12 +174,12 @@ def get_bookmarks(self) -> Dict: if not self.is_logged_in(): raise LoginError("You must log in to your account.") + assert self.__login_data is not None headers = {**Core.json_headers, "accesstoken": self.get_login_data()["accessToken"]} - cookies = self.__login_data["cookies"] response = APIRequest(Core.bookmarks_url, headers=headers, cookies=cookies, timeout=self.timeout) - return response.get_content() + return response.get_json_content() def get_bounds(self, zone: Dict[str, float]) -> str: """ @@ -252,7 +258,7 @@ def get_country_flag(self, country: str) -> Optional[Tuple[bytes, str]]: status_code = response.get_status_code() if not (400 <= status_code < 500): - return response.get_content(), flag_url.split(".")[-1] + return response.get_bytes_content(), flag_url.split(".")[-1] return None @@ -263,7 +269,7 @@ def get_flight_details(self, flight: Flight) -> Dict[Any, Any]: :param flight: A Flight instance """ response = APIRequest(Core.flight_data_url.format(flight.id), headers=Core.json_headers, timeout=self.timeout) - return response.get_content() + return response.get_json_content() def get_flights( self, @@ -295,8 +301,13 @@ def get_flights( if aircraft_type is not None: request_params["type"] = aircraft_type # Get all flights from Data Live FlightRadar24. - response = APIRequest(Core.real_time_flight_tracker_data_url, params=request_params, headers=Core.json_headers, timeout=self.timeout) - content = response.get_content() + response = APIRequest( + Core.real_time_flight_tracker_data_url, + params=request_params, + headers=Core.json_headers, + timeout=self.timeout, + ) + content = response.get_json_content() flights: List[Flight] = list() @@ -333,6 +344,7 @@ def get_history_data(self, flight: Flight, file_type: str, timestamp: int) -> st if not self.is_logged_in(): raise LoginError("You must log in to your account.") + assert self.__login_data is not None file_type = file_type.lower() if file_type not in ["csv", "kml"]: @@ -346,8 +358,7 @@ def get_history_data(self, flight: Flight, file_type: str, timestamp: int) -> st timeout=self.timeout ) - content = response.get_content() - return content.decode("utf-8") + return response.get_bytes_content().decode("utf-8") def get_login_data(self) -> Dict[Any, Any]: """ @@ -356,6 +367,7 @@ def get_login_data(self) -> Dict[Any, Any]: if not self.is_logged_in(): raise LoginError("You must log in to your account.") + assert self.__login_data is not None return self.__login_data["userData"].copy() def get_most_tracked(self) -> Dict: @@ -363,16 +375,16 @@ def get_most_tracked(self) -> Dict: Return the most tracked data. """ response = APIRequest(Core.most_tracked_url, headers=Core.json_headers, timeout=self.timeout) - return response.get_content() + return response.get_json_content() def get_volcanic_eruptions(self) -> Dict: """ Return boundaries of volcanic eruptions and ash clouds impacting aviation. """ response = APIRequest(Core.volcanic_eruption_data_url, headers=Core.json_headers, timeout=self.timeout) - return response.get_content() + return response.get_json_content() - def get_zones(self) -> Dict[str, Dict]: + def get_zones(self) -> Dict[str, Any]: """ Return all major zones on the globe. """ @@ -385,12 +397,12 @@ def search(self, query: str, limit: int = 50) -> Dict: Return the search result. """ response = APIRequest(Core.search_data_url.format(quote(query), limit), headers=Core.json_headers, timeout=self.timeout) - content = response.get_content() + content = response.get_json_content() results = content.get("results", []) stats = content.get("stats", {}) i = 0 - data = {} + data: Dict[str, Any] = {} for name, count in stats.get("count", {}).items(): data[name] = results[i:i + count] i += count @@ -418,13 +430,10 @@ def login(self, user: str, password: str) -> None: response = APIRequest(Core.user_login_url, headers=Core.json_headers, data=data, timeout=self.timeout) status_code = response.get_status_code() - content = response.get_content() + content = response.get_json_content() if not (200 <= status_code < 300) or not content.get("success"): - if isinstance(content, dict): - raise LoginError(content["message"]) - else: - raise LoginError("Your email or password is incorrect") + raise LoginError(content.get("message", "Your email or password is incorrect")) self.__login_data = { "userData": content["userData"], diff --git a/python/FlightRadar24/core.py b/python/FlightRadar24/core.py index b2da153..d474985 100644 --- a/python/FlightRadar24/core.py +++ b/python/FlightRadar24/core.py @@ -71,7 +71,10 @@ class Core: "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + " (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" + ), } json_headers = headers.copy() @@ -81,7 +84,11 @@ class Core: image_headers["accept"] = "image/gif, image/jpg, image/jpeg, image/png" html_headers = { - "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", + "accept": ( + "text/html,application/xhtml+xml,application/xml;q=0.9," + "image/avif,image/webp,image/apng,*/*;q=0.8," + "application/signed-exchange;v=b3;q=0.7" + ), "accept-encoding": "gzip, deflate, br", "accept-language": "pt-BR,pt;q=0.9,en-US;q=0.8,en;q=0.7", "cache-control": "max-age=0", @@ -94,7 +101,10 @@ class Core: "sec-fetch-site": "same-origin", "sec-fetch-user": "?1", "upgrade-insecure-requests": "1", - "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" + "user-agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + " (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" + ), } @@ -329,4 +339,4 @@ class Countries(Enum): WALLIS_AND_FUTUNA = "wallis-and-futuna" YEMEN = "yemen" ZAMBIA = "zambia" - ZIMBABWE = "zimbabwe" \ No newline at end of file + ZIMBABWE = "zimbabwe" diff --git a/python/FlightRadar24/parsers.py b/python/FlightRadar24/parsers.py index 779d796..2aef4bd 100644 --- a/python/FlightRadar24/parsers.py +++ b/python/FlightRadar24/parsers.py @@ -86,9 +86,9 @@ def parse_airports_html(html: bytes, country_href: str) -> List[Airport]: a_element = a_elements[0] icao = "" - iata = a_element.get("data-iata", "").strip() - latitude = a_element.get("data-lat", "").strip() - longitude = a_element.get("data-lon", "").strip() + 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) small_element = a_element.find("small") diff --git a/python/FlightRadar24/request.py b/python/FlightRadar24/request.py index beb45f2..67de1a7 100644 --- a/python/FlightRadar24/request.py +++ b/python/FlightRadar24/request.py @@ -51,7 +51,7 @@ def __init__( if params: url += "?" + urlencode(params) self.__response = request_method( url, headers=headers, cookies=cookies, data=data, timeout=timeout, - impersonate=_IMPERSONATE + impersonate=_IMPERSONATE # type: ignore[arg-type] ) if self.get_status_code() == 520: @@ -86,6 +86,24 @@ def get_content(self) -> Union[Dict, bytes]: return content + def get_json_content(self) -> Dict[str, Any]: + """ + Return the response content as a parsed JSON dictionary. + """ + content = self.get_content() + if not isinstance(content, dict): + raise ValueError(f"Expected JSON response from {self.url}, got bytes") + return content + + def get_bytes_content(self) -> bytes: + """ + Return the response content as raw bytes. + """ + content = self.get_content() + if not isinstance(content, bytes): + raise ValueError(f"Expected bytes response from {self.url}, got JSON") + return content + def get_cookies(self) -> Dict: """ Return the received cookies from the request. diff --git a/python/Makefile b/python/Makefile index f92854f..093d9d6 100644 --- a/python/Makefile +++ b/python/Makefile @@ -1,7 +1,7 @@ # Makefile for FlightRadarAPI Python package # Variables -PACKAGE_NAME = FlightRadarAPI +PACKAGE_NAME = FlightRadar24 PYTHON = python3 PIP = pip3 BUILD_DIR = build diff --git a/python/pyproject.toml b/python/pyproject.toml index 2a01863..97b9293 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -13,7 +13,8 @@ classifiers = [ "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] requires-python = ">=3.10" dependencies = [ @@ -54,6 +55,9 @@ markers = [ "integration: marks tests as integration tests", ] +[tool.mypy] +ignore_missing_imports = true + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/python/tests/test_api.py b/python/tests/test_api.py index 3bcbe67..eb54ef3 100644 --- a/python/tests/test_api.py +++ b/python/tests/test_api.py @@ -2,8 +2,8 @@ import pytest -from FlightRadar24 import Flight -from FlightRadar24.errors import CloudflareError +from FlightRadar24 import Entity, Flight +from FlightRadar24.errors import CloudflareError, LoginError from package import Countries, FlightRadar24API, version from util import repeat_test @@ -130,32 +130,6 @@ def test_get_country_flag(countries=["United States", "Brazil", "Egypt", "Japan" assert found >= expected -@repeat_test(**repeat_test_config) -def test_get_most_tracked(): - result = fr_api.get_most_tracked() - assert isinstance(result, dict) - - -@repeat_test(**repeat_test_config) -def test_get_airport_disruptions(): - result = fr_api.get_airport_disruptions() - assert isinstance(result, dict) - - -@repeat_test(**repeat_test_config) -def test_get_volcanic_eruptions(): - result = fr_api.get_volcanic_eruptions() - assert isinstance(result, dict) - - -@repeat_test(**repeat_test_config) -def test_search(): - result = fr_api.search("Guarulhos") - assert isinstance(result, dict) - for value in result.values(): - assert isinstance(value, list) - - def test_get_bounds(): zone = {"tl_y": 75.78, "br_y": -75.78, "tl_x": -427.56, "br_x": 427.56} assert fr_api.get_bounds(zone) == "75.78,-75.78,-427.56,427.56" @@ -215,3 +189,70 @@ def test_check_info_string_mismatch(): def test_check_info_combined(): assert _check_info_flight.check_info(min_altitude=30000, max_altitude=40000, airline_icao="GLO") + + +# --- Entity.get_distance_from --- + +def test_entity_distance_sao_paulo_to_rio(): + gru = Entity(-23.4356, -46.4731) + gig = Entity(-22.8099, -43.2505) + assert 336 < gru.get_distance_from(gig) < 337 + + +def test_entity_distance_from_self_is_zero(): + e = Entity(0.0, 0.0) + assert e.get_distance_from(e) == pytest.approx(0.0, abs=1e-9) + + +def test_entity_distance_raises_when_no_position(): + e1 = Entity(None, None) + e2 = Entity(-23.0, -46.0) + with pytest.raises(ValueError): + e1.get_distance_from(e2) + + +# --- FlightTrackerConfig --- + +def test_flight_tracker_config_defaults(): + cfg = fr_api.get_flight_tracker_config() + assert cfg.limit == "5000" + assert cfg.faa == "1" + assert cfg.satellite == "1" + assert cfg.maxage == "14400" + + +def test_get_flight_tracker_config_returns_independent_copy(): + c1 = fr_api.get_flight_tracker_config() + c2 = fr_api.get_flight_tracker_config() + c1.limit = "999" + assert c2.limit != "999" + + +# --- Auth state guards --- + +def test_is_logged_in_false_by_default(): + assert FlightRadar24API().is_logged_in() is False + + +def test_get_login_data_raises_when_not_logged_in(): + with pytest.raises(LoginError): + FlightRadar24API().get_login_data() + + +def test_logout_returns_true_when_not_logged_in(): + assert FlightRadar24API().logout() is True + + +def test_get_history_data_raises_when_not_logged_in(): + with pytest.raises(LoginError): + FlightRadar24API().get_history_data(_check_info_flight, "CSV", 0) + + +def test_get_history_data_raises_for_invalid_file_type(): + api = FlightRadar24API() + api._FlightRadar24API__login_data = { + "userData": {"accessToken": "fake"}, + "cookies": {"_frPl": "fake"}, + } + with pytest.raises(ValueError): + api.get_history_data(_check_info_flight, "PDF", 0) diff --git a/python/tests/test_snapshots.py b/python/tests/test_snapshots.py index 2b6943f..0fbed1f 100644 --- a/python/tests/test_snapshots.py +++ b/python/tests/test_snapshots.py @@ -211,3 +211,29 @@ def test_get_country_flag_shape(): assert isinstance(result, tuple) and len(result) == 2 assert isinstance(result[0], bytes) assert isinstance(result[1], str) and len(result[1]) > 0 + + +@repeat_test(**repeat_test_config) +def test_get_most_tracked_shape(): + result = fr_api.get_most_tracked() + assert isinstance(result, dict) and result is not None + + +@repeat_test(**repeat_test_config) +def test_get_airport_disruptions_shape(): + result = fr_api.get_airport_disruptions() + assert isinstance(result, dict) and result is not None + + +@repeat_test(**repeat_test_config) +def test_get_volcanic_eruptions_shape(): + result = fr_api.get_volcanic_eruptions() + assert isinstance(result, dict) and result is not None + + +@repeat_test(**repeat_test_config) +def test_search_shape(): + result = fr_api.search("Guarulhos") + assert isinstance(result, dict) and result is not None + for value in result.values(): + assert isinstance(value, list) From 057efaa6385f761424fc594a2195868535542e16 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 11 May 2026 13:15:10 -0300 Subject: [PATCH 5/8] ci: avoid duplicate runs on PRs and retry flaky tests up to 3 times --- .github/workflows/node-package.yml | 9 ++++++++- .github/workflows/python-package.yml | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node-package.yml b/.github/workflows/node-package.yml index a01120f..6d11ebb 100644 --- a/.github/workflows/node-package.yml +++ b/.github/workflows/node-package.yml @@ -2,6 +2,8 @@ name: Node.js Package on: push: + branches: + - main paths: - 'nodejs/**' - '.github/workflows/node-package.yml' @@ -37,4 +39,9 @@ jobs: - name: Type check run: npm run test:types - name: Test package - run: npm test + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + working_directory: ./nodejs + command: npm test diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1fb1609..cf5b730 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -2,6 +2,8 @@ name: Python Package on: push: + branches: + - main paths: - 'python/**' - '.github/workflows/python-package.yml' @@ -36,7 +38,11 @@ jobs: - name: Type check run: cd python && python -m mypy FlightRadar24 --ignore-missing-imports - name: Test with pytest - run: cd python && pytest tests -vv -s + uses: nick-fields/retry@v3 + with: + timeout_minutes: 10 + max_attempts: 3 + command: cd python && pytest tests -vv -s - name: Build and verify install run: | python -m build ./python From 3aadab901e715a04ea657d3207fbb296591af500 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 11 May 2026 13:18:52 -0300 Subject: [PATCH 6/8] fix: use cd in retry command instead of unsupported working_directory input --- .github/workflows/node-package.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/node-package.yml b/.github/workflows/node-package.yml index 6d11ebb..f8421de 100644 --- a/.github/workflows/node-package.yml +++ b/.github/workflows/node-package.yml @@ -43,5 +43,4 @@ jobs: with: timeout_minutes: 10 max_attempts: 3 - working_directory: ./nodejs - command: npm test + command: cd nodejs && npm test From 0db80cd1b2c2e709b69eefde4dddbba42825b892 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 11 May 2026 13:22:17 -0300 Subject: [PATCH 7/8] config: increase default request timeout from 10s to 30s --- nodejs/FlightRadar24/api.js | 4 ++-- nodejs/FlightRadar24/request.js | 2 +- python/FlightRadar24/api.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/nodejs/FlightRadar24/api.js b/nodejs/FlightRadar24/api.js index 4aa3720..dacb73a 100644 --- a/nodejs/FlightRadar24/api.js +++ b/nodejs/FlightRadar24/api.js @@ -35,10 +35,10 @@ class FlightRadar24API { * Constructor of FlightRadar24API class * * @param {object} [options={}] - * @param {number} [options.timeout=10000] - Request timeout in milliseconds + * @param {number} [options.timeout=30000] - Request timeout in milliseconds * @param {number} [options.maxWorkers=8] - Maximum concurrent requests when fetching flight details */ - constructor({timeout = 10000, maxWorkers = 8} = {}) { + constructor({timeout = 30000, maxWorkers = 8} = {}) { this.__flightTrackerConfig = new FlightTrackerConfig(); this.__loginData = null; this.timeout = timeout; diff --git a/nodejs/FlightRadar24/request.js b/nodejs/FlightRadar24/request.js index ba9bb45..ce3adf4 100644 --- a/nodejs/FlightRadar24/request.js +++ b/nodejs/FlightRadar24/request.js @@ -41,7 +41,7 @@ const chromeAgent = new Agent({ }, }); -const DEFAULT_TIMEOUT_MS = 15_000; +const DEFAULT_TIMEOUT_MS = 30_000; /** * Make an HTTP request to the FlightRadar24 API. diff --git a/python/FlightRadar24/api.py b/python/FlightRadar24/api.py index aecaa7c..8191127 100644 --- a/python/FlightRadar24/api.py +++ b/python/FlightRadar24/api.py @@ -20,7 +20,7 @@ class FlightRadar24API: Main class of the FlightRadarAPI """ - def __init__(self, user: Optional[str] = None, password: Optional[str] = None, timeout: int = 10, max_workers: int = 8): + def __init__(self, user: Optional[str] = None, password: Optional[str] = None, timeout: int = 30, max_workers: int = 8): """ Constructor of the FlightRadar24API class. From cb5371eb76f9291dfb385dc1f43e034af3557177 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Mon, 11 May 2026 13:44:45 -0300 Subject: [PATCH 8/8] docs: update JSDoc default timeout comment to 30000ms --- nodejs/FlightRadar24/request.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nodejs/FlightRadar24/request.js b/nodejs/FlightRadar24/request.js index ce3adf4..271cf85 100644 --- a/nodejs/FlightRadar24/request.js +++ b/nodejs/FlightRadar24/request.js @@ -53,7 +53,7 @@ const DEFAULT_TIMEOUT_MS = 30_000; * @param {object} [options.data] - POST body fields (presence triggers POST method) * @param {object} [options.cookies] - Cookies to include in the request * @param {Array} [options.allowedErrorCodes=[]] - Status codes that should not throw - * @param {number} [options.timeout=15000] - Request timeout in milliseconds + * @param {number} [options.timeout=30000] - Request timeout in milliseconds * @return {Promise<{content: *, statusCode: number, cookies: object}>} */ async function request(url, {