Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions python/FlightRadar24/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import dataclasses
import math
import time

from .core import Core
from .entities.airport import Airport
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion python/FlightRadar24/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={}&timestamp={}"

# Airports data URLs.
api_airport_data_url = api_flightradar_base_url + "/airport.json"
Expand Down Expand Up @@ -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()
Expand Down
30 changes: 26 additions & 4 deletions python/FlightRadar24/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ requires-python = ">=3.7"
dependencies = [
"Brotli",
"requests",
"cloudscraper",
]

[tool.hatch.build.targets.wheel]
Expand Down