diff --git a/nodejs/.eslintrc.json b/nodejs/.eslintrc.json index 122e7d1..c8d2bd8 100644 --- a/nodejs/.eslintrc.json +++ b/nodejs/.eslintrc.json @@ -14,6 +14,7 @@ "indent": ["warn", 4], "brace-style": ["warn", "stroustrup"], "valid-jsdoc": ["warn"], - "require-jsdoc": ["warn"] + "require-jsdoc": ["warn"], + "object-curly-spacing": ["warn", "always"] } } diff --git a/nodejs/FlightRadar24/api.js b/nodejs/FlightRadar24/api.js index dacb73a..c60bebb 100644 --- a/nodejs/FlightRadar24/api.js +++ b/nodejs/FlightRadar24/api.js @@ -1,11 +1,11 @@ const Core = require("./core"); -const {request} = require("./request"); +const { APIClient } = require("./request"); const Airport = require("./entities/airport"); const Flight = require("./entities/flight"); const FlightTrackerConfig = require("./flightTrackerConfig"); -const {AirportNotFoundError, LoginError} = require("./errors"); -const {isNumeric, radians, rad2deg} = require("./util"); -const {parseAirlinesHtml, parseAirportsHtml} = require("./parsers"); +const { AirportNotFoundError, LoginError } = require("./errors"); +const { isNumeric, radians, rad2deg } = require("./util"); +const { parseAirlinesHtml, parseAirportsHtml } = require("./parsers"); /** @@ -24,7 +24,7 @@ async function mapConcurrent(items, concurrency, fn) { await fn(items[i++]); } } - await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, worker)); + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)); } /** @@ -38,9 +38,10 @@ class FlightRadar24API { * @param {number} [options.timeout=30000] - Request timeout in milliseconds * @param {number} [options.maxWorkers=8] - Maximum concurrent requests when fetching flight details */ - constructor({timeout = 30000, maxWorkers = 8} = {}) { + constructor({ timeout = 30000, maxWorkers = 8 } = {}) { this.__flightTrackerConfig = new FlightTrackerConfig(); this.__loginData = null; + this.__client = new APIClient(); this.timeout = timeout; this.maxWorkers = maxWorkers; } @@ -51,7 +52,8 @@ class FlightRadar24API { * @return {Promise>} */ async getAirlines() { - const {content} = await request(Core.airlinesDataUrl, {headers: Core.htmlHeaders, timeout: this.timeout}); + const { content } = await this.__client.request( + Core.airlinesDataUrl, { headers: Core.htmlHeaders, timeout: this.timeout }); return parseAirlinesHtml(content); } @@ -70,7 +72,7 @@ class FlightRadar24API { const notFound = [403, 404]; const firstLogoUrl = Core.airlineLogoUrl(iata, icao); - let {content, statusCode} = await request(firstLogoUrl, { + let { content, statusCode } = await this.__client.request(firstLogoUrl, { headers: Core.imageHeaders, allowedErrorCodes: notFound, timeout: this.timeout, @@ -81,7 +83,7 @@ class FlightRadar24API { } const secondLogoUrl = Core.alternativeAirlineLogoUrl(icao); - ({content, statusCode} = await request(secondLogoUrl, { + ({ content, statusCode } = await this.__client.request(secondLogoUrl, { headers: Core.imageHeaders, allowedErrorCodes: notFound, timeout: this.timeout, @@ -112,7 +114,9 @@ class FlightRadar24API { return airport; } - const {content} = await request(Core.airportDataUrl(code), {headers: Core.jsonHeaders, timeout: this.timeout}); + const { content } = await this.__client.request( + Core.airportDataUrl(code), { headers: Core.jsonHeaders, timeout: this.timeout }, + ); const info = content["details"]; if (info === undefined) { @@ -134,13 +138,13 @@ class FlightRadar24API { throw new Error("The code '" + code + "' is invalid. It must be the IATA or ICAO of the airport."); } - const params = {"format": "json", "code": code, "limit": flightLimit, "page": page}; + const params = { "format": "json", "code": code, "limit": flightLimit, "page": page }; - if (this.__loginData !== null) { - params["token"] = this.__loginData["cookies"]["_frPl"]; + if (this.isLoggedIn()) { + params["token"] = this.__client.getCookie("_frPl"); } - const {content, statusCode} = await request(Core.apiAirportDataUrl, { + const { content, statusCode } = await this.__client.request(Core.apiAirportDataUrl, { params, headers: Core.jsonHeaders, allowedErrorCodes: [400], @@ -177,7 +181,9 @@ class FlightRadar24API { * @return {Promise} */ async getAirportDisruptions() { - const {content} = await request(Core.airportDisruptionsUrl, {headers: Core.jsonHeaders, timeout: this.timeout}); + const { content } = await this.__client.request( + Core.airportDisruptionsUrl, { headers: Core.jsonHeaders, timeout: this.timeout }, + ); return content; } @@ -191,7 +197,7 @@ class FlightRadar24API { const airports = []; await mapConcurrent(countries, this.maxWorkers, async (countryName) => { const countryHref = Core.airportsDataUrl + "/" + countryName; - const {content} = await request(countryHref, {headers: Core.htmlHeaders, timeout: this.timeout}); + const { content } = await this.__client.request(countryHref, { headers: Core.htmlHeaders, timeout: this.timeout }); airports.push(...parseAirportsHtml(content, countryHref)); }); return airports; @@ -207,10 +213,9 @@ class FlightRadar24API { throw new LoginError("You must log in to your account."); } - const headers = {...Core.jsonHeaders, "accesstoken": this.getLoginData()["accessToken"]}; - const {content} = await request(Core.bookmarksUrl, { + const headers = { ...Core.jsonHeaders, "accesstoken": this.getLoginData()["accessToken"] }; + const { content } = await this.__client.request(Core.bookmarksUrl, { headers, - cookies: this.__loginData["cookies"], timeout: this.timeout, }); @@ -290,10 +295,10 @@ class FlightRadar24API { async getCountryFlag(country) { const flagUrl = Core.countryFlagUrl(country.toLowerCase().replaceAll(" ", "-")); - const headers = {...Core.imageHeaders}; + const headers = { ...Core.imageHeaders }; delete headers["origin"]; - const {content, statusCode} = await request(flagUrl, { + const { content, statusCode } = await this.__client.request(flagUrl, { headers, allowedErrorCodes: [403, 404], timeout: this.timeout, @@ -313,7 +318,9 @@ class FlightRadar24API { * @return {Promise} */ async getFlightDetails(flight) { - const {content} = await request(Core.flightDataUrl(flight.id), {headers: Core.jsonHeaders, timeout: this.timeout}); + const { content } = await this.__client.request( + Core.flightDataUrl(flight.id), { headers: Core.jsonHeaders, timeout: this.timeout }, + ); return content; } @@ -328,17 +335,17 @@ class FlightRadar24API { * @return {Promise>} */ async getFlights(airline = null, bounds = null, registration = null, aircraftType = null, details = false) { - const params = {...this.__flightTrackerConfig}; + const params = { ...this.__flightTrackerConfig }; - if (this.__loginData !== null) { - params["enc"] = this.__loginData["cookies"]["_frPl"]; + if (this.isLoggedIn()) { + params["enc"] = this.__client.getCookie("_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; - const {content} = await request(Core.realTimeFlightTrackerDataUrl, { + const { content } = await this.__client.request(Core.realTimeFlightTrackerDataUrl, { params, headers: Core.jsonHeaders, timeout: this.timeout, @@ -371,7 +378,7 @@ class FlightRadar24API { * @return {FlightTrackerConfig} */ getFlightTrackerConfig() { - return new FlightTrackerConfig({...this.__flightTrackerConfig}); + return new FlightTrackerConfig({ ...this.__flightTrackerConfig }); } /** @@ -392,10 +399,9 @@ class FlightRadar24API { throw new Error("File type '" + fileType + "' is not supported. Only CSV and KML are supported."); } - const headers = {...Core.jsonHeaders, "accesstoken": this.getLoginData()["accessToken"]}; - const {content} = await request(Core.historicalDataUrl(flight.id, fileType, timestamp), { + const headers = { ...Core.jsonHeaders, "accesstoken": this.getLoginData()["accessToken"] }; + const { content } = await this.__client.request(Core.historicalDataUrl(flight.id, fileType, timestamp), { headers, - cookies: this.__loginData["cookies"], timeout: this.timeout, }); @@ -411,7 +417,7 @@ class FlightRadar24API { if (!this.isLoggedIn()) { throw new LoginError("You must log in to your account."); } - return {...this.__loginData["userData"]}; + return { ...this.__loginData["userData"] }; } /** @@ -420,7 +426,8 @@ class FlightRadar24API { * @return {Promise} */ async getMostTracked() { - const {content} = await request(Core.mostTrackedUrl, {headers: Core.jsonHeaders, timeout: this.timeout}); + const { content } = await this.__client.request( + Core.mostTrackedUrl, { headers: Core.jsonHeaders, timeout: this.timeout }); return content; } @@ -430,7 +437,9 @@ class FlightRadar24API { * @return {Promise} */ async getVolcanicEruptions() { - const {content} = await request(Core.volcanicEruptionDataUrl, {headers: Core.jsonHeaders, timeout: this.timeout}); + const { content } = await this.__client.request( + Core.volcanicEruptionDataUrl, { headers: Core.jsonHeaders, timeout: this.timeout }, + ); return content; } @@ -440,7 +449,7 @@ class FlightRadar24API { * @return {object} */ getZones() { - const zones = {...Core.staticZones}; + const zones = { ...Core.staticZones }; delete zones.version; return zones; } @@ -453,7 +462,9 @@ class FlightRadar24API { * @return {Promise} */ async search(query, limit = 50) { - const {content} = await request(Core.searchDataUrl(query, limit), {headers: Core.jsonHeaders, timeout: this.timeout}); + const { content } = await this.__client.request( + Core.searchDataUrl(query, limit), { headers: Core.jsonHeaders, timeout: this.timeout }, + ); const results = content["results"] ?? []; const countDict = content["stats"]?.["count"] ?? {}; @@ -491,9 +502,12 @@ class FlightRadar24API { * @return {Promise} */ async login(user, password) { - const {content, statusCode, cookies} = await request(Core.userLoginUrl, { + this.__loginData = null; + this.__client.clearCookies(); + + const { content, statusCode } = await this.__client.request(Core.userLoginUrl, { headers: Core.jsonHeaders, - data: {"email": user, "password": password, "remember": "true", "type": "web"}, + data: { "email": user, "password": password, "remember": "true", "type": "web" }, timeout: this.timeout, }); @@ -503,7 +517,7 @@ class FlightRadar24API { ); } - this.__loginData = {"userData": content["userData"], "cookies": cookies}; + this.__loginData = { "userData": content["userData"] }; } /** @@ -516,11 +530,17 @@ class FlightRadar24API { return true; } - const cookies = this.__loginData["cookies"]; this.__loginData = null; - const {statusCode} = await request(Core.userLogoutUrl, {headers: Core.jsonHeaders, cookies, timeout: this.timeout}); - return statusCode >= 200 && statusCode < 300; + try { + const { statusCode } = await this.__client.request( + Core.userLogoutUrl, { headers: Core.jsonHeaders, timeout: this.timeout }, + ); + return statusCode >= 200 && statusCode < 300; + } + finally { + this.__client.clearCookies(); + } } /** diff --git a/nodejs/FlightRadar24/core.js b/nodejs/FlightRadar24/core.js index 296a8d7..b9becc6 100644 --- a/nodejs/FlightRadar24/core.js +++ b/nodejs/FlightRadar24/core.js @@ -1,4 +1,4 @@ -const {staticZones} = require("./zones"); +const { staticZones } = require("./zones"); const FR24_BASE = "https://www.flightradar24.com"; const API_FR24_BASE = "https://api.flightradar24.com/common/v1"; @@ -68,8 +68,8 @@ const Core = { staticZones, headers: baseHeaders, - jsonHeaders: {accept: "application/json", ...baseHeaders}, - imageHeaders: {accept: "image/gif, image/jpg, image/jpeg, image/png", ...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", diff --git a/nodejs/FlightRadar24/entities/entity.js b/nodejs/FlightRadar24/entities/entity.js index 190634e..4524a99 100644 --- a/nodejs/FlightRadar24/entities/entity.js +++ b/nodejs/FlightRadar24/entities/entity.js @@ -1,4 +1,4 @@ -const {radians} = require("../util"); +const { radians } = require("../util"); const DEFAULT_TEXT = "N/A"; diff --git a/nodejs/FlightRadar24/entities/flight.js b/nodejs/FlightRadar24/entities/flight.js index 7f17cc6..61068a5 100644 --- a/nodejs/FlightRadar24/entities/flight.js +++ b/nodejs/FlightRadar24/entities/flight.js @@ -70,7 +70,7 @@ class Flight extends Entity { * @return {boolean} */ checkInfo(info) { - const comparisonFunctions = {"max": Math.max, "min": Math.min}; + const comparisonFunctions = { "max": Math.max, "min": Math.min }; for (let key in info) { if (!Object.prototype.hasOwnProperty.call(info, key)) { diff --git a/nodejs/FlightRadar24/errors.js b/nodejs/FlightRadar24/errors.js index 0989b1d..6d1c237 100644 --- a/nodejs/FlightRadar24/errors.js +++ b/nodejs/FlightRadar24/errors.js @@ -26,4 +26,4 @@ class CloudflareError extends FlightRadarError { /** Thrown when login fails or an authenticated endpoint is accessed without login. */ class LoginError extends FlightRadarError {} -module.exports = {FlightRadarError, AirportNotFoundError, CloudflareError, LoginError}; +module.exports = { FlightRadarError, AirportNotFoundError, CloudflareError, LoginError }; diff --git a/nodejs/FlightRadar24/flightTrackerConfig.js b/nodejs/FlightRadar24/flightTrackerConfig.js index 869266a..cbcb7fb 100644 --- a/nodejs/FlightRadar24/flightTrackerConfig.js +++ b/nodejs/FlightRadar24/flightTrackerConfig.js @@ -1,4 +1,4 @@ -const {isNumeric} = require("./util"); +const { isNumeric } = require("./util"); const proxyHandler = { diff --git a/nodejs/FlightRadar24/index.js b/nodejs/FlightRadar24/index.js index 4de8a5e..4caaf32 100644 --- a/nodejs/FlightRadar24/index.js +++ b/nodejs/FlightRadar24/index.js @@ -9,15 +9,15 @@ * https://www.flightradar24.com/terms-and-conditions */ -const {FlightRadarError, 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"); const Entity = require("./entities/entity"); const Flight = require("./entities/flight"); -const {Countries} = require("./core"); +const { Countries } = require("./core"); -const {version, author} = require("../package.json"); +const { version, author } = require("../package.json"); module.exports = { FlightRadar24API, diff --git a/nodejs/FlightRadar24/parsers.js b/nodejs/FlightRadar24/parsers.js index dd65c99..90c35dc 100644 --- a/nodejs/FlightRadar24/parsers.js +++ b/nodejs/FlightRadar24/parsers.js @@ -1,4 +1,4 @@ -const {JSDOM} = require("jsdom"); +const { JSDOM } = require("jsdom"); const Airport = require("./entities/airport"); /** @@ -69,7 +69,7 @@ function parseAirlinesHtml(html) { } } - airlines.push({"Name": airlineName, "ICAO": icao, "IATA": iata, "n_aircrafts": nAircrafts}); + airlines.push({ "Name": airlineName, "ICAO": icao, "IATA": iata, "n_aircrafts": nAircrafts }); } return airlines; @@ -152,4 +152,4 @@ function parseAirportsHtml(html, countryHref) { return airports; } -module.exports = {parseAirlinesHtml, parseAirportsHtml}; +module.exports = { parseAirlinesHtml, parseAirportsHtml }; diff --git a/nodejs/FlightRadar24/request.js b/nodejs/FlightRadar24/request.js index 271cf85..d7aaabe 100644 --- a/nodejs/FlightRadar24/request.js +++ b/nodejs/FlightRadar24/request.js @@ -1,5 +1,5 @@ -const {CloudflareError} = require("./errors"); -const {fetch, Agent} = require("undici"); +const { CloudflareError } = require("./errors"); +const { fetch, Agent } = require("undici"); // Chrome 136 TLS cipher suites to approximate its JA3 fingerprint const CHROME_CIPHERS = [ @@ -75,7 +75,7 @@ async function request(url, { } const method = data === null ? "GET" : "POST"; - const settings = {method, headers: requestHeaders, dispatcher: chromeAgent}; + const settings = { method, headers: requestHeaders, dispatcher: chromeAgent }; if (method === "POST") { const formData = new URLSearchParams(); @@ -136,7 +136,99 @@ async function request(url, { }); } - return {content, statusCode, cookies: responseCookies}; + return { content, statusCode, cookies: responseCookies }; } -module.exports = {request}; +/** + * HTTP session that automatically manages cookies across requests. + */ +class Session { + /** @constructor */ + constructor() { + this.__cookies = {}; + } + + /** + * Return the value of a stored cookie by name. + * + * @param {string} name + * @return {string|undefined} + */ + getCookie(name) { + return this.__cookies[name]; + } + + /** + * Clear all stored cookies. + */ + clearCookies() { + this.__cookies = {}; + } + + /** + * Make an HTTP request, automatically sending stored cookies and storing + * any cookies returned by the response. + * + * Accepts the same parameters as the module-level {@link request} function. + * + * @param {string} url + * @param {object} [options={}] + * @return {Promise<{content: *, statusCode: number, cookies: object}>} + */ + async request(url, options = {}) { + const { cookies: extraCookies, ...rest } = options; + const merged = { ...this.__cookies, ...(extraCookies ?? {}) }; + const cookies = Object.keys(merged).length > 0 ? merged : null; + + const result = await request(url, { ...rest, cookies }); + + if (result.cookies && Object.keys(result.cookies).length > 0) { + Object.assign(this.__cookies, result.cookies); + } + + return result; + } +} + +/** + * Central HTTP client for the FlightRadar24 package. + * + * Owns the persistent session (cookie jar, TLS fingerprint, future bypass logic) + * so that the rest of the codebase never has to deal with those concerns directly. + */ +class APIClient { + /** @constructor */ + constructor() { + this.__session = new Session(); + } + + /** + * Make a request through the shared session. + * + * @param {string} url + * @param {object} [options={}] + * @return {Promise<{content: *, statusCode: number, cookies: object}>} + */ + async request(url, options = {}) { + return this.__session.request(url, options); + } + + /** + * Return the value of a stored cookie by name. + * + * @param {string} name + * @return {string|undefined} + */ + getCookie(name) { + return this.__session.getCookie(name); + } + + /** + * Clear all cookies from the session. + */ + clearCookies() { + this.__session.clearCookies(); + } +} + +module.exports = { request, Session, APIClient }; diff --git a/nodejs/FlightRadar24/util.js b/nodejs/FlightRadar24/util.js index 50893ae..d4993f9 100644 --- a/nodejs/FlightRadar24/util.js +++ b/nodejs/FlightRadar24/util.js @@ -24,4 +24,4 @@ const radians = (x) => x * (Math.PI / 180); */ const rad2deg = (x) => x * (180 / Math.PI); -module.exports = {isNumeric, radians, rad2deg}; +module.exports = { isNumeric, radians, rad2deg }; diff --git a/nodejs/FlightRadar24/zones.js b/nodejs/FlightRadar24/zones.js index 35ca6d9..151054b 100644 --- a/nodejs/FlightRadar24/zones.js +++ b/nodejs/FlightRadar24/zones.js @@ -203,4 +203,4 @@ staticZones = { }, }; -module.exports = {staticZones}; +module.exports = { staticZones }; diff --git a/nodejs/tests/testApi.js b/nodejs/tests/testApi.js index f8832f2..0767c8b 100644 --- a/nodejs/tests/testApi.js +++ b/nodejs/tests/testApi.js @@ -1,4 +1,4 @@ -const {FlightRadar24API, Flight, Entity, FlightTrackerConfig, Countries, version} = require(".."); +const { FlightRadar24API, Flight, Entity, FlightTrackerConfig, Countries, version } = require(".."); const expect = require("chai").expect; @@ -188,7 +188,7 @@ describe("Testing FlightRadarAPI version " + version, function() { 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}; + 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"); }); }); @@ -207,13 +207,13 @@ describe("Testing FlightRadarAPI version " + version, function() { describe("setFlightTrackerConfig() — invalid key", function() { it("Throws when an unknown config key is set.", function() { - expect(() => frApi.setFlightTrackerConfig(null, {unknownKey: "1"})).to.throw(); + 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(); + expect(() => frApi.setFlightTrackerConfig(null, { limit: "not_a_number" })).to.throw(); }); }); @@ -226,31 +226,31 @@ describe("Testing FlightRadarAPI version " + version, function() { const flight = new Flight("123456789", info); it("Exact match returns true.", function() { - expect(flight.checkInfo({altitude: 35000})).to.be.true; + 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; + expect(flight.checkInfo({ minAltitude: 30000, maxAltitude: 40000 })).to.be.true; }); it("Exact mismatch returns false.", function() { - expect(flight.checkInfo({altitude: 40000})).to.be.false; + expect(flight.checkInfo({ altitude: 40000 })).to.be.false; }); it("Max exceeded returns false.", function() { - expect(flight.checkInfo({maxAltitude: 30000})).to.be.false; + expect(flight.checkInfo({ maxAltitude: 30000 })).to.be.false; }); it("String field match returns true.", function() { - expect(flight.checkInfo({airlineIcao: "GLO"})).to.be.true; + expect(flight.checkInfo({ airlineIcao: "GLO" })).to.be.true; }); it("String field mismatch returns false.", function() { - expect(flight.checkInfo({airlineIcao: "TAM"})).to.be.false; + 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; + expect(flight.checkInfo({ minAltitude: 30000, maxAltitude: 40000, airlineIcao: "GLO" })).to.be.true; }); }); @@ -334,7 +334,7 @@ describe("Testing FlightRadarAPI version " + version, function() { 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"}}; + 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); diff --git a/nodejs/tests/testSnapshots.js b/nodejs/tests/testSnapshots.js index f86949d..5fefe9e 100644 --- a/nodejs/tests/testSnapshots.js +++ b/nodejs/tests/testSnapshots.js @@ -1,4 +1,4 @@ -const {FlightRadar24API, Countries} = require(".."); +const { FlightRadar24API, Countries } = require(".."); const expect = require("chai").expect; @@ -102,7 +102,7 @@ const AIRPORT_DETAILS_SHAPE = { }, name: null, position: { - country: {name: null}, + country: { name: null }, latitude: null, longitude: null, }, diff --git a/python/FlightRadar24/api.py b/python/FlightRadar24/api.py index 8191127..144d21f 100644 --- a/python/FlightRadar24/api.py +++ b/python/FlightRadar24/api.py @@ -12,7 +12,7 @@ from .errors import AirportNotFoundError, LoginError from .flight_tracker_config import FlightTrackerConfig from .parsers import parse_airlines_html, parse_airports_html -from .request import APIRequest +from .request import APIClient class FlightRadar24API: @@ -31,6 +31,7 @@ def __init__(self, user: Optional[str] = None, password: Optional[str] = None, t """ self.__flight_tracker_config = FlightTrackerConfig() self.__login_data: Optional[Dict] = None + self.__client = APIClient() self.timeout: int = timeout self.max_workers: int = max_workers @@ -42,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) + response = self.__client.request(Core.airlines_data_url, headers=Core.html_headers, timeout=self.timeout) return parse_airlines_html(response.get_bytes_content()) def get_airline_logo(self, iata: str, icao: str) -> Optional[Tuple[bytes, str]]: @@ -54,7 +55,10 @@ 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, allowed_error_codes=[403, 404], timeout=self.timeout) + response = self.__client.request( + 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): @@ -63,7 +67,10 @@ 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, allowed_error_codes=[403, 404], timeout=self.timeout) + response = self.__client.request( + 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): @@ -86,7 +93,10 @@ def get_airport(self, code: str, *, details: bool = False) -> Airport: 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) + response = self.__client.request( + Core.airport_data_url.format(code), + headers=Core.json_headers, timeout=self.timeout, + ) content = response.get_json_content() if not content or not content.get("details"): @@ -107,8 +117,8 @@ def get_airport_details(self, code: str, flight_limit: int = 100, page: int = 1) request_params: Dict[str, Any] = {"format": "json"} - if self.__login_data is not None: - request_params["token"] = self.__login_data["cookies"]["_frPl"] + if self.is_logged_in(): + request_params["token"] = self.__client.get_cookie("_frPl") # Insert the method parameters into the dictionary for the request. request_params["code"] = code @@ -116,7 +126,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( + response = self.__client.request( Core.api_airport_data_url, params=request_params, headers=Core.json_headers, @@ -148,7 +158,10 @@ def get_airport_disruptions(self) -> Dict: """ Return airport disruptions. """ - response = APIRequest(Core.airport_disruptions_url, headers=Core.json_headers, timeout=self.timeout) + response = self.__client.request( + Core.airport_disruptions_url, + headers=Core.json_headers, timeout=self.timeout, + ) return response.get_json_content() def get_airports(self, countries: List[Countries]) -> List[Airport]: @@ -159,7 +172,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) + response = APIClient.request_standalone(href, headers=Core.html_headers, timeout=self.timeout) return parse_airports_html(response.get_bytes_content(), href) with ThreadPoolExecutor(max_workers=self.max_workers) as executor: @@ -176,9 +189,8 @@ def get_bookmarks(self) -> Dict: 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) + response = self.__client.request(Core.bookmarks_url, headers=headers, timeout=self.timeout) return response.get_json_content() def get_bounds(self, zone: Dict[str, float]) -> str: @@ -254,7 +266,10 @@ 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, allowed_error_codes=[403, 404], timeout=self.timeout) + response = self.__client.request( + flag_url, headers=headers, + allowed_error_codes=[403, 404], timeout=self.timeout, + ) status_code = response.get_status_code() if not (400 <= status_code < 500): @@ -268,7 +283,9 @@ 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) + response = APIClient.request_standalone( + Core.flight_data_url.format(flight.id), headers=Core.json_headers, timeout=self.timeout, + ) return response.get_json_content() def get_flights( @@ -291,8 +308,8 @@ def get_flights( """ request_params = dataclasses.asdict(self.__flight_tracker_config) - if self.__login_data is not None: - request_params["enc"] = self.__login_data["cookies"]["_frPl"] + if self.is_logged_in(): + request_params["enc"] = self.__client.get_cookie("_frPl") # Insert the method parameters into the dictionary for the request. if airline is not None: request_params["airline"] = airline @@ -301,7 +318,7 @@ def get_flights( if aircraft_type is not None: request_params["type"] = aircraft_type # Get all flights from Data Live FlightRadar24. - response = APIRequest( + response = self.__client.request( Core.real_time_flight_tracker_data_url, params=request_params, headers=Core.json_headers, @@ -352,10 +369,10 @@ def get_history_data(self, flight: Flight, file_type: str, timestamp: int) -> st headers = {**Core.json_headers, "accesstoken": self.get_login_data()["accessToken"]} - response = APIRequest( + response = self.__client.request( Core.historical_data_url.format(flight.id, file_type, timestamp), - headers=headers, cookies=self.__login_data["cookies"], - timeout=self.timeout + headers=headers, + timeout=self.timeout, ) return response.get_bytes_content().decode("utf-8") @@ -374,14 +391,17 @@ def get_most_tracked(self) -> Dict: """ Return the most tracked data. """ - response = APIRequest(Core.most_tracked_url, headers=Core.json_headers, timeout=self.timeout) + response = self.__client.request(Core.most_tracked_url, headers=Core.json_headers, timeout=self.timeout) 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) + response = self.__client.request( + Core.volcanic_eruption_data_url, + headers=Core.json_headers, timeout=self.timeout, + ) return response.get_json_content() def get_zones(self) -> Dict[str, Any]: @@ -396,7 +416,10 @@ 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) + response = self.__client.request( + Core.search_data_url.format(quote(query), limit), + headers=Core.json_headers, timeout=self.timeout, + ) content = response.get_json_content() results = content.get("results", []) stats = content.get("stats", {}) @@ -421,6 +444,9 @@ def login(self, user: str, password: str) -> None: :param user: Your email. :param password: Your password. """ + self.__login_data = None + self.__client.clear_cookies() + data = { "email": user, "password": password, @@ -428,7 +454,10 @@ def login(self, user: str, password: str) -> None: "type": "web" } - response = APIRequest(Core.user_login_url, headers=Core.json_headers, data=data, timeout=self.timeout) + response = self.__client.request( + Core.user_login_url, + headers=Core.json_headers, data=data, timeout=self.timeout, + ) status_code = response.get_status_code() content = response.get_json_content() @@ -437,7 +466,6 @@ def login(self, user: str, password: str) -> None: self.__login_data = { "userData": content["userData"], - "cookies": response.get_cookies(), } def logout(self) -> bool: @@ -449,11 +477,12 @@ def logout(self) -> bool: if self.__login_data is None: return True - cookies = self.__login_data["cookies"] self.__login_data = None - - response = APIRequest(Core.user_logout_url, headers=Core.json_headers, cookies=cookies, timeout=self.timeout) - return 200 <= response.get_status_code() < 300 + try: + response = self.__client.request(Core.user_logout_url, headers=Core.json_headers, timeout=self.timeout) + return 200 <= response.get_status_code() < 300 + finally: + self.__client.clear_cookies() def set_flight_tracker_config( self, diff --git a/python/FlightRadar24/request.py b/python/FlightRadar24/request.py index 67de1a7..948b84b 100644 --- a/python/FlightRadar24/request.py +++ b/python/FlightRadar24/request.py @@ -7,12 +7,42 @@ import brotli from curl_cffi import requests +from curl_cffi.requests import Session from .errors import CloudflareError _IMPERSONATE = "chrome136" +class APIClient: + """ + Central HTTP client for the FlightRadar24 package. + + Owns the persistent session (cookie jar, TLS fingerprint, future bypass logic) + so that the rest of the codebase never has to deal with those concerns directly. + """ + + def __init__(self) -> None: + self.__session: Session = Session(impersonate=_IMPERSONATE) # type: ignore[arg-type] + + def request(self, url: str, **kwargs) -> "APIRequest": + """Make a request through the shared session.""" + return APIRequest(url, session=self.__session, **kwargs) + + @staticmethod + def request_standalone(url: str, **kwargs) -> "APIRequest": + """Make a stateless request with no shared session (safe to call from threads).""" + return APIRequest(url, **kwargs) + + def get_cookie(self, name: str) -> Optional[str]: + """Return the value of a stored cookie by name.""" + return self.__session.cookies.get(name) + + def clear_cookies(self) -> None: + """Clear all cookies from the session.""" + self.__session.cookies.clear() + + class APIRequest: """ Class to make requests to the FlightRadar24. @@ -27,32 +57,36 @@ def __init__( self, url: str, *, + session: Optional[Session] = None, params: Optional[Dict] = None, headers: Optional[Dict] = None, timeout: int = 30, data: Optional[Dict] = None, - cookies: Optional[Dict] = None, allowed_error_codes: Optional[List[int]] = None ): """ Constructor of the APIRequest class. :param url: URL for the request + :param session: session to reuse across requests; handles cookies automatically :param params: params that will be inserted on the URL for the request :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 allowed_error_codes: status codes that should not raise an error """ self.url = url - request_method = requests.get if data is None else requests.post - if params: url += "?" + urlencode(params) - self.__response = request_method( - url, headers=headers, cookies=cookies, data=data, timeout=timeout, - impersonate=_IMPERSONATE # type: ignore[arg-type] - ) + + if session is not None: + request_method = session.get if data is None else session.post + self.__response = request_method(url, headers=headers, data=data, timeout=timeout) + else: + request_method = requests.get if data is None else requests.post + self.__response = request_method( + url, headers=headers, data=data, timeout=timeout, + impersonate=_IMPERSONATE # type: ignore[arg-type] + ) if self.get_status_code() == 520: raise CloudflareError( @@ -104,12 +138,6 @@ def get_bytes_content(self) -> 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. - """ - return self.__response.cookies.get_dict() - def get_headers(self) -> Any: """ Return the headers of the response.