FlightRadarAPI 1.5.1__tar.gz → 1.5.3__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 (22) hide show
  1. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/.gitignore +5 -0
  2. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/__init__.py +1 -1
  3. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/api.py +58 -27
  4. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/core.py +3 -0
  5. flightradarapi-1.5.3/FlightRadarAPI/parsers.py +239 -0
  6. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/request.py +28 -1
  7. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/PKG-INFO +3 -2
  8. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/README.md +1 -0
  9. flightradarapi-1.5.1/.tool-versions +0 -1
  10. flightradarapi-1.5.1/FlightRadarAPI/parsers.py +0 -143
  11. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadar24/__init__.py +0 -0
  12. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/entities/__init__.py +0 -0
  13. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/entities/airport.py +0 -0
  14. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/entities/entity.py +0 -0
  15. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/entities/flight.py +0 -0
  16. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/errors.py +0 -0
  17. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/flight_tracker_config.py +0 -0
  18. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/py.typed +0 -0
  19. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/FlightRadarAPI/zones.py +0 -0
  20. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/LICENSE +0 -0
  21. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/Makefile +0 -0
  22. {flightradarapi-1.5.1 → flightradarapi-1.5.3}/pyproject.toml +0 -0
@@ -19,6 +19,11 @@ coverage.xml
19
19
  # Node.js
20
20
  node_modules/
21
21
 
22
+ # Version managers (local dev only)
23
+ .tool-versions
24
+ .python-version
25
+ .nvmrc
26
+
22
27
  # Editors / OS
23
28
  .idea/
24
29
  .vscode/
@@ -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.5.1"
15
+ __version__ = "1.5.3"
16
16
 
17
17
  from .api import FlightRadar24API
18
18
  from .core import Countries
@@ -3,7 +3,7 @@
3
3
  import dataclasses
4
4
  import math
5
5
  from concurrent.futures import ThreadPoolExecutor, as_completed
6
- from typing import Any, Dict, List, Optional, Tuple, Union
6
+ from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
7
7
  from urllib.parse import quote
8
8
 
9
9
  from .core import Core, Countries
@@ -11,9 +11,16 @@ from .entities.airport import Airport
11
11
  from .entities.flight import Flight
12
12
  from .errors import AirportNotFoundError, LoginError
13
13
  from .flight_tracker_config import FlightTrackerConfig
14
- from .parsers import parse_airlines_html, parse_airports_html
14
+ from .parsers import country_to_slug, parse_airlines_html, parse_airports_json
15
15
  from .request import APIClient, RetryPolicy
16
16
 
17
+ # Some FR24 live-feed backends answer 200 with a well-formed envelope but no
18
+ # flight entries -- indistinguishable from a legitimately empty result. The
19
+ # "AWSALB" cookie then pins the session to that backend, so dropping it is what
20
+ # makes the load balancer re-roll on retry.
21
+ FEED_STICKY_COOKIES = ("AWSALB", "AWSALBCORS")
22
+ FEED_EMPTY_RETRIES = 4
23
+
17
24
 
18
25
  class FlightRadar24API:
19
26
  """
@@ -180,21 +187,33 @@ class FlightRadar24API:
180
187
  )
181
188
  return response.get_json_content()
182
189
 
183
- def get_airports(self, countries: List[Countries]) -> List[Airport]:
190
+ def get_airports(
191
+ self, countries: Optional[Union[Iterable[Union[Countries, str]], Countries, str]] = None,
192
+ ) -> List[Airport]:
184
193
  """
185
- Return a list with all airports for specified countries.
194
+ Return a list with all airports, optionally narrowed to some countries.
186
195
 
187
- :param countries: List of country names from Countries enum.
196
+ :param countries: Country names from the Countries enum, or their slug strings,
197
+ as any iterable or a single value. Every country when omitted.
188
198
  """
189
- def _fetch(country):
190
- href = Core.airports_data_url + "/" + country.value
191
- response = self.__client.request_standalone(href, headers=Core.html_headers, timeout=self.timeout)
192
- return parse_airports_html(response.get_bytes_content(), href)
199
+ wanted: Optional[List[Union[Countries, str]]] = None
200
+
201
+ if isinstance(countries, (Countries, str)):
202
+ wanted = [countries]
203
+ elif countries is not None:
204
+ # Materialised: a generator has no len() and is consumed once.
205
+ wanted = list(countries)
193
206
 
194
- with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
195
- results = executor.map(_fetch, countries)
207
+ if wanted is not None and len(wanted) == 0:
208
+ return []
209
+
210
+ response = self.__client.request(
211
+ Core.airports_json_url, headers=Core.json_headers, timeout=self.timeout,
212
+ )
196
213
 
197
- return [airport for result in results for airport in result]
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)
198
217
 
199
218
  def get_bookmarks(self) -> Dict:
200
219
  """
@@ -285,7 +304,12 @@ class FlightRadar24API:
285
304
 
286
305
  :param country: Country name
287
306
  """
288
- slug = country.lower().replace(" ", "-")
307
+ # Same slugifier as the feed, which spells some names "Myanmar (Burma)".
308
+ slug = country_to_slug(country)
309
+
310
+ if not slug:
311
+ return None
312
+
289
313
  flag_url = Core.country_flag_url.format(slug)
290
314
  headers = Core.image_headers.copy()
291
315
 
@@ -342,24 +366,31 @@ class FlightRadar24API:
342
366
  if registration is not None: request_params["reg"] = registration
343
367
  if aircraft_type is not None: request_params["type"] = aircraft_type
344
368
 
345
- # Get all flights from Data Live FlightRadar24.
346
- response = self.__client.request(
347
- Core.real_time_flight_tracker_data_url,
348
- params=request_params,
349
- headers=Core.json_headers,
350
- timeout=self.timeout,
351
- )
352
- content = response.get_json_content()
353
-
354
369
  flights: List[Flight] = list()
355
370
 
356
- for flight_id, flight_info in content.items():
371
+ for _ in range(FEED_EMPTY_RETRIES + 1):
372
+ # Get all flights from Data Live FlightRadar24.
373
+ response = self.__client.request(
374
+ Core.real_time_flight_tracker_data_url,
375
+ params=request_params,
376
+ headers=Core.json_headers,
377
+ timeout=self.timeout,
378
+ )
379
+ content = response.get_json_content()
357
380
 
358
381
  # Get flights only.
359
- if not flight_id[0].isnumeric():
360
- continue
361
-
362
- flights.append(Flight(flight_id, flight_info))
382
+ flights = [
383
+ Flight(flight_id, flight_info)
384
+ for flight_id, flight_info in content.items()
385
+ if flight_id[0].isnumeric()
386
+ ]
387
+
388
+ # "full_count": 0 means the feed really has nothing to report.
389
+ if flights or not content.get("full_count"):
390
+ break
391
+
392
+ for cookie_name in FEED_STICKY_COOKIES:
393
+ self.__client.delete_cookie(cookie_name)
363
394
 
364
395
  if details:
365
396
  with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
@@ -32,6 +32,9 @@ class Core:
32
32
  airport_data_url = flightradar_base_url + "/airports/traffic-stats/?airport={}"
33
33
  airports_data_url = flightradar_base_url + "/data/airports"
34
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"
37
+
35
38
  # Airlines data URL.
36
39
  airlines_data_url = flightradar_base_url + "/data/airlines"
37
40
 
@@ -0,0 +1,239 @@
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
@@ -122,6 +122,17 @@ class APIClient:
122
122
  """Clear all cookies from the session."""
123
123
  self.__session.cookies.clear()
124
124
 
125
+ def delete_cookie(self, name: str) -> None:
126
+ """Drop a single cookie, leaving the rest of the jar intact.
127
+
128
+ Sheds load-balancer stickiness without discarding the login session,
129
+ which lives in the same jar.
130
+ """
131
+ try:
132
+ del self.__session.cookies[name]
133
+ except KeyError:
134
+ pass
135
+
125
136
 
126
137
  class APIRequest:
127
138
  """
@@ -222,7 +233,23 @@ class APIRequest:
222
233
  try:
223
234
  content = decode(content)
224
235
  except Exception as err:
225
- _logger.warning(
236
+ # Decided by the body, not the header: undecodable text is genuinely
237
+ # broken and must warn, while binary bodies carry no such tell. Nothing
238
+ # here may raise, or it would replace `err` with its own failure.
239
+ if not isinstance(content, bytes):
240
+ transport_decoded = True
241
+ elif content_type.startswith(("application/json", "text/")):
242
+ try:
243
+ content.decode("utf-8")
244
+ transport_decoded = True
245
+ except UnicodeDecodeError:
246
+ transport_decoded = False
247
+ else:
248
+ corrupt_gzip = content_encoding == "gzip" and content.startswith(b"\x1f\x8b")
249
+ transport_decoded = content_encoding in ("gzip", "br") and not corrupt_gzip
250
+
251
+ _logger.log(
252
+ logging.DEBUG if transport_decoded else logging.WARNING,
226
253
  "APIRequest.get_content: failed to decode Content-Encoding=%r for %s (%s). "
227
254
  "Assuming the transport already decompressed and returning raw bytes.",
228
255
  content_encoding, self.url, err,
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: FlightRadarAPI
3
- Version: 1.5.1
3
+ Version: 1.5.3
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
@@ -58,6 +58,7 @@ flights = fr_api.get_flights(...) # Returns a list of Flight objects
58
58
  **Getting airports list:**
59
59
  ```py
60
60
  airports = fr_api.get_airports(...) # Returns a list of Airport objects
61
+ all_airports = fr_api.get_airports() # Omit the countries to get every airport
61
62
  ```
62
63
  **Getting airlines list:**
63
64
  ```py
@@ -32,6 +32,7 @@ flights = fr_api.get_flights(...) # Returns a list of Flight objects
32
32
  **Getting airports list:**
33
33
  ```py
34
34
  airports = fr_api.get_airports(...) # Returns a list of Airport objects
35
+ all_airports = fr_api.get_airports() # Omit the countries to get every airport
35
36
  ```
36
37
  **Getting airlines list:**
37
38
  ```py
@@ -1 +0,0 @@
1
- python 3.11.0
@@ -1,143 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
-
3
- import logging
4
- from typing import Dict, List, Optional
5
-
6
- from bs4 import BeautifulSoup
7
-
8
- from .entities.airport import Airport
9
-
10
- _logger = logging.getLogger(__name__)
11
-
12
-
13
- def parse_airlines_html(html: bytes) -> List[Dict]:
14
- """
15
- Parse the airlines listing HTML page into a list of airline dicts.
16
- """
17
- soup = BeautifulSoup(html, "html.parser")
18
- tbody = soup.find("tbody")
19
-
20
- if not tbody:
21
- _logger.warning(
22
- "parse_airlines_html: no <tbody> in response — FR24 page layout may have changed."
23
- )
24
- return []
25
-
26
- airlines = []
27
-
28
- for tr in tbody.find_all("tr"):
29
- td_notranslate = tr.find("td", class_="notranslate")
30
-
31
- if not td_notranslate:
32
- continue
33
-
34
- a_element = td_notranslate.find("a", href=lambda href: href and href.startswith("/data/airlines"))
35
-
36
- if not a_element:
37
- continue
38
-
39
- airline_name = a_element.get_text(strip=True)
40
-
41
- if len(airline_name) < 2:
42
- continue
43
-
44
- td_elements = tr.find_all("td")
45
- iata = None
46
- icao = None
47
-
48
- if len(td_elements) >= 4:
49
- codes_text = td_elements[3].get_text(strip=True)
50
-
51
- if " / " in codes_text:
52
- parts = codes_text.split(" / ")
53
- if len(parts) == 2:
54
- iata = parts[0].strip()
55
- icao = parts[1].strip()
56
- elif len(codes_text) == 2:
57
- iata = codes_text
58
- elif len(codes_text) == 3:
59
- icao = codes_text
60
-
61
- n_aircrafts = None
62
-
63
- if len(td_elements) >= 5:
64
- aircrafts_text = td_elements[4].get_text(strip=True)
65
- if aircrafts_text:
66
- n_aircrafts = int(aircrafts_text.split(" ", maxsplit=1)[0].strip())
67
-
68
- airlines.append({"Name": airline_name, "ICAO": icao, "IATA": iata, "n_aircrafts": n_aircrafts})
69
-
70
- return airlines
71
-
72
-
73
- def parse_airports_html(html: bytes, country_href: str) -> List[Airport]:
74
- """
75
- Parse the airports listing HTML page for a country into a list of Airport instances.
76
- """
77
- soup = BeautifulSoup(html, "html.parser")
78
- tbody = soup.find("tbody")
79
-
80
- if not tbody:
81
- _logger.warning(
82
- "parse_airports_html: no <tbody> for %s — FR24 page layout may have changed.",
83
- country_href,
84
- )
85
- return []
86
-
87
- country_name = country_href.split("/")[-1].replace("-", " ").title()
88
- airports = []
89
-
90
- for tr in tbody.find_all("tr"):
91
- a_elements = tr.find_all("a", attrs={"data-iata": True, "data-lat": True, "data-lon": True})
92
-
93
- if not a_elements:
94
- continue
95
-
96
- a_element = a_elements[0]
97
-
98
- icao = ""
99
- iata = str(a_element.get("data-iata", "")).strip()
100
- latitude = str(a_element.get("data-lat", "")).strip()
101
- longitude = str(a_element.get("data-lon", "")).strip()
102
- name_part = a_element.get_text(strip=True)
103
-
104
- small_element = a_element.find("small")
105
-
106
- if small_element:
107
- codes_text = small_element.get_text(strip=True).lstrip("(").rstrip(")").strip()
108
- name_part = name_part.replace(small_element.get_text(strip=True), "").replace("()", "").strip()
109
-
110
- if "/" in codes_text:
111
- code1, code2 = (s.strip() for s in codes_text.split("/", maxsplit=1))
112
- if len(code1) == 3 and len(code2) == 4:
113
- iata, icao = code1, code2
114
- elif len(code1) == 4 and len(code2) == 3:
115
- iata, icao = code2, code1
116
- elif len(codes_text) == 3:
117
- iata = codes_text
118
- elif len(codes_text) == 4:
119
- icao = codes_text
120
-
121
- lat_float: Optional[float]
122
- lon_float: Optional[float]
123
- try:
124
- lat_float = float(latitude) if latitude else None
125
- lon_float = float(longitude) if longitude else None
126
- except ValueError:
127
- _logger.warning(
128
- "parse_airports_html: invalid coordinates for airport %r (lat=%r, lon=%r) — skipping position.",
129
- name_part, latitude, longitude,
130
- )
131
- lat_float, lon_float = None, None
132
-
133
- airports.append(Airport(basic_info={
134
- "name": name_part,
135
- "icao": icao,
136
- "iata": iata,
137
- "lat": lat_float,
138
- "lon": lon_float,
139
- "alt": None,
140
- "country": country_name,
141
- }))
142
-
143
- return airports
File without changes
File without changes