diff --git a/python/FlightRadar24/api.py b/python/FlightRadar24/api.py index 79007d0..9f4f586 100644 --- a/python/FlightRadar24/api.py +++ b/python/FlightRadar24/api.py @@ -4,6 +4,7 @@ import dataclasses import math +import time from .core import Core from .entities.airport import Airport @@ -270,12 +271,65 @@ def get_country_flag(self, country: str) -> Optional[Tuple[bytes, str]]: return response.get_content(), flag_url.split(".")[-1] def get_flight_details(self, flight: Flight) -> Dict[Any, Any]: + """ + Return the flight details from FlightRadar24. + + Uses the flight-playback API (the old clickhandler endpoint now only returns + a session token). The response is normalised to match the legacy clickhandler + schema so all existing callers continue to work without changes: + - result.response.data.flight → top-level dict + - track [{timestamp,latitude,longitude,altitude.feet,speed.kts,heading}] + → trail [{ts,lat,lng,alt,hd,spd}] newest-first (same as before) + - aircraft.identification.modes → aircraft.hex + + :param flight: A Flight instance (only .id is used) + """ + url = Core.api_flightradar_base_url + "/flight-playback.json" + # timestamp must be >= the flight's last fix time for the full track to be returned; + # passing the current Unix time covers both live and recently-completed flights. + response = APIRequest(url, params={"flightId": flight.id, "timestamp": int(time.time())}, + headers=Core.json_headers, timeout=self.timeout) + content = response.get_content() + + try: + raw = content["result"]["response"]["data"]["flight"] + except (KeyError, TypeError): + return {} + + # Normalise track → trail, converting new field names to old ones. + # track is chronological (oldest-first); reverse so callers get newest-first + # as they did with the old clickhandler endpoint. + track = raw.get("track") or [] + trail = [] + for pt in reversed(track): + try: + trail.append({ + "ts": pt["timestamp"], + "lat": pt["latitude"], + "lng": pt["longitude"], + "alt": pt["altitude"]["feet"], + "hd": pt["heading"], + "spd": pt["speed"]["kts"], + }) + except (KeyError, TypeError): + continue + raw["trail"] = trail + + # Normalise aircraft.hex (was a top-level field, now nested under identification) + aircraft = raw.get("aircraft") or {} + raw["aircraft"] = aircraft + if "hex" not in aircraft: + aircraft["hex"] = (aircraft.get("identification") or {}).get("modes", "N/A") + + return raw + + def get_flight_playback(self, flight: Flight, ts) -> Dict[Any, Any]: """ Return the flight details from Data Live FlightRadar24. :param flight: A Flight instance """ - response = APIRequest(Core.flight_data_url.format(flight.id), headers=Core.json_headers, timeout=self.timeout) + response = APIRequest(Core.api_playback_data_url.format(flight.id, ts), headers=Core.json_headers, timeout=self.timeout) return response.get_content() def get_flights( @@ -285,7 +339,8 @@ def get_flights( registration: Optional[str] = None, aircraft_type: Optional[str] = None, *, - details: bool = False + details: bool = False, + flight_id: Optional[str] = None, ) -> List[Flight]: """ Return a list of flights. See more options at set_flight_tracker_config() method. @@ -306,6 +361,7 @@ def get_flights( if bounds: request_params["bounds"] = bounds.replace(",", "%2C") if registration: request_params["reg"] = registration if aircraft_type: request_params["type"] = aircraft_type + if flight_id: request_params["selected"] = flight_id # Get all flights from Data Live FlightRadar24. response = APIRequest(Core.real_time_flight_tracker_data_url, request_params, Core.json_headers, timeout=self.timeout) diff --git a/python/FlightRadar24/core.py b/python/FlightRadar24/core.py index fd3250f..014b197 100644 --- a/python/FlightRadar24/core.py +++ b/python/FlightRadar24/core.py @@ -25,6 +25,7 @@ class Core(ABC): # Historical data URL. historical_data_url = flightradar_base_url + "/download/?flight={}&file={}&trailLimit=0&history={}" + api_playback_data_url = api_flightradar_base_url + "/flight-playback.json?flightId={}×tamp={}" # Airports data URLs. api_airport_data_url = api_flightradar_base_url + "/airport.json" @@ -65,7 +66,7 @@ class Core(ABC): "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-site", - "user-agent": "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" } json_headers = headers.copy() diff --git a/python/FlightRadar24/request.py b/python/FlightRadar24/request.py index bb75d91..b2841f1 100644 --- a/python/FlightRadar24/request.py +++ b/python/FlightRadar24/request.py @@ -6,11 +6,23 @@ import json import gzip -import requests +import cloudscraper import requests.structures from .errors import CloudflareError +# Shared session so Cloudflare cookies are reused across requests. +# FR24-specific headers are set at session level; cloudscraper manages +# user-agent, accept-encoding, sec-fetch-* to keep the Cloudflare +# challenge fingerprint consistent. +_session = cloudscraper.create_scraper( + browser={"browser": "chrome", "platform": "windows", "mobile": False} +) +_session.headers.update({ + "origin": "https://www.flightradar24.com", + "referer": "https://www.flightradar24.com/", +}) + class APIRequest(object): """ @@ -52,10 +64,20 @@ def __init__( "cookies": cookies } - request_method = requests.get if data is None else requests.post + request_method = _session.get if data is None else _session.post + + # Only pass headers that don't conflict with cloudscraper's own fingerprint. + # Cloudflare flags accept:application/json (no text/html) as non-browser. + # user-agent, accept, accept-encoding, accept-language, sec-fetch-* are all + # managed by the session so the Cloudflare challenge fingerprint stays valid. + _SAFE_HEADERS = {"cache-control", "content-type"} + per_request_headers = { + k: v for k, v in (headers or {}).items() + if k.lower() in _SAFE_HEADERS + } or None if params: url += "?" + "&".join(["{}={}".format(k, v) for k, v in params.items()]) - self.__response = request_method(url, headers=headers, cookies=cookies, data=data, timeout=timeout) + self.__response = request_method(url, headers=per_request_headers, cookies=cookies, data=data, timeout=timeout) if self.get_status_code() == 520: raise CloudflareError( @@ -73,7 +95,7 @@ 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"] + content_type = self.__response.headers.get("Content-Type", "") # Try to decode the content. try: content = self.__content_encodings[content_encoding](content) diff --git a/python/pyproject.toml b/python/pyproject.toml index 4f4fc77..46b0af4 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -22,6 +22,7 @@ requires-python = ">=3.7" dependencies = [ "Brotli", "requests", + "cloudscraper", ] [tool.hatch.build.targets.wheel]