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
2 changes: 1 addition & 1 deletion python/FlightRadar24/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"""

__author__ = "Jean Loui Bernard Silva de Jesus"
__version__ = "1.3.33"
__version__ = "1.3.34"

from .api import FlightRadar24API, FlightTrackerConfig
from .entities import Airport, Entity, Flight
106 changes: 91 additions & 15 deletions python/FlightRadar24/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

import dataclasses
import math
import time

from .core import Core
from .entities.airport import Airport
from .entities.flight import Flight
from .errors import AirportNotFoundError, LoginError
from .request import APIRequest
from .request import APIRequest, reset_connections


@dataclasses.dataclass
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,10 @@ def get_flights(
registration: Optional[str] = None,
aircraft_type: Optional[str] = None,
*,
details: bool = False
details: bool = False,
flight_id: Optional[str] = None,
max_retries: int = 3,
retry_delay: float = 0.5,
) -> List[Flight]:
"""
Return a list of flights. See more options at set_flight_tracker_config() method.
Expand All @@ -295,6 +352,8 @@ def get_flights(
:param registration: Aircraft registration
:param aircraft_type: Aircraft model code. Ex: "B737"
:param details: If True, it returns flights with detailed information
:param max_retries: Attempts made when the feed returns no flights (see below)
:param retry_delay: Seconds to wait between such attempts
"""
request_params = dataclasses.asdict(self.__flight_tracker_config)

Expand All @@ -306,24 +365,41 @@ 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

# 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()
if flight_id: request_params["selected"] = flight_id

flights: List[Flight] = list()

for flight_id, flight_info in response.items():
# The feed endpoint is load-balanced and some backend nodes intermittently
# answer HTTP 200 with a stats-only body ("visible" counts all zero, no
# flight entries) even though flights exist in the requested area. Such a
# response is indistinguishable from a genuinely empty region, so an empty
# result is retried a few times. Keep-alive connections stick to the same
# backend node, so the pooled connection is dropped before each retry to
# get routed to a different (healthy) node.
for attempt in range(max(1, max_retries)):
if attempt > 0:
reset_connections()
time.sleep(retry_delay)

# Get flights only.
if not flight_id[0].isnumeric():
continue
# Get all flights from Data Live FlightRadar24.
response = APIRequest(Core.real_time_flight_tracker_data_url, request_params, Core.json_headers, timeout=self.timeout)
content = response.get_content()

flight = Flight(flight_id, flight_info)
flights.append(flight)
for fid, flight_info in content.items():

# Set flight details.
if details:
# Get flights only.
if not fid[0].isnumeric():
continue

flight = Flight(fid, flight_info)
flights.append(flight)

if flights:
break

# Set flight details.
if details:
for flight in flights:
flight_details = self.get_flight_details(flight)
flight.set_flight_details(flight_details)

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
73 changes: 60 additions & 13 deletions python/FlightRadar24/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,49 @@
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/",
})


def reset_connections() -> None:
"""
Drop the session's pooled keep-alive connections and its AWS load
balancer affinity cookies, so the next request gets routed to a
different backend node.

FR24's endpoints sit behind an AWS ALB with cookie-based session
stickiness (AWSALB/AWSALBCORS): once a request lands on a broken
backend node (e.g. feed.js answering a stats-only body with zero
flights), the affinity cookie pins every later request to that same
node. Only these cookies are removed — others (e.g. Cloudflare
clearance) must survive.
"""
_session.close()

jar = _session.cookies
for cookie in list(jar):
if cookie.name.startswith("AWSALB"):
jar.clear(cookie.domain, cookie.path, cookie.name)


class APIRequest(object):
"""
Class to make requests to the FlightRadar24.
"""
__content_encodings = {
"": lambda x: x,
"br": brotli.decompress,
"gzip": gzip.decompress
}

def __init__(
self,
Expand Down Expand Up @@ -52,10 +80,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,11 +111,20 @@ 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", "")

# The transport (requests/urllib3) normally decompresses gzip/brotli bodies
# transparently, in which case the payload is already plain and must not be
# decoded again. Only decode when the body still looks compressed: gzip is
# identified by its magic number; brotli has no magic number, so it is tried
# and silently skipped if the data was already decompressed by the transport.
if content_encoding == "gzip" and content[:2] == b"\x1f\x8b":
content = gzip.decompress(content)
elif content_encoding == "br":
try:
content = brotli.decompress(content)
except brotli.error:
pass

# Return a dictionary if the content type is JSON.
if "application/json" in content_type:
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
Loading