diff --git a/docs/go.md b/docs/go.md
index 6997bfa..ce09a61 100644
--- a/docs/go.md
+++ b/docs/go.md
@@ -57,6 +57,12 @@ You can fetch various types of data using the following methods:
allAirports, err := client.GetAirports(ctx, nil)
```
+ FR24 lists airports one page per country (and per state for the largest
+ countries), so this makes one request per page: a handful for a few
+ countries, a few hundred for every airport, `MaxWorkers` at a time. The
+ listing carries the name, codes and country only; use `GetAirport(ctx, code)`
+ for the position and altitude of an airport.
+
- **Airlines list:**
```go
diff --git a/docs/nodejs.md b/docs/nodejs.md
index 8fc3264..b1cfca1 100644
--- a/docs/nodejs.md
+++ b/docs/nodejs.md
@@ -36,6 +36,12 @@ You can fetch various types of data using the following methods:
let airports = await frApi.getAirports(...); // Returns a list of Airport objects
```
+ FR24 lists airports one page per country (and per state for the largest
+ countries), so this makes one request per page: a handful for a few
+ countries, a few hundred for every airport, `maxWorkers` at a time. The
+ listing carries the name, codes and country only; use `getAirport(code)`
+ for the position and altitude of an airport.
+
- **Airlines list:**
```javascript
diff --git a/docs/python.md b/docs/python.md
index e7a3fc7..d9ec6a8 100644
--- a/docs/python.md
+++ b/docs/python.md
@@ -36,6 +36,12 @@ You can fetch various types of data using the following methods:
airports = fr_api.get_airports(...) # Returns a list of Airport objects
```
+ FR24 lists airports one page per country (and per state for the largest
+ countries), so this makes one request per page: a handful for a few
+ countries, a few hundred for every airport, `max_workers` at a time. The
+ listing carries the name, codes and country only; use `get_airport(code)`
+ for the position and altitude of an airport.
+
- **Airlines list:**
```python
diff --git a/go/flightradarapi/api.go b/go/flightradarapi/api.go
index 19e1d3d..0215037 100644
--- a/go/flightradarapi/api.go
+++ b/go/flightradarapi/api.go
@@ -9,6 +9,7 @@ import (
"math"
"net/http"
"net/url"
+ "slices"
"strings"
"sync"
"time"
@@ -286,23 +287,234 @@ func (c *Client) GetAirportDisruptions(ctx context.Context) (map[string]any, err
// GetAirports returns every airport, or only those of the given countries. Pass
// nil for every airport; an empty non-nil slice selects none.
+//
+// FR24 lists airports one page per country, and one per state for the largest
+// countries, so this fetches one page per country asked for (a few hundred for
+// every airport), MaxWorkers at a time. The listing carries the name, codes and
+// country only: use [Client.GetAirport] for the position and altitude of an
+// airport.
func (c *Client) GetAirports(ctx context.Context, countries []Country) ([]*Airport, error) {
if countries != nil && len(countries) == 0 {
return []*Airport{}, nil
}
- response, err := c.client.request(ctx, c.endpoints.airportsJSON, requestOptions{
- headers: jsonHeaders,
+ // The index names every country, and declares the asset version the JSON
+ // requests below have to quote.
+ response, err := c.client.request(ctx, c.endpoints.airportsData, requestOptions{
+ headers: htmlHeaders,
+ timeout: c.Timeout,
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ indexPage, _ := parseInertiaPage(response.Body)
+ version := toText(indexPage["version"])
+
+ var slugs []string
+
+ if countries == nil {
+ for _, country := range parseAirportsIndex(indexPage) {
+ slugs = append(slugs, country.Slug)
+ }
+ } else {
+ for _, country := range countries {
+ slugs = append(slugs, countryToSlug(string(country)))
+ }
+ }
+
+ slugs = uniqueInOrder(slices.DeleteFunc(slugs, func(slug string) bool { return slug == "" }))
+
+ countryURLs := make([]string, len(slugs))
+
+ for index, slug := range slugs {
+ countryURLs[index] = c.endpoints.airportsByCountryURL(slug)
+ }
+
+ countryPages, err := c.getAirportPages(ctx, countryURLs, version)
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Second pass for the countries split by state, every state at once. A
+ // state path is relative to the site, so it is resolved against the page
+ // that listed it rather than against a fixed host.
+ var stateURLs []string
+ var stateOwners []int
+
+ for owner, page := range countryPages {
+ for _, path := range page.statePaths {
+ stateURLs = append(stateURLs, resolveReference(countryURLs[owner], path))
+ stateOwners = append(stateOwners, owner)
+ }
+ }
+
+ statePages, err := c.getAirportPages(ctx, stateURLs, version)
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Grouped by country, in the order asked for, each country's states after
+ // it.
+ airportsByCountry := make([][]*Airport, len(countryPages))
+
+ for index, page := range countryPages {
+ airportsByCountry[index] = page.airports
+ }
+ for index, page := range statePages {
+ owner := stateOwners[index]
+ airportsByCountry[owner] = append(airportsByCountry[owner], page.airports...)
+ }
+
+ airports := []*Airport{}
+
+ for _, found := range airportsByCountry {
+ airports = append(airports, found...)
+ }
+
+ // A country FR24 does not know answers with the index page, which lists
+ // neither airports nor states.
+ var missing []string
+
+ for index, page := range countryPages {
+ if len(page.airports) == 0 && len(page.statePaths) == 0 {
+ missing = append(missing, slugs[index])
+ }
+ }
+ if len(missing) > 0 {
+ log().Warn("GetAirports: no airports found for some countries — check the Country constants",
+ "countries", strings.Join(missing, ", "))
+ }
+ return airports, nil
+}
+
+// airportsPage is what one airports listing page said: its airports, or the
+// paths of the state pages it is split into.
+type airportsPage struct {
+ airports []*Airport
+ statePaths []string
+}
+
+// getAirportPages fetches and parses the airport listing pages at urls, in
+// order, MaxWorkers at a time. The first failure fails the whole call.
+func (c *Client) getAirportPages(ctx context.Context, urls []string, version string) ([]airportsPage, error) {
+ pages := make([]airportsPage, len(urls))
+
+ if len(urls) == 0 {
+ return pages, nil
+ }
+
+ // Clamped rather than skipped: MaxWorkers is public, and a zero there must
+ // not deadlock the crawl.
+ workers := make(chan struct{}, max(c.MaxWorkers, 1))
+ var group sync.WaitGroup
+ var once sync.Once
+ var firstErr error
+
+ for index, target := range urls {
+ workers <- struct{}{}
+ group.Add(1)
+
+ go func() {
+ defer group.Done()
+ defer func() { <-workers }()
+
+ payload, err := c.getInertiaPage(ctx, target, version)
+
+ if err != nil {
+ once.Do(func() { firstErr = err })
+ return
+ }
+ pages[index].airports, pages[index].statePaths = parseAirportsPage(payload)
+ }()
+ }
+ group.Wait()
+
+ if firstErr != nil {
+ return nil, firstErr
+ }
+ return pages, nil
+}
+
+// getInertiaPage fetches a FR24 data page as its Inertia page object, falling
+// back to the HTML. It returns the decoded JSON body, or the HTML bytes, for
+// parseInertiaPage to read.
+//
+// With the asset version FR24 answers the bare page JSON, a fraction of the
+// HTML. A 409 means the assets were redeployed since the version was read: the
+// HTML carries the same page object and needs no version.
+func (c *Client) getInertiaPage(ctx context.Context, target, version string) (any, error) {
+ if version != "" {
+ response, err := c.client.requestStandalone(ctx, target, requestOptions{
+ headers: withHeaders(inertiaHeaders, map[string]string{"x-inertia-version": version}),
+ allowedErrorCodes: []int{409},
+ timeout: c.Timeout,
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ if response.StatusCode != 409 {
+ if !response.IsJSON() {
+ return response.Body, nil
+ }
+
+ // Decoded as any value, not as an object: a body that is not a
+ // page object is the parser's warning, not a failed call.
+ var page any
+
+ if err := json.Unmarshal([]byte(response.Text()), &page); err != nil {
+ return nil, fmt.Errorf("%w: could not parse the JSON body of %s: %w", ErrFlightRadar, response.URL, err)
+ }
+ return page, nil
+ }
+ }
+
+ response, err := c.client.requestStandalone(ctx, target, requestOptions{
+ headers: htmlHeaders,
timeout: c.Timeout,
})
if err != nil {
return nil, err
}
+ return response.Body, nil
+}
- // The raw body, not JSON(): an HTML body reaches the parser's guard, and a
- // malformed JSON body still returns empty rather than failing the call.
- return parseAirportsJSON(response.Body, countries), nil
+// uniqueInOrder drops repeated values, keeping the first occurrence of each.
+func uniqueInOrder(values []string) []string {
+ seen := make(map[string]bool, len(values))
+ unique := values[:0:0]
+
+ for _, value := range values {
+ if !seen[value] {
+ seen[value] = true
+ unique = append(unique, value)
+ }
+ }
+ return unique
+}
+
+// resolveReference resolves a path FR24 printed on a page against that page's
+// URL, so "/data/airports/brazil/sp" lands on the host the page came from. A
+// reference that will not parse is returned as given.
+func resolveReference(base, reference string) string {
+ baseURL, err := url.Parse(base)
+
+ if err != nil {
+ return reference
+ }
+
+ referenceURL, err := url.Parse(reference)
+
+ if err != nil {
+ return reference
+ }
+ return baseURL.ResolveReference(referenceURL).String()
}
// GetBookmarks returns the bookmarks of the logged-in account.
diff --git a/go/flightradarapi/api_test.go b/go/flightradarapi/api_test.go
index b5449d8..31eca65 100644
--- a/go/flightradarapi/api_test.go
+++ b/go/flightradarapi/api_test.go
@@ -1,16 +1,22 @@
package flightradarapi
import (
+ "bytes"
"context"
"encoding/json"
"errors"
+ "log/slog"
"math"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
+ "os"
+ "path/filepath"
+ "slices"
"strconv"
"strings"
+ "sync"
"sync/atomic"
"testing"
"time"
@@ -48,7 +54,8 @@ func testEndpoints(base string) endpoints {
historicalData: base + "/download/?flight=%s&file=%s&trailLimit=0&history=%d",
apiAirportData: base + "/common/v1/airport.json",
airportData: base + "/airports/traffic-stats/?airport=%s",
- airportsJSON: base + "/_json/airports.php",
+ airportsData: base + "/data/airports",
+ airportsByCountry: base + "/data/airports/%s",
airportDisruption: base + "/webapi/v1/airport-disruptions",
airlinesData: base + "/data/airlines",
volcanicEruptionData: base + "/weather/volcanic",
@@ -97,54 +104,399 @@ func TestGetAirlinesParsesTheListingPage(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if len(airlines) != 5 {
- t.Errorf("got %d airlines, want 5", len(airlines))
+ if len(airlines) != 6 {
+ t.Errorf("got %d airlines, want 6", len(airlines))
}
}
-func TestGetAirportsFiltersByCountry(t *testing.T) {
+// --- GetAirports ---
+//
+// FR24 lists airports one Inertia page per country, and per state for the
+// largest countries. These tests pin down which pages the crawl asks for and
+// how it reads them, against the bundled fixtures, mirroring
+// python/tests/test_airports_offline.py.
+
+// Every page the fixtures model, by the path FR24 serves it at.
+var airportPages = map[string]string{
+ "/data/airports": "airports_index.html",
+ "/data/airports/uruguay": "airports_country.json",
+ "/data/airports/brazil": "airports_country_states.json",
+ "/data/airports/brazil/sp": "airports_state_sp.json",
+ "/data/airports/brazil/rj": "airports_state_rj.json",
+ "/data/airports/myanmar-burma": "airports_country.html",
+}
+
+const fixtureAssetVersion = "919b94d2a33f365a34f99828f6eeec6a"
+
+// airportsCall is one request the fake FR24 saw.
+type airportsCall struct {
+ path string
+ headers http.Header
+}
+
+// airportsServer serves the fixture pages the way FR24 does. A request carrying
+// the inertia headers gets the page JSON when the version matches and a 409
+// otherwise; a plain request gets the HTML. The index page only exists as HTML,
+// like the real one as the crawl fetches it.
+type airportsServer struct {
+ t *testing.T
+ version string
+
+ mu sync.Mutex
+ calls []airportsCall
+}
+
+func newAirportsServer(t *testing.T, version string) *airportsServer {
+ t.Helper()
+ return &airportsServer{t: t, version: version}
+}
+
+func (s *airportsServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ s.mu.Lock()
+ s.calls = append(s.calls, airportsCall{path: r.URL.Path, headers: r.Header.Clone()})
+ s.mu.Unlock()
+
+ fixture, ok := airportPages[r.URL.Path]
+
+ if !ok {
+ fixture = "airports_index.html"
+ }
+
+ if r.Header.Get("X-Inertia") == "true" {
+ if r.Header.Get("X-Inertia-Version") != s.version {
+ w.WriteHeader(http.StatusConflict)
+ return
+ }
+ if strings.HasSuffix(fixture, ".json") {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(loadFixture(s.t, fixture))
+ return
+ }
+
+ page, ok := parseInertiaPage(loadFixture(s.t, fixture))
+
+ if !ok {
+ s.t.Errorf("fixture %s carries no page object", fixture)
+ }
+ writeJSON(s.t, w, page)
+ return
+ }
+
+ if strings.HasSuffix(fixture, ".json") {
+ fixture = strings.TrimSuffix(fixture, ".json") + ".html"
+
+ if _, err := os.Stat(filepath.Join("testdata", fixture)); err != nil {
+ fixture = "airports_country.html"
+ }
+ }
+ w.Header().Set("Content-Type", "text/html")
+ w.Write(loadFixture(s.t, fixture))
+}
+
+// paths are the paths requested so far, in order.
+func (s *airportsServer) paths() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ paths := make([]string, 0, len(s.calls))
+
+ for _, call := range s.calls {
+ paths = append(paths, call.path)
+ }
+ return paths
+}
+
+func (s *airportsServer) call(index int) airportsCall {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.calls[index]
+}
+
+// newSerialAirportsClient wires a client to the fake, one request at a time, so
+// the order of the requests is the order of the crawl rather than the order the
+// goroutines happened to run in.
+func newSerialAirportsClient(t *testing.T, server *airportsServer) *Client {
+ t.Helper()
+ client := newTestClient(t, server)
+ client.MaxWorkers = 1
+ return client
+}
+
+func TestGetAirportsCrawlsTheCountryThenItsStates(t *testing.T) {
+ server := newAirportsServer(t, fixtureAssetVersion)
+
+ airports, err := newSerialAirportsClient(t, server).GetAirports(context.Background(), []Country{CountryBrazil})
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got := iatas(airports); !slices.Equal(got, []string{"GIG", "GRU", "CGH"}) {
+ t.Errorf("got %v, want GIG, GRU, CGH", got)
+ }
+ for _, airport := range airports {
+ if airport.Country != "Brazil" {
+ t.Errorf("%s: got country %q, want Brazil", airport.IATA, airport.Country)
+ }
+ }
+
+ want := []string{"/data/airports", "/data/airports/brazil", "/data/airports/brazil/rj", "/data/airports/brazil/sp"}
+
+ if got := server.paths(); !slices.Equal(got, want) {
+ t.Errorf("got requests %v, want %v", got, want)
+ }
+}
+
+func TestGetAirportsAsksForThePageJSONWithTheIndexVersion(t *testing.T) {
+ server := newAirportsServer(t, fixtureAssetVersion)
+
+ if _, err := newTestClient(t, server).GetAirports(context.Background(), []Country{CountryUruguay}); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got := server.paths(); !slices.Equal(got, []string{"/data/airports", "/data/airports/uruguay"}) {
+ t.Fatalf("got requests %v, want the index then the country", got)
+ }
+
+ index, country := server.call(0), server.call(1)
+
+ if index.headers.Get("X-Inertia") != "" || !strings.Contains(index.headers.Get("Accept"), "text/html") {
+ t.Errorf("index: got headers %v, want the html headers", index.headers)
+ }
+ if country.headers.Get("X-Inertia") != "true" || country.headers.Get("X-Inertia-Version") != fixtureAssetVersion {
+ t.Errorf("country: got headers %v, want the inertia headers with the index version", country.headers)
+ }
+ if country.headers.Get("X-Requested-With") != "XMLHttpRequest" || country.headers.Get("Accept") != "application/json" {
+ t.Errorf("country: got headers %v, want an XMLHttpRequest for JSON", country.headers)
+ }
+}
+
+func TestGetAirportsFallsBackToHTMLWhenTheAssetsWereRedeployed(t *testing.T) {
+ server := newAirportsServer(t, "something-newer")
+
+ airports, err := newTestClient(t, server).GetAirports(context.Background(), []Country{"uruguay"})
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ // The index answered with a stale version, the JSON request got a 409 and
+ // the HTML page was read instead.
+ if len(airports) != 1 {
+ t.Errorf("got %d airports, want the one on the HTML page", len(airports))
+ }
+
+ want := []string{"/data/airports", "/data/airports/uruguay", "/data/airports/uruguay"}
+
+ if got := server.paths(); !slices.Equal(got, want) {
+ t.Fatalf("got requests %v, want %v", got, want)
+ }
+
+ retry := server.call(2).headers
+
+ if retry.Get("X-Inertia") != "" || !strings.Contains(retry.Get("Accept"), "text/html") {
+ t.Errorf("got headers %v, want the html headers", retry)
+ }
+}
+
+func TestGetAirportsReadsTheHTMLWhenTheIndexDeclaresNoVersion(t *testing.T) {
+ // An index with no page object leaves the crawl without a version, so it
+ // asks for the HTML straight away rather than fishing for a 409.
mux := http.NewServeMux()
- mux.HandleFunc("/_json/airports.php", func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.Write(loadFixture(t, "airports.json"))
+ server := newAirportsServer(t, fixtureAssetVersion)
+ mux.HandleFunc("/data/airports", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte("
maintenance"))
})
+ mux.Handle("/data/airports/", server)
- client := newTestClient(t, mux)
- airports, err := client.GetAirports(context.Background(), []Country{CountryBrazil})
+ airports, err := newTestClient(t, mux).GetAirports(context.Background(), []Country{CountryMyanmarBurma})
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got := iatas(airports); !slices.Equal(got, []string{"RGN"}) {
+ t.Errorf("got %v, want RGN", got)
+ }
+ if got := server.paths(); !slices.Equal(got, []string{"/data/airports/myanmar-burma"}) {
+ t.Fatalf("got requests %v, want the country page once", got)
+ }
+ if headers := server.call(0).headers; headers.Get("X-Inertia") != "" {
+ t.Errorf("got headers %v, want the html headers", headers)
+ }
+}
+
+func TestGetAirportsWithoutCountriesWalksTheWholeIndex(t *testing.T) {
+ server := newAirportsServer(t, fixtureAssetVersion)
+
+ airports, err := newSerialAirportsClient(t, server).GetAirports(context.Background(), nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if len(airports) != 4 {
- t.Errorf("got %d Brazilian airports, want 4", len(airports))
+ if got := iatas(airports); !slices.Equal(got, []string{"GIG", "GRU", "CGH", "RGN", "CYR", "MVD"}) {
+ t.Errorf("got %v, want every airport grouped by country", got)
+ }
+
+ want := []string{
+ "/data/airports",
+ "/data/airports/brazil", "/data/airports/myanmar-burma", "/data/airports/uruguay",
+ "/data/airports/brazil/rj", "/data/airports/brazil/sp",
+ }
+
+ if got := server.paths(); !slices.Equal(got, want) {
+ t.Errorf("got requests %v, want %v", got, want)
+ }
+}
+
+func TestGetAirportsGroupsByCountryWhateverOrderThePagesArriveIn(t *testing.T) {
+ server := newAirportsServer(t, fixtureAssetVersion)
+ client := newTestClient(t, server)
+ client.MaxWorkers = 16
+
+ // Repeated so a scheduling order that happens to match does not pass it.
+ for range 5 {
+ airports, err := client.GetAirports(context.Background(), []Country{CountryUruguay, CountryBrazil, CountryMyanmarBurma})
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got := iatas(airports); !slices.Equal(got, []string{"CYR", "MVD", "GIG", "GRU", "CGH", "RGN"}) {
+ t.Fatalf("got %v, want the countries in the order asked for", got)
+ }
}
+}
+
+func TestGetAirportsAcceptsMixedSpellingsAndDropsRepeats(t *testing.T) {
+ server := newAirportsServer(t, fixtureAssetVersion)
- every, err := client.GetAirports(context.Background(), nil)
+ airports, err := newTestClient(t, server).GetAirports(context.Background(),
+ []Country{"Uruguay", CountryUruguay, "uruguay", ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if len(every) != 8 {
- t.Errorf("got %d airports for nil, want all 8", len(every))
+ if got := iatas(airports); !slices.Equal(got, []string{"CYR", "MVD"}) {
+ t.Errorf("got %v, want CYR and MVD once", got)
+ }
+ if got := server.paths(); !slices.Equal(got, []string{"/data/airports", "/data/airports/uruguay"}) {
+ t.Errorf("got requests %v, want the country page once", got)
}
}
func TestGetAirportsWithAnEmptyFilterMakesNoRequest(t *testing.T) {
- var calls atomic.Int32
+ server := newAirportsServer(t, fixtureAssetVersion)
+
+ airports, err := newTestClient(t, server).GetAirports(context.Background(), []Country{})
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if airports == nil || len(airports) != 0 || len(server.paths()) != 0 {
+ t.Errorf("got %v after %v, want an empty slice and no request", airports, server.paths())
+ }
+}
+
+func TestGetAirportsUnknownCountryReturnsNothingAndWarns(t *testing.T) {
+ var logged bytes.Buffer
+ SetLogger(slog.New(slog.NewTextHandler(&logged, nil)))
+ t.Cleanup(func() { SetLogger(nil) })
+
+ server := newAirportsServer(t, fixtureAssetVersion)
+ airports, err := newTestClient(t, server).GetAirports(context.Background(), []Country{"atlantis"})
+
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if airports == nil || len(airports) != 0 {
+ t.Errorf("got %v, want an empty slice", airports)
+ }
+
+ output := logged.String()
+
+ if !strings.Contains(output, "level=WARN") || !strings.Contains(output, "no airports found") ||
+ !strings.Contains(output, "atlantis") {
+ t.Errorf("got %q, want a warning naming atlantis", output)
+ }
+}
+
+func TestGetAirportsFailsWhenAPageCannotBeFetched(t *testing.T) {
mux := http.NewServeMux()
- mux.HandleFunc("/_json/airports.php", func(w http.ResponseWriter, r *http.Request) {
- calls.Add(1)
+ server := newAirportsServer(t, fixtureAssetVersion)
+ mux.Handle("/", server)
+ mux.HandleFunc("/data/airports/brazil/sp", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ })
+
+ _, err := newTestClient(t, mux).GetAirports(context.Background(), []Country{CountryBrazil})
+ var statusErr *StatusError
+
+ if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("got %v, want the 500 reported", err)
+ }
+}
+
+func TestGetAirportsFailsWhenTheIndexCannotBeFetched(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/data/airports", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ })
+
+ if _, err := newTestClient(t, mux).GetAirports(context.Background(), nil); err == nil {
+ t.Fatal("got no error, want the 503 reported")
+ }
+}
+
+func TestGetAirportsRejectsAMalformedPageJSON(t *testing.T) {
+ mux := http.NewServeMux()
+ server := newAirportsServer(t, fixtureAssetVersion)
+ mux.Handle("/", server)
+ mux.HandleFunc("/data/airports/uruguay", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
- w.Write(loadFixture(t, "airports.json"))
+ w.Write([]byte(`{"component": "Data/AirportsByCountry", "props": `))
})
- airports, err := newTestClient(t, mux).GetAirports(context.Background(), []Country{})
+ _, err := newTestClient(t, mux).GetAirports(context.Background(), []Country{CountryUruguay})
+
+ if !errors.Is(err, ErrFlightRadar) || !strings.Contains(err.Error(), "could not parse the JSON body") {
+ t.Fatalf("got %v, want the malformed body reported", err)
+ }
+}
+
+func TestGetAirportsTreatsAPageJSONThatIsNotAnObjectAsEmpty(t *testing.T) {
+ mux := http.NewServeMux()
+ server := newAirportsServer(t, fixtureAssetVersion)
+ mux.Handle("/", server)
+ mux.HandleFunc("/data/airports/uruguay", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`null`))
+ })
+
+ airports, err := newTestClient(t, mux).GetAirports(context.Background(), []Country{CountryUruguay})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- if len(airports) != 0 || calls.Load() != 0 {
- t.Errorf("got %d airports after %d calls, want neither", len(airports), calls.Load())
+ if len(airports) != 0 {
+ t.Errorf("got %d airports, want none", len(airports))
+ }
+}
+
+func TestGetAirportsResolvesStatePathsAgainstTheCountryPage(t *testing.T) {
+ // FR24 prints state paths without a host; an absolute URL must survive too.
+ base := "https://www.flightradar24.com/data/airports/brazil"
+
+ cases := map[string]string{
+ "/data/airports/brazil/sp": "https://www.flightradar24.com/data/airports/brazil/sp",
+ "https://www.flightradar24.com/data/airports/x/y": "https://www.flightradar24.com/data/airports/x/y",
+ "rj": "https://www.flightradar24.com/data/airports/rj",
+ "::not a url": "::not a url",
+ }
+
+ for reference, want := range cases {
+ if got := resolveReference(base, reference); got != want {
+ t.Errorf("%q: got %q, want %q", reference, got, want)
+ }
+ }
+ if got := resolveReference("::broken base", "/data/airports/brazil/sp"); got != "/data/airports/brazil/sp" {
+ t.Errorf("got %q, want the path as given", got)
}
}
diff --git a/go/flightradarapi/core.go b/go/flightradarapi/core.go
index 08ba314..209f338 100644
--- a/go/flightradarapi/core.go
+++ b/go/flightradarapi/core.go
@@ -30,7 +30,8 @@ type endpoints struct {
apiAirportData string
airportData string
- airportsJSON string
+ airportsData string
+ airportsByCountry string
airportDisruption string
airlinesData string
@@ -56,9 +57,15 @@ func defaultEndpoints() endpoints {
historicalData: flightRadarBaseURL + "/download/?flight=%s&file=%s&trailLimit=0&history=%d",
- apiAirportData: apiFlightRadarBaseURL + "/airport.json",
- airportData: flightRadarBaseURL + "/airports/traffic-stats/?airport=%s",
- airportsJSON: flightRadarBaseURL + "/_json/airports.php",
+ apiAirportData: apiFlightRadarBaseURL + "/airport.json",
+ airportData: flightRadarBaseURL + "/airports/traffic-stats/?airport=%s",
+ // The listing is split by country, and by state for the largest
+ // countries. These are Inertia pages: the data rides JSON-encoded in the
+ // data-page attribute of the HTML, or comes as the whole body when the
+ // request carries inertiaHeaders and the asset version the index page
+ // declares.
+ airportsData: flightRadarBaseURL + "/data/airports",
+ airportsByCountry: flightRadarBaseURL + "/data/airports/%s",
airportDisruption: flightRadarBaseURL + "/webapi/v1/airport-disruptions",
airlinesData: flightRadarBaseURL + "/data/airlines",
@@ -97,6 +104,10 @@ func (e endpoints) countryFlagURL(slug string) string {
return fmt.Sprintf(e.countryFlag, url.PathEscape(slug))
}
+func (e endpoints) airportsByCountryURL(slug string) string {
+ return fmt.Sprintf(e.airportsByCountry, url.PathEscape(slug))
+}
+
func (e endpoints) airlineLogoURL(iata, icao string) string {
return fmt.Sprintf(e.airlineLogo, url.PathEscape(iata), url.PathEscape(icao))
}
@@ -130,6 +141,13 @@ var (
imageHeaders = withHeaders(baseHeaders, map[string]string{
"accept": "image/gif, image/jpg, image/jpeg, image/png",
})
+ // inertiaHeaders ask an Inertia page for its page object instead of the
+ // HTML around it. The request must also carry an x-inertia-version matching
+ // the deployed assets, or FR24 answers 409.
+ inertiaHeaders = withHeaders(jsonHeaders, map[string]string{
+ "x-inertia": "true",
+ "x-requested-with": "XMLHttpRequest",
+ })
htmlHeaders = map[string]string{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9," +
"image/avif,image/webp,image/apng,*/*;q=0.8," +
diff --git a/go/flightradarapi/parsers.go b/go/flightradarapi/parsers.go
index 57fd6b8..83f2f9c 100644
--- a/go/flightradarapi/parsers.go
+++ b/go/flightradarapi/parsers.go
@@ -4,11 +4,9 @@ import (
"bytes"
"encoding/json"
"errors"
- "io"
"math"
"reflect"
"regexp"
- "slices"
"strconv"
"strings"
"unicode"
@@ -25,6 +23,16 @@ var numericPattern = regexp.MustCompile(`^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+
// U+001C-U+001F and U+FEFF.
const surroundingSpace = " \t\n\r\f\v"
+// airlineCodePattern is an airline code badge: a two-letter IATA code or a
+// three-letter ICAO code. ASCII only, so a stray Unicode letter does not pass
+// for a code.
+var airlineCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
+
+// aircraftCountPattern is a fleet size as the page prints it: "340" or "3,878".
+var aircraftCountPattern = regexp.MustCompile(`^[0-9]{1,3}(?:[,.][0-9]{3})*$|^[0-9]+$`)
+
+const airlinesPathPrefix = "/data/airlines/"
+
// Airline is one row of the airlines listing. The JSON tags match the keys the
// Python and Node.js ports use, so NumAircrafts serialises as "n_aircrafts".
type Airline struct {
@@ -35,6 +43,11 @@ type Airline struct {
}
// parseAirlinesHTML parses the airlines listing page into airline records.
+//
+// Every airline is a link to its data page, with the IATA and ICAO codes as
+// badges next to the name. The "Largest fleets" section repeats a few airlines
+// with their aircraft count; an airline the page lists twice becomes one entry
+// carrying everything the page said about it.
func parseAirlinesHTML(page []byte) []Airline {
document, err := html.Parse(bytes.NewReader(page))
@@ -43,71 +56,114 @@ func parseAirlinesHTML(page []byte) []Airline {
return []Airline{}
}
- tbody := findElement(document, func(node *html.Node) bool { return node.Data == "tbody" })
+ links := findElements(document, func(node *html.Node) bool {
+ return node.Data == "a" && strings.HasPrefix(attr(node, "href"), airlinesPathPrefix)
+ })
- if tbody == nil {
- log().Warn("parseAirlinesHTML: no in response — FR24 page layout may have changed")
+ if len(links) == 0 {
+ log().Warn("parseAirlinesHTML: no airline links in response — FR24 page layout may have changed")
return []Airline{}
}
airlines := []Airline{}
+ indexByHref := map[string]int{}
- for _, row := range findElements(tbody, func(node *html.Node) bool { return node.Data == "tr" }) {
- cells := findElements(row, func(node *html.Node) bool { return node.Data == "td" })
- notranslate := findElement(row, func(node *html.Node) bool {
- return node.Data == "td" && hasClass(node, "notranslate")
- })
+ for _, link := range links {
+ name := airlineName(link)
- if notranslate == nil {
+ if len(name) < 2 {
continue
}
- link := findElement(notranslate, func(node *html.Node) bool {
- return node.Data == "a" && strings.HasPrefix(attr(node, "href"), "/data/airlines")
- })
+ var iata, icao string
- if link == nil {
- continue
+ for _, text := range leafTexts(link, "span") {
+ switch {
+ case !airlineCodePattern.MatchString(text):
+ case len(text) == 2 && iata == "":
+ iata = text
+ case len(text) == 3 && icao == "":
+ icao = text
+ }
}
- name := textContent(link)
-
- if len(name) < 2 {
+ // Section links ("All") point under /data/airlines/ too; a code tells
+ // an airline apart from them.
+ if iata == "" && icao == "" {
continue
}
- airline := Airline{Name: name}
+ var numAircrafts *int
- if len(cells) >= 4 {
- codes := textContent(cells[3])
+ for _, text := range leafTexts(link, "strong") {
+ if aircraftCountPattern.MatchString(text) {
+ digits := strings.Map(func(char rune) rune {
+ if char >= '0' && char <= '9' {
+ return char
+ }
+ return -1
+ }, text)
- switch {
- case strings.Contains(codes, " / "):
- if parts := strings.Split(codes, " / "); len(parts) == 2 {
- airline.IATA = strings.TrimSpace(parts[0])
- airline.ICAO = strings.TrimSpace(parts[1])
+ if count, err := strconv.Atoi(digits); err == nil {
+ numAircrafts = &count
+ break
}
- case len(codes) == 2:
- airline.IATA = codes
- case len(codes) == 3:
- airline.ICAO = codes
}
}
- if len(cells) >= 5 {
- if text := textContent(cells[4]); text != "" {
- field := strings.TrimSpace(strings.SplitN(text, " ", 2)[0])
+ href := attr(link, "href")
+ index, seen := indexByHref[href]
- if count, err := strconv.Atoi(field); err == nil {
- airline.NumAircrafts = &count
- }
- }
+ if !seen {
+ index = len(airlines)
+ indexByHref[href] = index
+ airlines = append(airlines, Airline{Name: name})
+ }
+
+ airline := &airlines[index]
+
+ if airline.ICAO == "" {
+ airline.ICAO = icao
+ }
+ if airline.IATA == "" {
+ airline.IATA = iata
+ }
+ if airline.NumAircrafts == nil {
+ airline.NumAircrafts = numAircrafts
}
- airlines = append(airlines, airline)
}
return airlines
}
+// airlineName is the airline name of a listing entry. The name sits in the
+// element titled with it, with the logo's aria-label as a fallback; the anchor
+// text also carries the codes and the fleet size.
+func airlineName(link *html.Node) string {
+ for _, attribute := range []string{"title", "aria-label"} {
+ element := findElement(link, func(node *html.Node) bool { return hasAttr(node, attribute) })
+
+ if element != nil {
+ return strings.TrimSpace(attr(element, attribute))
+ }
+ }
+ return textContent(link)
+}
+
+// leafTexts is the text of the `tag` elements under root that hold no other
+// element. The name element is left out by its title attribute: an airline
+// called "TAP" would otherwise read as an ICAO code.
+func leafTexts(root *html.Node, tag string) []string {
+ var texts []string
+
+ for _, element := range findElements(root, func(node *html.Node) bool { return node.Data == tag }) {
+ if hasAttr(element, "title") || findElement(element, func(*html.Node) bool { return true }) != nil {
+ continue
+ }
+ texts = append(texts, textContent(element))
+ }
+ return texts
+}
+
// countryToSlug slugifies a country name the way FR24 spells it in its data page
// URLs, so feed rows can be matched against the Country constants.
func countryToSlug(country string) string {
@@ -214,49 +270,79 @@ func parseNumber(text string) *float64 {
return &number
}
-// parseAirportsJSON parses the airports JSON feed into airports, keeping only
-// the wanted countries when any are given.
-func parseAirportsJSON(payload []byte, countries []Country) []*Airport {
- decoder := json.NewDecoder(bytes.NewReader(bytes.TrimPrefix(payload, []byte("\xef\xbb\xbf"))))
-
- // Numbers stay as text, so a literal no float64 can hold is rejected
- // instead of silently becoming an infinity.
- decoder.UseNumber()
+// parseInertiaPage returns the Inertia page object (component, props, version)
+// of a FR24 data page, or false when the payload carries none.
+//
+// Served as HTML, the page keeps it JSON-encoded in the data-page attribute of
+// its root element; asked with inertiaHeaders, FR24 sends it as the body, which
+// the caller has already decoded into a map. Both shapes are accepted, so a
+// test can feed the parser a decoded fixture the way the other ports do.
+func parseInertiaPage(payload any) (map[string]any, bool) {
+ var page any
+
+ switch typed := payload.(type) {
+ case map[string]any:
+ page = typed
+ case string:
+ return parseInertiaPage([]byte(typed))
+ case []byte:
+ document, err := html.Parse(bytes.NewReader(typed))
- var data map[string]any
+ if err != nil {
+ log().Warn("parseInertiaPage: could not parse the response as HTML")
+ return nil, false
+ }
- if err := decoder.Decode(&data); err != nil {
- log().Warn("parseAirportsJSON: response is not valid JSON — FR24 feed may have changed")
- return []*Airport{}
- }
+ root := findElement(document, func(node *html.Node) bool { return hasAttr(node, "data-page") })
- // Decode stops at the first value, so a truncated or spliced body would
- // otherwise pass as valid. The Python and Node.js parsers reject it.
- if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
- log().Warn("parseAirportsJSON: response carries data past the JSON body — FR24 feed may have changed")
- return []*Airport{}
+ if root == nil {
+ log().Warn("parseInertiaPage: no data-page attribute in response — FR24 page layout may have changed")
+ return nil, false
+ }
+ if err := json.Unmarshal([]byte(attr(root, "data-page")), &page); err != nil {
+ log().Warn("parseInertiaPage: data-page is not valid JSON — FR24 page layout may have changed")
+ return nil, false
+ }
+ default:
+ page = payload
}
- rows, ok := data["rows"].([]any)
+ object, ok := page.(map[string]any)
if !ok {
- log().Warn(`parseAirportsJSON: no "rows" array in response — FR24 feed may have changed`)
- return []*Airport{}
+ log().Warn("parseInertiaPage: no page props in response — FR24 page layout may have changed")
+ return nil, false
+ }
+ if _, ok := object["props"].(map[string]any); !ok {
+ log().Warn("parseInertiaPage: no page props in response — FR24 page layout may have changed")
+ return nil, false
}
+ return object, true
+}
- var wanted map[string]bool
+// airportsCountry is one entry of the airports index: the country name as FR24
+// prints it ("UNITED STATES") and the slug of its listing page, which is also
+// its Country constant.
+type airportsCountry struct {
+ Name string
+ Slug string
+}
- if countries != nil {
- wanted = make(map[string]bool, len(countries))
+// parseAirportsIndex parses the airports index (endpoints.airportsData) into
+// its countries. The payload is the page HTML, or its decoded page object.
+func parseAirportsIndex(payload any) []airportsCountry {
+ page, ok := parseInertiaPage(payload)
+ var rows []any
- for _, country := range countries {
- wanted[countryToSlug(string(country))] = true
- }
+ if ok {
+ rows, _ = page["props"].(map[string]any)["airportsByCountry"].([]any)
+ }
+ if rows == nil {
+ log().Warn(`parseAirportsIndex: no "airportsByCountry" list in response — FR24 page layout may have changed`)
+ return []airportsCountry{}
}
- matched := make(map[string]bool, len(wanted))
- var unpositioned []string
- airports := []*Airport{}
+ countries := []airportsCountry{}
for _, entry := range rows {
row, ok := entry.(map[string]any)
@@ -265,58 +351,63 @@ func parseAirportsJSON(payload []byte, countries []Country) []*Airport {
continue
}
- // Slugified only when filtering: this loop runs over every airport in
- // the feed.
- if wanted != nil {
- slug := countryToSlug(toText(row["country"]))
+ name := toText(row["name"])
+ slug := countryToSlug(name)
- if !wanted[slug] {
- continue
- }
- matched[slug] = true
+ if slug != "" {
+ countries = append(countries, airportsCountry{Name: name, Slug: slug})
}
+ }
+ return countries
+}
- latitude := toNumber(row["lat"])
- longitude := toNumber(row["lon"])
+// parseAirportsPage parses a country or state airports page into its airports
+// and the paths of its state pages. The payload is the page HTML, or its
+// decoded page object.
+//
+// A country with many airports lists its states instead, each with the path of
+// its own page; those paths come back for the caller to fetch. The listing has
+// no coordinates or altitude: [Client.GetAirport] has them for one airport.
+func parseAirportsPage(payload any) (airports []*Airport, statePaths []string) {
+ airports, statePaths = []*Airport{}, []string{}
+ page, ok := parseInertiaPage(payload)
- // One bad coordinate drops both: half a position reads as located.
- if latitude == nil || longitude == nil {
- latitude, longitude = nil, nil
- unpositioned = append(unpositioned, toText(row["name"]))
- }
+ if !ok {
+ return airports, statePaths
+ }
+
+ props := page["props"].(map[string]any)
+ country, _ := props["country"].(map[string]any)
+ countryName := toText(country["name"])
+
+ rows, _ := props["airports"].([]any)
+ states, _ := props["states"].([]any)
+ for _, entry := range rows {
+ row, ok := entry.(map[string]any)
+
+ if !ok {
+ continue
+ }
airports = append(airports, newAirportFromBasicInfo(basicAirportInfo{
- Name: toText(row["name"]),
- ICAO: toText(row["icao"]),
- IATA: toText(row["iata"]),
- Latitude: latitude,
- Longitude: longitude,
- Altitude: toNumber(row["alt"]),
- Country: toText(row["country"]),
+ Name: toText(row["name"]),
+ ICAO: toText(row["icao"]),
+ IATA: toText(row["iata"]),
+ Country: countryName,
}))
}
- // One line, not one per row: the feed carries every airport.
- if len(unpositioned) > 0 {
- log().Warn("parseAirportsJSON: airports with unusable coordinates carry no position",
- "count", len(unpositioned), "examples", unpositioned[:min(3, len(unpositioned))])
- }
-
- if wanted != nil {
- var missing []string
+ for _, entry := range states {
+ state, ok := entry.(map[string]any)
- for slug := range wanted {
- if !matched[slug] {
- missing = append(missing, slug)
- }
+ if !ok {
+ continue
}
- if len(missing) > 0 {
- slices.Sort(missing)
- log().Warn("parseAirportsJSON: no airports found for some countries — check the Country constants",
- "countries", strings.Join(missing, ", "))
+ if path := toText(state["url"]); path != "" {
+ statePaths = append(statePaths, path)
}
}
- return airports
+ return airports, statePaths
}
// --- HTML helpers ---
@@ -350,8 +441,15 @@ func attr(node *html.Node, name string) string {
return ""
}
-func hasClass(node *html.Node, name string) bool {
- return slices.Contains(strings.Fields(attr(node, "class")), name)
+// hasAttr reports whether the element carries the attribute at all, which
+// attr cannot tell from an empty value.
+func hasAttr(node *html.Node, name string) bool {
+ for _, attribute := range node.Attr {
+ if attribute.Key == name {
+ return true
+ }
+ }
+ return false
}
// textContent concatenates the node's text, trimming each piece, as
diff --git a/go/flightradarapi/parsers_test.go b/go/flightradarapi/parsers_test.go
index 2bd0718..219dc3b 100644
--- a/go/flightradarapi/parsers_test.go
+++ b/go/flightradarapi/parsers_test.go
@@ -2,10 +2,9 @@ package flightradarapi
import (
"encoding/json"
- "fmt"
- "math"
"os"
"path/filepath"
+ "slices"
"strings"
"testing"
)
@@ -35,28 +34,6 @@ func airlinesByName(t *testing.T) map[string]Airline {
return byName
}
-func airportsByIATA(t *testing.T, airports []*Airport) map[string]*Airport {
- t.Helper()
- byIATA := map[string]*Airport{}
-
- for _, airport := range airports {
- byIATA[airport.IATA] = airport
- }
- return byIATA
-}
-
-// airportsFromRow parses a one-row feed built from a raw JSON literal, the way
-// both other ports do it.
-func airportsFromRow(t *testing.T, row string) []*Airport {
- t.Helper()
- airports := parseAirportsJSON([]byte(`{"rows":[`+row+`]}`), nil)
-
- if len(airports) != 1 {
- t.Fatalf("got %d airports for row %s, want 1", len(airports), row)
- }
- return airports
-}
-
// --- parseAirlinesHTML ---
func TestParseAirlinesHTMLExtractsKnownRows(t *testing.T) {
@@ -102,289 +79,327 @@ func TestParseAirlinesHTMLParsesAircraftCount(t *testing.T) {
for _, expected := range []struct {
name string
count int
- }{{"LATAM Airlines", 340}, {"Gol", 140}} {
+ }{
+ {"LATAM Airlines", 340},
+ {"Gol", 140},
+ // Thousands separator, as the page prints large fleets.
+ {"Delta Air Lines", 1900},
+ } {
count := byName[expected.name].NumAircrafts
if count == nil || *count != expected.count {
t.Errorf("%s: got %v aircraft, want %d", expected.name, count, expected.count)
}
}
+
+ // Only the "Largest fleets" section carries a count.
+ if count := byName["Sky2"].NumAircrafts; count != nil {
+ t.Errorf("Sky2: got %d aircraft, want none", *count)
+ }
+}
+
+func TestParseAirlinesHTMLMergesAnAirlineListedInTwoSections(t *testing.T) {
+ // Delta and Gol appear under "Largest fleets" and again under "All airlines".
+ seen := map[string]int{}
+
+ for _, airline := range parseAirlinesHTML(loadFixture(t, "airlines.html")) {
+ seen[airline.Name]++
+ }
+ for _, name := range []string{"Delta Air Lines", "Gol"} {
+ if seen[name] != 1 {
+ t.Errorf("%s: listed %d times, want once", name, seen[name])
+ }
+ }
+}
+
+func TestParseAirlinesHTMLKeepsACodeShapedName(t *testing.T) {
+ tap := airlinesByName(t)["TAP"]
+
+ if tap.Name != "TAP" || tap.ICAO != "TAP" || tap.IATA != "TP" || tap.NumAircrafts != nil {
+ t.Errorf("got %+v, want TAP / TAP / TP with no count", tap)
+ }
}
func TestParseAirlinesHTMLSkipsInvalidRows(t *testing.T) {
airlines := parseAirlinesHTML(loadFixture(t, "airlines.html"))
- // 5 valid rows; 2 invalid (no notranslate, wrong href) must be skipped.
- if len(airlines) != 5 {
- t.Errorf("got %d airlines, want 5", len(airlines))
+ // 6 airlines; the section link, the entry with no link, the wrong href and
+ // the one-letter name must be skipped.
+ if len(airlines) != 6 {
+ t.Errorf("got %d airlines, want 6", len(airlines))
+ }
+ for _, airline := range airlines {
+ if airline.Name == "All" {
+ t.Errorf("the section link was read as an airline: %+v", airline)
+ }
+ }
+}
+
+func TestParseAirlinesHTMLKeepsTheOrderOfThePage(t *testing.T) {
+ var names []string
+
+ for _, airline := range parseAirlinesHTML(loadFixture(t, "airlines.html")) {
+ names = append(names, airline.Name)
+ }
+
+ want := []string{"Delta Air Lines", "LATAM Airlines", "Gol", "Sky2", "SkyTeam", "TAP"}
+
+ if !slices.Equal(names, want) {
+ t.Errorf("got %v, want %v", names, want)
}
}
func TestParseAirlinesHTMLEmptyInputReturnsEmptyList(t *testing.T) {
- for _, page := range []string{"", "
no tbody here
"} {
+ for _, page := range []string{"", "
no airlines here
"} {
if airlines := parseAirlinesHTML([]byte(page)); len(airlines) != 0 {
t.Errorf("got %d airlines for %q, want 0", len(airlines), page)
}
}
}
-// --- parseAirportsJSON ---
+// --- parseInertiaPage ---
-func TestParseAirportsJSONExtractsBasicFields(t *testing.T) {
- byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil))
+func decodeFixture(t *testing.T, name string) map[string]any {
+ t.Helper()
+ var page map[string]any
- for _, code := range []string{"GRU", "GIG"} {
- if _, ok := byIATA[code]; !ok {
- t.Fatalf("missing airport %q", code)
- }
+ if err := json.Unmarshal(loadFixture(t, name), &page); err != nil {
+ t.Fatalf("could not decode fixture %s: %v", name, err)
}
+ return page
+}
- gru := byIATA["GRU"]
+func TestParseInertiaPageReadsTheDataPageAttribute(t *testing.T) {
+ page, ok := parseInertiaPage(loadFixture(t, "airports_index.html"))
- if gru.ICAO != "SBGR" || gru.Country != "Brazil" {
- t.Errorf("GRU: got ICAO %q country %q", gru.ICAO, gru.Country)
- }
- if gru.Latitude == nil || math.Abs(*gru.Latitude-(-23.429991)) > 1e-6 {
- t.Errorf("GRU latitude: got %v", gru.Latitude)
+ if !ok {
+ t.Fatal("no page object found")
}
- if gru.Longitude == nil || math.Abs(*gru.Longitude-(-46.4674)) > 1e-6 {
- t.Errorf("GRU longitude: got %v", gru.Longitude)
+ if page["component"] != "Data/Airports" || page["version"] != "919b94d2a33f365a34f99828f6eeec6a" {
+ t.Errorf("got component %v version %v", page["component"], page["version"])
}
- if gru.Altitude == nil || *gru.Altitude != 2436 {
- t.Errorf("GRU altitude: got %v, want 2436", gru.Altitude)
+ if _, ok := page["props"].(map[string]any)["airportsByCountry"]; !ok {
+ t.Errorf("got props %v, want airportsByCountry among them", page["props"])
}
}
-func TestParseAirportsJSONFormatsAltitudeWithoutDecimals(t *testing.T) {
- // Both other ports report 2436, never 2436.0.
- byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil))
+func TestParseInertiaPageAcceptsADecodedJSONBody(t *testing.T) {
+ body := decodeFixture(t, "airports_country.json")
+ page, ok := parseInertiaPage(body)
- if got := formatNumber(*byIATA["GRU"].Altitude); got != "2436" {
- t.Errorf("got altitude %q, want \"2436\"", got)
+ if !ok || page["component"] != body["component"] {
+ t.Errorf("got %v, want the body handed back", page)
}
}
-func TestParseAirportsJSONKeepsEveryCountryWithoutFilter(t *testing.T) {
- countries := map[string]bool{}
-
- for _, airport := range parseAirportsJSON(loadFixture(t, "airports.json"), nil) {
- countries[airport.Country] = true
+func TestParseInertiaPageAcceptsAString(t *testing.T) {
+ if _, ok := parseInertiaPage(string(loadFixture(t, "airports_index.html"))); !ok {
+ t.Error("the HTML as a string was not read")
}
+}
- for _, country := range []string{"Brazil", "United States", "Spain"} {
- if !countries[country] {
- t.Errorf("missing country %q", country)
+func TestParseInertiaPageRejectsUnrecognisedPayloads(t *testing.T) {
+ for _, payload := range []any{
+ []byte(""),
+ []byte(``),
+ []byte(``),
+ []byte(``),
+ map[string]any{"component": "Data/Airports"},
+ map[string]any{"props": "not an object"},
+ []any{1, 2},
+ nil,
+ 42,
+ } {
+ if page, ok := parseInertiaPage(payload); ok || page != nil {
+ t.Errorf("%v: got %v, want nothing", payload, page)
}
}
}
-func TestParseAirportsJSONFiltersByCountrySlug(t *testing.T) {
- airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{"united-states"})
+// --- parseAirportsIndex ---
+
+func TestParseAirportsIndexListsEveryCountryWithItsSlug(t *testing.T) {
+ countries := parseAirportsIndex(loadFixture(t, "airports_index.html"))
+ var slugs []string
- if len(airports) == 0 {
- t.Fatal("no airports returned")
+ for _, country := range countries {
+ slugs = append(slugs, country.Slug)
}
- for _, airport := range airports {
- if airport.Country != "United States" {
- t.Errorf("got country %q, want United States", airport.Country)
- }
+ if want := []string{"brazil", "myanmar-burma", "uruguay"}; !slices.Equal(slugs, want) {
+ t.Fatalf("got %v, want %v", slugs, want)
+ }
+ if countries[1].Name != "MYANMAR (BURMA)" {
+ t.Errorf("got name %q, want it as FR24 prints it", countries[1].Name)
}
}
-func TestParseAirportsJSONAcceptsSeveralCountries(t *testing.T) {
- airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{"brazil", "spain"})
- countries := map[string]bool{}
+func TestParseAirportsIndexSlugsMatchTheCountryConstants(t *testing.T) {
+ known := map[Country]bool{}
- for _, airport := range airports {
- countries[airport.Country] = true
+ for _, country := range AllCountries() {
+ known[country] = true
}
- if len(countries) != 2 || !countries["Brazil"] || !countries["Spain"] {
- t.Errorf("got countries %v, want Brazil and Spain", countries)
+ for _, country := range parseAirportsIndex(loadFixture(t, "airports_index.html")) {
+ if !known[Country(country.Slug)] {
+ t.Errorf("%q is not a Country constant", country.Slug)
+ }
}
}
-func TestParseAirportsJSONInvalidCoordinatesBecomeNil(t *testing.T) {
- // Regression: invalid coords used to be coerced to (0, 0), placing the
- // airport in the Gulf of Guinea.
- byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil))
- bad := byIATA["BAD"]
-
- if bad == nil {
- t.Fatal("missing airport BAD")
- }
- if bad.Latitude != nil || bad.Longitude != nil {
- t.Errorf("got position (%v, %v), want no position", bad.Latitude, bad.Longitude)
+func TestParseAirportsIndexInvalidPayloadReturnsEmptyList(t *testing.T) {
+ for _, payload := range []any{
+ []byte(""),
+ map[string]any{"props": map[string]any{}},
+ map[string]any{"props": map[string]any{"airportsByCountry": "x"}},
+ map[string]any{"props": map[string]any{"airportsByCountry": []any{1, "x", map[string]any{"name": nil}}}},
+ } {
+ if countries := parseAirportsIndex(payload); len(countries) != 0 {
+ t.Errorf("%v: got %v, want none", payload, countries)
+ }
}
}
-func TestParseAirportsJSONRejectsNumericLookingJunk(t *testing.T) {
- payload, err := json.Marshal(map[string]any{"rows": []any{map[string]any{
- "name": "Junk Airport", "iata": "JNK", "icao": "JJNK",
- "lat": "43.30 N", "lon": -8.37725, "country": "Spain", "alt": "-1",
- }}})
-
- if err != nil {
- t.Fatal(err)
- }
+// --- parseAirportsPage ---
- airport := parseAirportsJSON(payload, nil)[0]
+func iatas(airports []*Airport) []string {
+ codes := make([]string, 0, len(airports))
- // One bad coordinate drops both; altitude is independent and survives.
- if airport.Latitude != nil || airport.Longitude != nil {
- t.Errorf("got position (%v, %v), want no position", airport.Latitude, airport.Longitude)
- }
- if airport.Altitude == nil || *airport.Altitude != -1 {
- t.Errorf("got altitude %v, want -1", airport.Altitude)
+ for _, airport := range airports {
+ codes = append(codes, airport.IATA)
}
+ return codes
}
-func TestParseAirportsJSONAgreesWithOtherPortsOnUnicode(t *testing.T) {
- cases := []struct {
- name string
- value string
- expected *float64
- }{
- // Arabic-Indic digits, which a Unicode-aware \d matched before.
- {"arabic-indic digits", "٤٣", nil},
- // U+FEFF, which JavaScript's trim() drops and Python's strip() keeps.
- {"leading byte-order mark", "\ufeff43", nil},
- // U+001C, which Python's strip() drops and JavaScript's trim() keeps.
- {"leading file separator", "\u001c43", nil},
- {"plain spaces are trimmed", " 43 ", float64Ptr(43)},
+func TestParseAirportsPageExtractsTheAirportsOfACountry(t *testing.T) {
+ airports, statePaths := parseAirportsPage(decodeFixture(t, "airports_country.json"))
+
+ if len(statePaths) != 0 {
+ t.Errorf("got state paths %v, want none", statePaths)
+ }
+ if got := iatas(airports); !slices.Equal(got, []string{"CYR", "MVD"}) {
+ t.Fatalf("got %v, want CYR and MVD", got)
}
- for _, testCase := range cases {
- t.Run(testCase.name, func(t *testing.T) {
- row, err := json.Marshal(map[string]any{
- "name": "X", "iata": "XXX", "icao": "XXXX", "country": "Spain",
- "lat": testCase.value, "lon": testCase.value, "alt": testCase.value,
- })
+ mvd := airports[1]
- if err != nil {
- t.Fatal(err)
- }
+ if mvd.Name != "Montevideo Carrasco International Airport" || mvd.ICAO != "SUMU" || mvd.Country != "Uruguay" {
+ t.Errorf("got %q / %q / %q", mvd.Name, mvd.ICAO, mvd.Country)
+ }
+}
- airport := airportsFromRow(t, string(row))[0]
+func TestParseAirportsPageListingCarriesNoPosition(t *testing.T) {
+ airports, _ := parseAirportsPage(decodeFixture(t, "airports_country.json"))
- if !equalOptional(airport.Altitude, testCase.expected) {
- t.Errorf("got altitude %v, want %v", airport.Altitude, testCase.expected)
- }
- })
+ for _, airport := range airports {
+ if airport.Latitude != nil || airport.Longitude != nil || airport.Altitude != nil {
+ t.Errorf("%s: got position (%v, %v) altitude %v, want none",
+ airport.IATA, airport.Latitude, airport.Longitude, airport.Altitude)
+ }
}
}
-func TestParseAirportsJSONNumericCoercion(t *testing.T) {
- // Keep in step with the same list in the Python and Node.js suites.
- cases := []struct {
- name string
- literal string
- expected *float64
- }{
- {"whitespace", `" "`, nil},
- {"an array holding a number", `[43]`, nil},
- {"a coordinate with a hemisphere suffix", `"43.30 N"`, nil},
- {"an exponent overflowing a double", `"1e999"`, nil},
- {"400 plain digits", `"` + repeat("1", 400) + `"`, nil},
- {"a whole number as a string", `"2436"`, float64Ptr(2436)},
- {"a negative decimal", `-23.4`, float64Ptr(-23.4)},
- {"a genuine zero", `0`, float64Ptr(0)},
- }
-
- for _, testCase := range cases {
- t.Run(testCase.name, func(t *testing.T) {
- row := fmt.Sprintf(
- `{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain","lat":%s,"lon":%s,"alt":%s}`,
- testCase.literal, testCase.literal, testCase.literal)
- airport := airportsFromRow(t, row)[0]
-
- for label, got := range map[string]*float64{
- "latitude": airport.Latitude,
- "longitude": airport.Longitude,
- "altitude": airport.Altitude,
- } {
- if !equalOptional(got, testCase.expected) {
- t.Errorf("%s: got %v, want %v", label, got, testCase.expected)
- }
- }
- })
- }
-}
-
-func TestParseAirportsJSONSurvivesRawNumbersNoDoubleCanHold(t *testing.T) {
- for _, literal := range []string{repeat("1", 400), "9007199254740993", "-" + repeat("1", 400)} {
- row := fmt.Sprintf(
- `{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain","lat":%s,"lon":1,"alt":2}`, literal)
- latitude := airportsFromRow(t, row)[0].Latitude
-
- if latitude != nil && (math.IsInf(*latitude, 0) || math.IsNaN(*latitude)) {
- t.Errorf("%s: got %v, want nil or a finite number", literal, *latitude)
- }
+func TestParseAirportsPageReturnsTheStatePagesOfASplitCountry(t *testing.T) {
+ airports, statePaths := parseAirportsPage(decodeFixture(t, "airports_country_states.json"))
+
+ if len(airports) != 0 {
+ t.Errorf("got %d airports, want none", len(airports))
+ }
+ if want := []string{"/data/airports/brazil/rj", "/data/airports/brazil/sp"}; !slices.Equal(statePaths, want) {
+ t.Errorf("got %v, want %v", statePaths, want)
}
}
-func TestParseAirportsJSONKeepsTextFieldsAsStrings(t *testing.T) {
- // Anything else breaks GetCountryFlag(airport.Country).
- for _, literal := range []string{"null", "0", "false", "true", "[]", "{}", "123"} {
- row := fmt.Sprintf(
- `{"name":%s,"iata":%s,"icao":%s,"country":%s,"lat":1,"lon":2,"alt":3}`,
- literal, literal, literal, literal)
- airport := airportsFromRow(t, row)[0]
-
- if airport.Name != "" || airport.IATA != "" || airport.ICAO != "" || airport.Country != "" {
- t.Errorf("%s: got %q/%q/%q/%q, want empty strings",
- literal, airport.Name, airport.IATA, airport.ICAO, airport.Country)
+func TestParseAirportsPageStatePageKeepsTheCountryName(t *testing.T) {
+ airports, statePaths := parseAirportsPage(decodeFixture(t, "airports_state_sp.json"))
+
+ if len(statePaths) != 0 {
+ t.Errorf("got state paths %v, want none", statePaths)
+ }
+ if got := iatas(airports); !slices.Equal(got, []string{"GRU", "CGH"}) {
+ t.Errorf("got %v, want GRU and CGH", got)
+ }
+ for _, airport := range airports {
+ if airport.Country != "Brazil" {
+ t.Errorf("%s: got country %q, want Brazil", airport.IATA, airport.Country)
}
}
}
-func TestParseAirportsJSONCountrySpellingAndSlugRoundTrip(t *testing.T) {
- byIATA := airportsByIATA(t, parseAirportsJSON(loadFixture(t, "airports.json"), nil))
- ann := byIATA["VBA"]
+func TestParseAirportsPageReadsAnHTMLPage(t *testing.T) {
+ airports, _ := parseAirportsPage(loadFixture(t, "airports_country.html"))
- if ann == nil {
- t.Fatal("missing airport VBA")
+ if got := iatas(airports); !slices.Equal(got, []string{"RGN"}) {
+ t.Fatalf("got %v, want RGN", got)
}
- if ann.Country != "Myanmar (Burma)" {
- t.Errorf("got country %q", ann.Country)
+ // FR24 spells it "Myanmar (Burma)" on the page and "myanmar-burma" in URLs.
+ if airports[0].Country != "Myanmar (Burma)" {
+ t.Errorf("got country %q", airports[0].Country)
}
- if slug := countryToSlug(ann.Country); slug != "myanmar-burma" {
- t.Errorf("got slug %q, want myanmar-burma", slug)
- }
- if ann.Altitude == nil || *ann.Altitude != 43 {
- t.Errorf("got altitude %v, want 43", ann.Altitude)
+ if slug := countryToSlug(airports[0].Country); slug != string(CountryMyanmarBurma) {
+ t.Errorf("got slug %q, want %q", slug, CountryMyanmarBurma)
}
}
-func TestParseAirportsJSONFilterAcceptsEitherCountrySpelling(t *testing.T) {
- for _, spelling := range []Country{"myanmar-burma", "Myanmar (Burma)", CountryMyanmarBurma} {
- airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{spelling})
+func TestParseAirportsPageSkipsRowsThatAreNotObjects(t *testing.T) {
+ payload := map[string]any{"props": map[string]any{
+ "country": map[string]any{"name": "Nowhere"},
+ "airports": []any{1, "x", nil, map[string]any{"iata": "RRR"}},
+ }}
+ airports, _ := parseAirportsPage(payload)
- if len(airports) != 1 {
- t.Errorf("%q: got %d airports, want 1", spelling, len(airports))
- }
+ if got := iatas(airports); !slices.Equal(got, []string{"RRR"}) {
+ t.Errorf("got %v, want only RRR", got)
}
}
-func TestParseAirportsJSONSkipsRowsThatAreNotObjects(t *testing.T) {
- payload := []byte(`{"rows":[[1,2,3],"x",7,null,` +
- `{"name":"Real","iata":"RRR","icao":"RRRR","country":"Spain","lat":1,"lon":2,"alt":3}]}`)
- airports := parseAirportsJSON(payload, nil)
+func TestParseAirportsPageKeepsTextFieldsAsStrings(t *testing.T) {
+ // Anything else breaks GetCountryFlag(airport.Country).
+ payload := map[string]any{"props": map[string]any{
+ "country": map[string]any{"name": []any{"Brazil"}},
+ "airports": []any{map[string]any{"name": 42.0, "iata": nil, "icao": map[string]any{"x": 1.0}}},
+ }}
+ airports, _ := parseAirportsPage(payload)
+
+ if len(airports) != 1 {
+ t.Fatalf("got %d airports, want 1", len(airports))
+ }
+
+ airport := airports[0]
- if len(airports) != 1 || airports[0].IATA != "RRR" {
- t.Errorf("got %d airports, want only RRR", len(airports))
+ if airport.Name != "" || airport.IATA != "" || airport.ICAO != "" || airport.Country != "" {
+ t.Errorf("got %q/%q/%q/%q, want empty strings", airport.Name, airport.IATA, airport.ICAO, airport.Country)
}
}
-func TestParseAirportsJSONUnknownCountryReturnsEmptyList(t *testing.T) {
- if airports := parseAirportsJSON(loadFixture(t, "airports.json"), []Country{"atlantis"}); len(airports) != 0 {
- t.Errorf("got %d airports, want 0", len(airports))
+func TestParseAirportsPageIgnoresMalformedStates(t *testing.T) {
+ payload := map[string]any{"props": map[string]any{"states": []any{
+ map[string]any{"url": "/data/airports/x/a"},
+ map[string]any{"url": ""},
+ map[string]any{"code": "B"},
+ "junk",
+ map[string]any{"url": 3.0},
+ }}}
+ airports, statePaths := parseAirportsPage(payload)
+
+ if len(airports) != 0 || !slices.Equal(statePaths, []string{"/data/airports/x/a"}) {
+ t.Errorf("got %d airports and state paths %v, want only /data/airports/x/a", len(airports), statePaths)
}
}
-func TestParseAirportsJSONInvalidPayloadReturnsEmptyList(t *testing.T) {
- for _, payload := range []string{"", "{}", "not json"} {
- if airports := parseAirportsJSON([]byte(payload), nil); len(airports) != 0 {
- t.Errorf("%q: got %d airports, want 0", payload, len(airports))
+func TestParseAirportsPageInvalidPayloadReturnsNothing(t *testing.T) {
+ for _, payload := range []any{
+ []byte(""),
+ []byte("not a data page"),
+ map[string]any{"props": map[string]any{}},
+ map[string]any{"props": map[string]any{"country": "Brazil", "airports": "none", "states": 7.0}},
+ } {
+ airports, statePaths := parseAirportsPage(payload)
+
+ if airports == nil || statePaths == nil {
+ t.Errorf("%v: got nil slices, want empty ones", payload)
+ }
+ if len(airports) != 0 || len(statePaths) != 0 {
+ t.Errorf("%v: got %d airports and %v, want nothing", payload, len(airports), statePaths)
}
}
}
@@ -431,46 +446,12 @@ func TestCountryConstantsRoundTripThroughTheSlugifier(t *testing.T) {
}
}
-func equalOptional(got, want *float64) bool {
- if got == nil || want == nil {
- return got == nil && want == nil
- }
- return *got == *want
-}
-
// float64Ptr is the shorthand the table-driven tests need for optional numbers.
func float64Ptr(value float64) *float64 { return &value }
-func repeat(text string, times int) string {
- result := make([]byte, 0, len(text)*times)
-
- for range times {
- result = append(result, text...)
- }
- return string(result)
-}
-
-func TestParseAirportsJSONRejectsDataPastTheJSONBody(t *testing.T) {
- // Decode reads the first value only, so a spliced body used to pass.
- payload := []byte(`{"rows":[{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain",` +
- `"lat":1,"lon":2,"alt":3}]}trailing garbage`)
-
- if airports := parseAirportsJSON(payload, nil); len(airports) != 0 {
- t.Errorf("got %d airports, want the body rejected", len(airports))
- }
-
- // Trailing whitespace is not garbage.
- clean := []byte(`{"rows":[{"name":"X","iata":"XXX","icao":"XXXX","country":"Spain",` +
- `"lat":1,"lon":2,"alt":3}]}` + "\n\n ")
-
- if airports := parseAirportsJSON(clean, nil); len(airports) != 1 {
- t.Errorf("got %d airports, want the body accepted", len(airports))
- }
-}
-
func TestTextFieldsReadAPayloadDecodedWithUseNumber(t *testing.T) {
- // This package decodes the airports feed that way, and the exported
- // constructors take a map the caller may have decoded the same way.
+ // The exported constructors take a map the caller may have decoded with
+ // UseNumber, and must read a number there as text.
decoder := json.NewDecoder(strings.NewReader(`{"age": 12, "name": "Lukla"}`))
decoder.UseNumber()
diff --git a/go/flightradarapi/testdata/airlines.html b/go/flightradarapi/testdata/airlines.html
index 3c56b99..020dfd5 100644
--- a/go/flightradarapi/testdata/airlines.html
+++ b/go/flightradarapi/testdata/airlines.html
@@ -1,60 +1,72 @@
-
-