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.
Files changed (20) hide show
  1. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/__init__.py +1 -1
  2. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/api.py +84 -6
  3. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/core.py +11 -3
  4. flightradarapi-1.7.0/FlightRadarAPI/parsers.py +242 -0
  5. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/PKG-INFO +1 -1
  6. flightradarapi-1.6.2/FlightRadarAPI/parsers.py +0 -239
  7. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/.gitignore +0 -0
  8. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/entities/__init__.py +0 -0
  9. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/entities/airport.py +0 -0
  10. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/entities/entity.py +0 -0
  11. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/entities/flight.py +0 -0
  12. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/errors.py +0 -0
  13. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/flight_tracker_config.py +0 -0
  14. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/py.typed +0 -0
  15. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/request.py +0 -0
  16. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/FlightRadarAPI/zones.py +0 -0
  17. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/LICENSE +0 -0
  18. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/Makefile +0 -0
  19. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/README.md +0 -0
  20. {flightradarapi-1.6.2 → flightradarapi-1.7.0}/pyproject.toml +0 -0
@@ -12,7 +12,7 @@ https://www.flightradar24.com/terms-and-conditions
12
12
  """
13
13
 
14
14
  __author__ = "Jean Loui Bernard Silva de Jesus"
15
- __version__ = "1.6.2"
15
+ __version__ = "1.7.0"
16
16
 
17
17
  from .api import FlightRadar24API
18
18
  from .core import Countries
@@ -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, parse_airports_json
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
- response = self.__client.request(
211
- Core.airports_json_url, headers=Core.json_headers, timeout=self.timeout,
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
- # get_content(), not get_json_content(): an html body reaches the parser's
215
- # guard; a malformed JSON body still raises. The parser slugifies `wanted`.
216
- return parse_airports_json(response.get_content(), wanted)
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,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: FlightRadarAPI
3
- Version: 1.6.2
3
+ Version: 1.7.0
4
4
  Summary: SDK for FlightRadar24
5
5
  Project-URL: Homepage, https://github.com/JeanExtreme002/FlightRadarAPI
6
6
  Project-URL: Source Code, https://github.com/JeanExtreme002/FlightRadarAPI
@@ -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