FlightRadarAPI 1.5.2__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.
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/__init__.py +1 -1
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/api.py +30 -13
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/core.py +3 -0
- flightradarapi-1.5.3/FlightRadarAPI/parsers.py +239 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/request.py +17 -1
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/PKG-INFO +2 -1
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/README.md +1 -0
- flightradarapi-1.5.2/FlightRadarAPI/parsers.py +0 -143
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/.gitignore +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadar24/__init__.py +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/entities/__init__.py +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/entities/airport.py +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/entities/entity.py +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/entities/flight.py +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/errors.py +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/flight_tracker_config.py +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/py.typed +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/FlightRadarAPI/zones.py +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/LICENSE +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/Makefile +0 -0
- {flightradarapi-1.5.2 → flightradarapi-1.5.3}/pyproject.toml +0 -0
|
@@ -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,7 +11,7 @@ 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,
|
|
14
|
+
from .parsers import country_to_slug, parse_airlines_html, parse_airports_json
|
|
15
15
|
from .request import APIClient, RetryPolicy
|
|
16
16
|
|
|
17
17
|
# Some FR24 live-feed backends answer 200 with a well-formed envelope but no
|
|
@@ -187,21 +187,33 @@ class FlightRadar24API:
|
|
|
187
187
|
)
|
|
188
188
|
return response.get_json_content()
|
|
189
189
|
|
|
190
|
-
def get_airports(
|
|
190
|
+
def get_airports(
|
|
191
|
+
self, countries: Optional[Union[Iterable[Union[Countries, str]], Countries, str]] = None,
|
|
192
|
+
) -> List[Airport]:
|
|
191
193
|
"""
|
|
192
|
-
Return a list with all airports
|
|
194
|
+
Return a list with all airports, optionally narrowed to some countries.
|
|
193
195
|
|
|
194
|
-
:param countries:
|
|
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.
|
|
195
198
|
"""
|
|
196
|
-
|
|
197
|
-
href = Core.airports_data_url + "/" + country.value
|
|
198
|
-
response = self.__client.request_standalone(href, headers=Core.html_headers, timeout=self.timeout)
|
|
199
|
-
return parse_airports_html(response.get_bytes_content(), href)
|
|
199
|
+
wanted: Optional[List[Union[Countries, str]]] = None
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
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)
|
|
203
206
|
|
|
204
|
-
|
|
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
|
+
)
|
|
213
|
+
|
|
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)
|
|
205
217
|
|
|
206
218
|
def get_bookmarks(self) -> Dict:
|
|
207
219
|
"""
|
|
@@ -292,7 +304,12 @@ class FlightRadar24API:
|
|
|
292
304
|
|
|
293
305
|
:param country: Country name
|
|
294
306
|
"""
|
|
295
|
-
|
|
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
|
+
|
|
296
313
|
flag_url = Core.country_flag_url.format(slug)
|
|
297
314
|
headers = Core.image_headers.copy()
|
|
298
315
|
|
|
@@ -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
|
|
@@ -233,7 +233,23 @@ class APIRequest:
|
|
|
233
233
|
try:
|
|
234
234
|
content = decode(content)
|
|
235
235
|
except Exception as err:
|
|
236
|
-
|
|
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,
|
|
237
253
|
"APIRequest.get_content: failed to decode Content-Encoding=%r for %s (%s). "
|
|
238
254
|
"Assuming the transport already decompressed and returning raw bytes.",
|
|
239
255
|
content_encoding, self.url, err,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: FlightRadarAPI
|
|
3
|
-
Version: 1.5.
|
|
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,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
|
|
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
|