FlightRadarAPI 1.6.2__tar.gz → 1.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/__init__.py +1 -1
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/api.py +84 -6
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/core.py +11 -3
- flightradarapi-1.7.0/FlightRadarAPI/parsers.py +242 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/PKG-INFO +1 -1
- flightradarapi-1.6.2/FlightRadarAPI/parsers.py +0 -239
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/.gitignore +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/entities/__init__.py +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/entities/airport.py +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/entities/entity.py +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/entities/flight.py +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/errors.py +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/flight_tracker_config.py +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/py.typed +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/request.py +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/zones.py +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/LICENSE +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/Makefile +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/README.md +0 -0
- {flightradarapi-1.6.2 → flightradarapi-1.7.0}/pyproject.toml +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# -*- coding: utf-8 -*-
|
|
2
2
|
|
|
3
3
|
import dataclasses
|
|
4
|
+
import logging
|
|
4
5
|
import math
|
|
5
6
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
7
|
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
|
@@ -11,9 +12,11 @@ from .entities.airport import Airport
|
|
|
11
12
|
from .entities.flight import Flight
|
|
12
13
|
from .errors import AirportNotFoundError, LoginError
|
|
13
14
|
from .flight_tracker_config import FlightTrackerConfig
|
|
14
|
-
from .parsers import country_to_slug, parse_airlines_html,
|
|
15
|
+
from .parsers import country_to_slug, parse_airlines_html, parse_airports_index, parse_airports_page, parse_inertia_page
|
|
15
16
|
from .request import APIClient, RetryPolicy
|
|
16
17
|
|
|
18
|
+
_logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
17
20
|
# Some FR24 live-feed backends answer 200 with a well-formed envelope but no
|
|
18
21
|
# flight entries -- indistinguishable from a legitimately empty result. The
|
|
19
22
|
# "AWSALB" cookie then pins the session to that backend, so dropping it is what
|
|
@@ -193,6 +196,12 @@ class FlightRadar24API:
|
|
|
193
196
|
"""
|
|
194
197
|
Return a list with all airports, optionally narrowed to some countries.
|
|
195
198
|
|
|
199
|
+
FR24 lists airports one page per country, and one per state for the
|
|
200
|
+
largest countries, so this fetches one page per country asked for (a few
|
|
201
|
+
hundred for every airport), `max_workers` at a time. The listing carries
|
|
202
|
+
the name, codes and country only: use get_airport(code) for the position
|
|
203
|
+
and altitude of an airport.
|
|
204
|
+
|
|
196
205
|
:param countries: Country names from the Countries enum, or their slug strings,
|
|
197
206
|
as any iterable or a single value. Every country when omitted.
|
|
198
207
|
"""
|
|
@@ -207,13 +216,82 @@ class FlightRadar24API:
|
|
|
207
216
|
if wanted is not None and len(wanted) == 0:
|
|
208
217
|
return []
|
|
209
218
|
|
|
210
|
-
|
|
211
|
-
|
|
219
|
+
# The index names every country, and declares the asset version the
|
|
220
|
+
# JSON requests below have to quote.
|
|
221
|
+
response = self.__client.request(Core.airports_data_url, headers=Core.html_headers, timeout=self.timeout)
|
|
222
|
+
index = parse_inertia_page(response.get_bytes_content())
|
|
223
|
+
version = index.get("version") if index is not None else None
|
|
224
|
+
|
|
225
|
+
if wanted is None:
|
|
226
|
+
slugs = [country["slug"] for country in parse_airports_index(index or {})]
|
|
227
|
+
else:
|
|
228
|
+
slugs = [country_to_slug(country) for country in wanted]
|
|
229
|
+
|
|
230
|
+
slugs = list(dict.fromkeys(slug for slug in slugs if slug))
|
|
231
|
+
version = version if isinstance(version, str) else None
|
|
232
|
+
|
|
233
|
+
country_pages = self.__get_airport_pages(
|
|
234
|
+
[Core.airports_by_country_url.format(slug) for slug in slugs], version,
|
|
212
235
|
)
|
|
213
236
|
|
|
214
|
-
#
|
|
215
|
-
|
|
216
|
-
|
|
237
|
+
# Second pass for the countries split by state, every state at once.
|
|
238
|
+
state_owners = [index for index, (_, paths) in enumerate(country_pages) for _ in paths]
|
|
239
|
+
state_pages = self.__get_airport_pages(
|
|
240
|
+
[
|
|
241
|
+
path if path.startswith("http") else Core.flightradar_base_url + path
|
|
242
|
+
for _, paths in country_pages for path in paths
|
|
243
|
+
],
|
|
244
|
+
version,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Grouped by country, in the order asked for, each country's states after it.
|
|
248
|
+
airports_by_country: List[List[Airport]] = [list(found) for found, _ in country_pages]
|
|
249
|
+
|
|
250
|
+
for owner, (found, _) in zip(state_owners, state_pages):
|
|
251
|
+
airports_by_country[owner].extend(found)
|
|
252
|
+
|
|
253
|
+
airports = [airport for found in airports_by_country for airport in found]
|
|
254
|
+
|
|
255
|
+
# A country FR24 does not know answers with the index page, which lists
|
|
256
|
+
# neither airports nor states.
|
|
257
|
+
missing = [slug for slug, (found, paths) in zip(slugs, country_pages) if not found and not paths]
|
|
258
|
+
|
|
259
|
+
if missing:
|
|
260
|
+
_logger.warning(
|
|
261
|
+
"get_airports: no airports found for %s — check the Countries enum.", ", ".join(missing),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return airports
|
|
265
|
+
|
|
266
|
+
def __get_airport_pages(self, urls: List[str], version: Optional[str]) -> List[Tuple[List[Airport], List[str]]]:
|
|
267
|
+
"""
|
|
268
|
+
Fetch and parse the airport listing pages at `urls`, in order, max_workers at a time.
|
|
269
|
+
"""
|
|
270
|
+
if not urls:
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
274
|
+
return list(executor.map(lambda url: parse_airports_page(self.__get_inertia_page(url, version)), urls))
|
|
275
|
+
|
|
276
|
+
def __get_inertia_page(self, url: str, version: Optional[str]) -> Union[Dict, bytes]:
|
|
277
|
+
"""
|
|
278
|
+
Fetch a FR24 data page as its Inertia page object, falling back to the HTML.
|
|
279
|
+
|
|
280
|
+
With the asset version FR24 answers the bare page JSON, a fraction of the
|
|
281
|
+
HTML. A 409 means the assets were redeployed since the version was read:
|
|
282
|
+
the HTML carries the same page object and needs no version.
|
|
283
|
+
"""
|
|
284
|
+
if version is not None:
|
|
285
|
+
headers = {**Core.inertia_headers, "x-inertia-version": version}
|
|
286
|
+
response = self.__client.request_standalone(
|
|
287
|
+
url, headers=headers, timeout=self.timeout, allowed_error_codes=[409],
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if response.get_status_code() != 409:
|
|
291
|
+
return response.get_content()
|
|
292
|
+
|
|
293
|
+
response = self.__client.request_standalone(url, headers=Core.html_headers, timeout=self.timeout)
|
|
294
|
+
return response.get_bytes_content()
|
|
217
295
|
|
|
218
296
|
def get_bookmarks(self) -> Dict:
|
|
219
297
|
"""
|
|
@@ -30,10 +30,12 @@ class Core:
|
|
|
30
30
|
# Airports data URLs.
|
|
31
31
|
api_airport_data_url = api_flightradar_base_url + "/airport.json"
|
|
32
32
|
airport_data_url = flightradar_base_url + "/airports/traffic-stats/?airport={}"
|
|
33
|
+
# The listing is split by country, and by state for the largest countries.
|
|
34
|
+
# These are Inertia pages: the data rides JSON-encoded in the data-page
|
|
35
|
+
# attribute of the HTML, or comes as the whole body when the request carries
|
|
36
|
+
# `inertia_headers` and the asset version the index page declares.
|
|
33
37
|
airports_data_url = flightradar_base_url + "/data/airports"
|
|
34
|
-
|
|
35
|
-
# Every airport in one payload; the per-country pages above are client-rendered.
|
|
36
|
-
airports_json_url = flightradar_base_url + "/_json/airports.php"
|
|
38
|
+
airports_by_country_url = airports_data_url + "/{}"
|
|
37
39
|
|
|
38
40
|
# Airlines data URL.
|
|
39
41
|
airlines_data_url = flightradar_base_url + "/data/airlines"
|
|
@@ -86,6 +88,12 @@ class Core:
|
|
|
86
88
|
image_headers = headers.copy()
|
|
87
89
|
image_headers["accept"] = "image/gif, image/jpg, image/jpeg, image/png"
|
|
88
90
|
|
|
91
|
+
# Asks an Inertia page for its page object instead of the HTML around it.
|
|
92
|
+
# `x-inertia-version` must match the deployed assets or FR24 answers 409.
|
|
93
|
+
inertia_headers = json_headers.copy()
|
|
94
|
+
inertia_headers["x-inertia"] = "true"
|
|
95
|
+
inertia_headers["x-requested-with"] = "XMLHttpRequest"
|
|
96
|
+
|
|
89
97
|
html_headers = {
|
|
90
98
|
"accept": (
|
|
91
99
|
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
9
|
+
|
|
10
|
+
from bs4 import BeautifulSoup
|
|
11
|
+
from bs4.element import Tag
|
|
12
|
+
|
|
13
|
+
from .entities.airport import Airport
|
|
14
|
+
|
|
15
|
+
_logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# An airline code badge: a two-letter IATA code or a three-letter ICAO code.
|
|
18
|
+
# ASCII only, so a stray Unicode letter does not pass for a code.
|
|
19
|
+
AIRLINE_CODE_PATTERN = re.compile(r"^[A-Z0-9]{2,3}$", re.ASCII)
|
|
20
|
+
|
|
21
|
+
# A fleet size as the page prints it: "340" or "3,878".
|
|
22
|
+
AIRCRAFT_COUNT_PATTERN = re.compile(r"^\d{1,3}(?:[,.]\d{3})*$|^\d+$", re.ASCII)
|
|
23
|
+
|
|
24
|
+
AIRLINES_PATH_PREFIX = "/data/airlines/"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_airline_link(href: object) -> bool:
|
|
28
|
+
return isinstance(href, str) and href.startswith(AIRLINES_PATH_PREFIX)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _leaf_texts(root: Tag, tag: str) -> List[str]:
|
|
32
|
+
"""
|
|
33
|
+
Text of the `tag` elements under `root` that hold no other element.
|
|
34
|
+
|
|
35
|
+
The name element is left out by its `title` attribute: an airline called
|
|
36
|
+
"TAP" would otherwise read as an ICAO code.
|
|
37
|
+
"""
|
|
38
|
+
return [
|
|
39
|
+
element.get_text(strip=True)
|
|
40
|
+
for element in root.find_all(tag)
|
|
41
|
+
if element.find() is None and not element.has_attr("title")
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _airline_name(link: Tag) -> str:
|
|
46
|
+
"""
|
|
47
|
+
The airline name of a listing entry.
|
|
48
|
+
|
|
49
|
+
The name sits in the element titled with it, with the logo's aria-label as a
|
|
50
|
+
fallback; the anchor text also carries the codes and the fleet size.
|
|
51
|
+
"""
|
|
52
|
+
for attribute in ("title", "aria-label"):
|
|
53
|
+
element = link.find(lambda tag, name=attribute: tag.has_attr(name))
|
|
54
|
+
|
|
55
|
+
if isinstance(element, Tag):
|
|
56
|
+
return str(element[attribute]).strip()
|
|
57
|
+
|
|
58
|
+
return link.get_text(strip=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_airlines_html(html: bytes) -> List[Dict]:
|
|
62
|
+
"""
|
|
63
|
+
Parse the airlines listing page into a list of airline dicts.
|
|
64
|
+
|
|
65
|
+
Every airline is a link to its data page, with the IATA and ICAO codes as
|
|
66
|
+
badges next to the name. The "Largest fleets" section repeats a few airlines
|
|
67
|
+
with their aircraft count; an airline the page lists twice becomes one entry
|
|
68
|
+
carrying everything the page said about it.
|
|
69
|
+
"""
|
|
70
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
71
|
+
links = soup.find_all("a", href=_is_airline_link)
|
|
72
|
+
|
|
73
|
+
if not links:
|
|
74
|
+
_logger.warning(
|
|
75
|
+
"parse_airlines_html: no airline links in response — FR24 page layout may have changed."
|
|
76
|
+
)
|
|
77
|
+
return []
|
|
78
|
+
|
|
79
|
+
airlines: Dict[str, Dict] = {}
|
|
80
|
+
|
|
81
|
+
for link in links:
|
|
82
|
+
name = _airline_name(link)
|
|
83
|
+
|
|
84
|
+
if len(name) < 2:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
codes = [text for text in _leaf_texts(link, "span") if AIRLINE_CODE_PATTERN.match(text)]
|
|
88
|
+
iata = next((code for code in codes if len(code) == 2), None)
|
|
89
|
+
icao = next((code for code in codes if len(code) == 3), None)
|
|
90
|
+
|
|
91
|
+
# Section links ("All") point under /data/airlines/ too; a code tells
|
|
92
|
+
# an airline apart from them.
|
|
93
|
+
if iata is None and icao is None:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
n_aircrafts = next(
|
|
97
|
+
(int(re.sub(r"\D", "", text)) for text in _leaf_texts(link, "strong") if AIRCRAFT_COUNT_PATTERN.match(text)),
|
|
98
|
+
None,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
airline = airlines.setdefault(
|
|
102
|
+
str(link["href"]), {"Name": name, "ICAO": None, "IATA": None, "n_aircrafts": None},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
for key, value in (("ICAO", icao), ("IATA", iata), ("n_aircrafts", n_aircrafts)):
|
|
106
|
+
if airline[key] is None:
|
|
107
|
+
airline[key] = value
|
|
108
|
+
|
|
109
|
+
return list(airlines.values())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def country_to_slug(country: object) -> str:
|
|
113
|
+
"""
|
|
114
|
+
Slugify a country name the way FR24 spells it in its data page URLs, so feed
|
|
115
|
+
rows can be matched against the Countries enum ("United States" -> "united-states").
|
|
116
|
+
"""
|
|
117
|
+
# `.value` unwraps a Countries member, which str() would render as
|
|
118
|
+
# "Countries.BRAZIL". Diacritics are stripped so a future "Curaçao" still
|
|
119
|
+
# matches "curacao"; `is None` rather than truthiness because str(0 or "") is
|
|
120
|
+
# "" here while String(0 ?? "") is "0" in Node.
|
|
121
|
+
if isinstance(country, Enum):
|
|
122
|
+
country = country.value
|
|
123
|
+
|
|
124
|
+
decomposed = unicodedata.normalize("NFKD", "" if country is None else str(country))
|
|
125
|
+
ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char))
|
|
126
|
+
|
|
127
|
+
# Punctuation becomes a hyphen rather than being deleted: FR24's own assets are
|
|
128
|
+
# named that way, e.g. flags-small/cote-d-ivoire.svg.
|
|
129
|
+
return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _to_text(value: object) -> str:
|
|
133
|
+
"""
|
|
134
|
+
Keep a text field as a string, or "" when the feed sends anything else.
|
|
135
|
+
|
|
136
|
+
Airport documents these as strings and callers treat them as such:
|
|
137
|
+
get_country_flag(airport.country) would slugify a dict into "object-object"
|
|
138
|
+
and request that flag.
|
|
139
|
+
"""
|
|
140
|
+
return value if isinstance(value, str) else ""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_inertia_page(payload: Union[bytes, str, Dict]) -> Optional[Dict]:
|
|
144
|
+
"""
|
|
145
|
+
Return the Inertia page object (component, props, version) of a FR24 data page.
|
|
146
|
+
|
|
147
|
+
Served as HTML, the page keeps it JSON-encoded in the data-page attribute of
|
|
148
|
+
its root element; asked with Core.inertia_headers, FR24 sends it as the body,
|
|
149
|
+
which `request.get_content()` has already decoded into a dict.
|
|
150
|
+
"""
|
|
151
|
+
if isinstance(payload, dict):
|
|
152
|
+
page: object = payload
|
|
153
|
+
else:
|
|
154
|
+
root = BeautifulSoup(payload, "html.parser").find(lambda tag: tag.has_attr("data-page"))
|
|
155
|
+
|
|
156
|
+
if not isinstance(root, Tag):
|
|
157
|
+
_logger.warning("parse_inertia_page: no data-page attribute in response — FR24 page layout may have changed.")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
page = json.loads(str(root["data-page"]))
|
|
162
|
+
except ValueError:
|
|
163
|
+
_logger.warning("parse_inertia_page: data-page is not valid JSON — FR24 page layout may have changed.")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
if not isinstance(page, dict) or not isinstance(page.get("props"), dict):
|
|
167
|
+
_logger.warning("parse_inertia_page: no page props in response — FR24 page layout may have changed.")
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
return page
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_airports_index(payload: Union[bytes, str, Dict]) -> List[Dict]:
|
|
174
|
+
"""
|
|
175
|
+
Parse the airports index (Core.airports_data_url) into its countries.
|
|
176
|
+
|
|
177
|
+
Each entry has the country "name" as FR24 prints it ("UNITED STATES") and the
|
|
178
|
+
"slug" of its listing page, which is also its Countries enum value.
|
|
179
|
+
"""
|
|
180
|
+
page = parse_inertia_page(payload)
|
|
181
|
+
rows = page["props"].get("airportsByCountry") if page is not None else None
|
|
182
|
+
|
|
183
|
+
if not isinstance(rows, list):
|
|
184
|
+
_logger.warning('parse_airports_index: no "airportsByCountry" list in response — FR24 page layout may have changed.')
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
countries = []
|
|
188
|
+
|
|
189
|
+
for row in rows:
|
|
190
|
+
if not isinstance(row, dict):
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
name = _to_text(row.get("name"))
|
|
194
|
+
slug = country_to_slug(name)
|
|
195
|
+
|
|
196
|
+
if slug:
|
|
197
|
+
countries.append({"name": name, "slug": slug})
|
|
198
|
+
|
|
199
|
+
return countries
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def parse_airports_page(payload: Union[bytes, str, Dict]) -> Tuple[List[Airport], List[str]]:
|
|
203
|
+
"""
|
|
204
|
+
Parse a country or state airports page into (airports, state_paths).
|
|
205
|
+
|
|
206
|
+
A country with many airports lists its states instead, each with the path of
|
|
207
|
+
its own page; those paths come back for the caller to fetch. The listing has
|
|
208
|
+
no coordinates or altitude: `get_airport(code)` has them for one airport.
|
|
209
|
+
"""
|
|
210
|
+
page = parse_inertia_page(payload)
|
|
211
|
+
|
|
212
|
+
if page is None:
|
|
213
|
+
return [], []
|
|
214
|
+
|
|
215
|
+
props = page["props"]
|
|
216
|
+
country = props.get("country")
|
|
217
|
+
country_name = _to_text(country.get("name")) if isinstance(country, dict) else ""
|
|
218
|
+
|
|
219
|
+
rows = props.get("airports")
|
|
220
|
+
states = props.get("states")
|
|
221
|
+
|
|
222
|
+
airports = [
|
|
223
|
+
Airport(basic_info={
|
|
224
|
+
"name": _to_text(row.get("name")),
|
|
225
|
+
"icao": _to_text(row.get("icao")),
|
|
226
|
+
"iata": _to_text(row.get("iata")),
|
|
227
|
+
"lat": None,
|
|
228
|
+
"lon": None,
|
|
229
|
+
"alt": None,
|
|
230
|
+
"country": country_name,
|
|
231
|
+
})
|
|
232
|
+
for row in (rows if isinstance(rows, list) else [])
|
|
233
|
+
if isinstance(row, dict)
|
|
234
|
+
]
|
|
235
|
+
|
|
236
|
+
state_paths = [
|
|
237
|
+
state["url"]
|
|
238
|
+
for state in (states if isinstance(states, list) else [])
|
|
239
|
+
if isinstance(state, dict) and isinstance(state.get("url"), str) and state["url"]
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
return airports, state_paths
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import logging
|
|
5
|
-
import math
|
|
6
|
-
import re
|
|
7
|
-
import unicodedata
|
|
8
|
-
from enum import Enum
|
|
9
|
-
from typing import Dict, Iterable, List, Optional, Union
|
|
10
|
-
|
|
11
|
-
from bs4 import BeautifulSoup
|
|
12
|
-
|
|
13
|
-
from .entities.airport import Airport
|
|
14
|
-
|
|
15
|
-
_logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
# ASCII only: \d would otherwise match Unicode digits such as "٤٣".
|
|
18
|
-
NUMERIC_PATTERN = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", re.ASCII)
|
|
19
|
-
|
|
20
|
-
# str.strip() and trim() disagree on U+001C-U+001F and U+FEFF: pick one set.
|
|
21
|
-
SURROUNDING_SPACE = " \t\n\r\f\v"
|
|
22
|
-
|
|
23
|
-
# Number.MAX_SAFE_INTEGER, the largest integer JavaScript holds exactly.
|
|
24
|
-
MAX_EXACT_INTEGER = 2 ** 53 - 1
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def parse_airlines_html(html: bytes) -> List[Dict]:
|
|
28
|
-
"""
|
|
29
|
-
Parse the airlines listing HTML page into a list of airline dicts.
|
|
30
|
-
"""
|
|
31
|
-
soup = BeautifulSoup(html, "html.parser")
|
|
32
|
-
tbody = soup.find("tbody")
|
|
33
|
-
|
|
34
|
-
if not tbody:
|
|
35
|
-
_logger.warning(
|
|
36
|
-
"parse_airlines_html: no <tbody> in response — FR24 page layout may have changed."
|
|
37
|
-
)
|
|
38
|
-
return []
|
|
39
|
-
|
|
40
|
-
airlines = []
|
|
41
|
-
|
|
42
|
-
for tr in tbody.find_all("tr"):
|
|
43
|
-
td_notranslate = tr.find("td", class_="notranslate")
|
|
44
|
-
|
|
45
|
-
if not td_notranslate:
|
|
46
|
-
continue
|
|
47
|
-
|
|
48
|
-
a_element = td_notranslate.find("a", href=lambda href: href and href.startswith("/data/airlines"))
|
|
49
|
-
|
|
50
|
-
if not a_element:
|
|
51
|
-
continue
|
|
52
|
-
|
|
53
|
-
airline_name = a_element.get_text(strip=True)
|
|
54
|
-
|
|
55
|
-
if len(airline_name) < 2:
|
|
56
|
-
continue
|
|
57
|
-
|
|
58
|
-
td_elements = tr.find_all("td")
|
|
59
|
-
iata = None
|
|
60
|
-
icao = None
|
|
61
|
-
|
|
62
|
-
if len(td_elements) >= 4:
|
|
63
|
-
codes_text = td_elements[3].get_text(strip=True)
|
|
64
|
-
|
|
65
|
-
if " / " in codes_text:
|
|
66
|
-
parts = codes_text.split(" / ")
|
|
67
|
-
if len(parts) == 2:
|
|
68
|
-
iata = parts[0].strip()
|
|
69
|
-
icao = parts[1].strip()
|
|
70
|
-
elif len(codes_text) == 2:
|
|
71
|
-
iata = codes_text
|
|
72
|
-
elif len(codes_text) == 3:
|
|
73
|
-
icao = codes_text
|
|
74
|
-
|
|
75
|
-
n_aircrafts = None
|
|
76
|
-
|
|
77
|
-
if len(td_elements) >= 5:
|
|
78
|
-
aircrafts_text = td_elements[4].get_text(strip=True)
|
|
79
|
-
if aircrafts_text:
|
|
80
|
-
n_aircrafts = int(aircrafts_text.split(" ", maxsplit=1)[0].strip())
|
|
81
|
-
|
|
82
|
-
airlines.append({"Name": airline_name, "ICAO": icao, "IATA": iata, "n_aircrafts": n_aircrafts})
|
|
83
|
-
|
|
84
|
-
return airlines
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def country_to_slug(country: object) -> str:
|
|
88
|
-
"""
|
|
89
|
-
Slugify a country name the way FR24 spells it in its data page URLs, so feed
|
|
90
|
-
rows can be matched against the Countries enum ("United States" -> "united-states").
|
|
91
|
-
"""
|
|
92
|
-
# `.value` unwraps a Countries member, which str() would render as
|
|
93
|
-
# "Countries.BRAZIL". Diacritics are stripped so a future "Curaçao" still
|
|
94
|
-
# matches "curacao"; `is None` rather than truthiness because str(0 or "") is
|
|
95
|
-
# "" here while String(0 ?? "") is "0" in Node.
|
|
96
|
-
if isinstance(country, Enum):
|
|
97
|
-
country = country.value
|
|
98
|
-
|
|
99
|
-
decomposed = unicodedata.normalize("NFKD", "" if country is None else str(country))
|
|
100
|
-
ascii_only = "".join(char for char in decomposed if not unicodedata.combining(char))
|
|
101
|
-
|
|
102
|
-
# Punctuation becomes a hyphen rather than being deleted: FR24's own assets are
|
|
103
|
-
# named that way, e.g. flags-small/cote-d-ivoire.svg.
|
|
104
|
-
return re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-")
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _to_text(value: object) -> str:
|
|
108
|
-
"""
|
|
109
|
-
Keep a text field as a string, or "" when the feed sends anything else.
|
|
110
|
-
|
|
111
|
-
Airport documents these as strings and callers treat them as such:
|
|
112
|
-
get_country_flag(airport.country) would slugify a dict into "object-object"
|
|
113
|
-
and request that flag.
|
|
114
|
-
"""
|
|
115
|
-
return value if isinstance(value, str) else ""
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _to_number(value: object) -> Optional[Union[int, float]]:
|
|
119
|
-
"""
|
|
120
|
-
Coerce a numeric feed field into a number, or None when it is unusable.
|
|
121
|
-
|
|
122
|
-
An unusable coordinate must not become 0.0: that would place the airport in
|
|
123
|
-
the Gulf of Guinea instead of marking its position as unknown.
|
|
124
|
-
|
|
125
|
-
Plain whole numbers stay ints, so an altitude reads 2436 in both ports rather
|
|
126
|
-
than 2436.0 here -- the feed sends `alt` as an int for most rows and as a
|
|
127
|
-
string ("-1") for the rest. Anything written with a decimal point or an
|
|
128
|
-
exponent stays a float: "2436.0" and "1e3" are floats here and integral
|
|
129
|
-
Numbers in Node, equal in value but not in type.
|
|
130
|
-
"""
|
|
131
|
-
# bool is an int here, and str([43]) would invent a number.
|
|
132
|
-
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
|
|
133
|
-
return None
|
|
134
|
-
|
|
135
|
-
if isinstance(value, str):
|
|
136
|
-
stripped = value.strip(SURROUNDING_SPACE)
|
|
137
|
-
|
|
138
|
-
if not NUMERIC_PATTERN.match(stripped):
|
|
139
|
-
return None
|
|
140
|
-
|
|
141
|
-
text: Union[int, float, str] = stripped
|
|
142
|
-
else:
|
|
143
|
-
text = value
|
|
144
|
-
|
|
145
|
-
# One conversion, so overflow is handled once: Python ints have no ceiling.
|
|
146
|
-
try:
|
|
147
|
-
number = float(text)
|
|
148
|
-
except OverflowError:
|
|
149
|
-
return None
|
|
150
|
-
|
|
151
|
-
if not math.isfinite(number):
|
|
152
|
-
return None
|
|
153
|
-
|
|
154
|
-
try:
|
|
155
|
-
exact = int(text)
|
|
156
|
-
except (ValueError, OverflowError):
|
|
157
|
-
return number
|
|
158
|
-
|
|
159
|
-
# int only when lossless (so -23.4 survives) and only where JavaScript is exact.
|
|
160
|
-
return exact if exact == number and abs(exact) <= MAX_EXACT_INTEGER else number
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def parse_airports_json(
|
|
164
|
-
payload: Union[bytes, str, Dict], countries: Optional[Iterable[object]] = None,
|
|
165
|
-
) -> List[Airport]:
|
|
166
|
-
"""
|
|
167
|
-
Parse the airports JSON feed into a list of Airport instances.
|
|
168
|
-
|
|
169
|
-
:param payload: Body of Core.airports_json_url.
|
|
170
|
-
:param countries: Country slugs, or Countries members. Every airport is kept when omitted.
|
|
171
|
-
"""
|
|
172
|
-
if isinstance(payload, (bytes, str)):
|
|
173
|
-
try:
|
|
174
|
-
data = json.loads(payload)
|
|
175
|
-
except ValueError:
|
|
176
|
-
_logger.warning("parse_airports_json: response is not valid JSON — FR24 feed may have changed.")
|
|
177
|
-
return []
|
|
178
|
-
else:
|
|
179
|
-
data = payload
|
|
180
|
-
|
|
181
|
-
rows = data.get("rows") if isinstance(data, dict) else None
|
|
182
|
-
|
|
183
|
-
if not isinstance(rows, list):
|
|
184
|
-
_logger.warning('parse_airports_json: no "rows" array in response — FR24 feed may have changed.')
|
|
185
|
-
return []
|
|
186
|
-
|
|
187
|
-
wanted = {country_to_slug(country) for country in countries} if countries is not None else None
|
|
188
|
-
matched = set()
|
|
189
|
-
unpositioned = []
|
|
190
|
-
airports = []
|
|
191
|
-
|
|
192
|
-
for row in rows:
|
|
193
|
-
if not isinstance(row, dict):
|
|
194
|
-
continue
|
|
195
|
-
|
|
196
|
-
# Slugified only when filtering: it is otherwise unused, and this loop
|
|
197
|
-
# runs over every airport in the feed.
|
|
198
|
-
if wanted is not None:
|
|
199
|
-
slug = country_to_slug(row.get("country"))
|
|
200
|
-
|
|
201
|
-
if slug not in wanted:
|
|
202
|
-
continue
|
|
203
|
-
matched.add(slug)
|
|
204
|
-
|
|
205
|
-
latitude = _to_number(row.get("lat"))
|
|
206
|
-
longitude = _to_number(row.get("lon"))
|
|
207
|
-
|
|
208
|
-
# One bad coordinate drops both: half a position reads as located.
|
|
209
|
-
if latitude is None or longitude is None:
|
|
210
|
-
latitude = longitude = None
|
|
211
|
-
unpositioned.append(_to_text(row.get("name")))
|
|
212
|
-
|
|
213
|
-
airports.append(Airport(basic_info={
|
|
214
|
-
"name": _to_text(row.get("name")),
|
|
215
|
-
"icao": _to_text(row.get("icao")),
|
|
216
|
-
"iata": _to_text(row.get("iata")),
|
|
217
|
-
"lat": latitude,
|
|
218
|
-
"lon": longitude,
|
|
219
|
-
"alt": _to_number(row.get("alt")),
|
|
220
|
-
"country": _to_text(row.get("country")),
|
|
221
|
-
}))
|
|
222
|
-
|
|
223
|
-
# One line, not one per row: the feed carries every airport.
|
|
224
|
-
if unpositioned:
|
|
225
|
-
_logger.warning(
|
|
226
|
-
"parse_airports_json: %d airport(s) had unusable coordinates and carry no position (e.g. %s).",
|
|
227
|
-
len(unpositioned), ", ".join(repr(name) for name in unpositioned[:3]),
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
if wanted is not None:
|
|
231
|
-
missing = sorted(wanted - matched)
|
|
232
|
-
|
|
233
|
-
if missing:
|
|
234
|
-
_logger.warning(
|
|
235
|
-
"parse_airports_json: no airports found for %s — check the Countries enum.",
|
|
236
|
-
", ".join(missing),
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
return airports
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|