DEV Community

Matvey
Matvey

Posted on

All 47 Days Changed Price in Thirteen Days. None of the Five Cheapest Survived

I run a visa agency in Bali. My clients fly, my staff fly, and half of the Apify Actors I maintain exist because some travel number I needed was trapped in a web page. One of them reads Ryanair: the cheapest fare for every departure date on a route, and the cheapest destinations out of any Ryanair base. I built it as a separate Actor for one airline because Ryanair's fares are thin or missing in the big metasearch engines — on a lot of European routes, comparing prices without Ryanair means comparing without the cheapest ticket.

A while back I pointed a cron job at it to make sure it kept working, with the dullest input I could think of:

{
  "mode": "calendar",
  "origin": "STN",
  "destination": "DUB",
  "departureDate": "2027-03-15",
  "months": 2,
  "currency": "EUR"
}
Enter fullscreen mode Exit fullscreen mode

London Stansted to Dublin, two months of departure dates, five months ahead. 47 rows, about two seconds, a bit over a cent. Each row is a day with the cheapest fare on it:

{
  "type": "calendar_day",
  "direction": "outbound",
  "origin": "STN",
  "destination": "DUB",
  "date": "2027-03-20",
  "price": 27.99,
  "currency": "EUR",
  "departAt": "2027-03-20T06:30:00",
  "arriveAt": "2027-03-20T07:50:00",
  "soldOut": false,
  "unavailable": false,
  "bookingUrl": "https://www.ryanair.com/gb/en/trip/flights/select?adults=1&dateOut=2027-03-20&originIata=STN&destinationIata=DUB&isReturn=false"
}
Enter fullscreen mode Exit fullscreen mode

I was watching for failures, so I never looked at the contents. Last night I did, and realised the job had quietly built something more interesting than a health check: 18 snapshots of the same 47 days, taken between 17 and 30 October. A panel. So I diffed it.

Zero of 47 days held still

First question: how many of those 47 dates kept the same price for all thirteen days?

None. Not one. Every single date moved at least once; the median date moved five times, the busiest moved seven. The widest was 12 April 2027, which wandered between EUR 30.99 and EUR 56.99 — an 84% spread on the same seat on the same day, with no booking involved, just me asking on different mornings.

distinct prices seen per date, across 18 snapshots
  2 prices   5 dates
  3 prices   7 dates
  4 prices   7 dates
  5 prices   9 dates
  6 prices  13 dates
  7 prices   6 dates
  1 price    0 dates
Enter fullscreen mode Exit fullscreen mode

The whole window drifted down: summing all 47 days gives EUR 2,440.51 on 17 October and EUR 2,071.11 on 30 October. 37 dates ended cheaper, 9 ended dearer, 1 came back to where it started. An average move was EUR 4.72; the biggest single step was 25 April 2027 falling from EUR 55.99 to EUR 34.99 between two consecutive runs.

The churn is lumpy, not a smooth decay:

17 Oct -> 18 Oct    1/47 moved
19 Oct -> 20 Oct   19/47 moved
20 Oct -> 21 Oct    1/47 moved
23 Oct -> 24 Oct   25/47 moved
24 Oct -> 26 Oct   27/47 moved
28 Oct -> 30 Oct   28/47 moved  (19 up, 9 down)
Enter fullscreen mode Exit fullscreen mode

Before blaming the airline I had to rule out myself, and the panel happened to contain the control I needed. Two runs went out four minutes apart on 27 October, and two more sat four hours apart on 22 October. Both pairs: 0 of 47 rows different, to the cent. The scraper is boring and deterministic; what moves is the fare.

The part that would have burned me

If this were only noise around a trend, you could average it away. It isn't, because the thing people actually publish out of a fare calendar is not a price — it's a ranking. "Cheapest day to fly Dublin in March." "Our five best dates this week."

Here is what the cheapest day in the window did:

17 Oct    EUR 29.99   20 Mar 2027
24 Oct    EUR 27.99   20 Mar 2027
26 Oct    EUR 24.99   14 Apr 2027
27 Oct    EUR 24.99   13 Apr 2027
28 Oct    EUR 26.99   13 Apr 2027
30 Oct    EUR 27.99   20 Mar 2027
Enter fullscreen mode Exit fullscreen mode

Three different answers to "when is this trip cheapest", and a round trip back to the original one. Fine — that is a 17% price range on one claim.

The top five is worse. I took the five cheapest dates in every snapshot and compared the sets:

dates that appeared in some snapshot's top five:  12
dates that appeared in every snapshot's top five:  0
Enter fullscreen mode Exit fullscreen mode

Twelve candidates for five slots, and no date held a top-five place across all eighteen runs. The first snapshot's list was 15, 18, 20, 21 March and 20 April; the last one's was 20 March, 13, 20, 21, 27 April. Two survivors between the two ends, zero survivors across all eighteen.

So: a fare calendar is a time series with a very short shelf life, and any product that treats one pull as a fact is publishing something that was true on a Tuesday.

How wrong is a cached copy?

That is answerable here, because I have every age from 2 to 13 days against the same reference day. Mean absolute error per date, old snapshot versus 30 October:

age    MAE      worst single date   dates wrong
 2d    2.54 EUR      15.00 EUR         32/47
 4d    2.90 EUR      15.00 EUR         35/47
 7d    4.27 EUR      17.00 EUR         30/47
10d    9.52 EUR      28.00 EUR         43/47
13d   10.64 EUR      28.00 EUR         46/47
Enter fullscreen mode Exit fullscreen mode

A two-day-old calendar is already wrong on two thirds of its rows, but usually by small change. A week old costs you EUR 4.27 on an average date and EUR 17 on the worst one. Two weeks old is not a price list, it is a souvenir. On fares in the EUR 25-70 band, the 13-day error is a quarter of the ticket.

My own takeaway, for a tool that costs a cent per pull: there is no good reason to cache this for more than a day. Re-pull it and diff it.

The quiet one: same price, different plane

While diffing I keyed on price, then on the whole row, and the counts disagreed. Across the 17 steps, departAt changed 216 times — and in 125 of those, the price was byte-identical.

2027-04-08   17 Oct: 55.99, departs 06:30
             18 Oct: 55.99, departs 20:00
2027-04-03   17 Oct: 73.99, departs 11:10
             18 Oct: 73.99, departs 06:25
Enter fullscreen mode Exit fullscreen mode

Same money, a different flight. Ryanair flies Stansted-Dublin many times a day, and "cheapest fare of the day" is a competition between those flights; the winner changes while the winning number doesn't. One date in my window produced eight different departure times over thirteen days at only four different prices.

That matters the moment a human reads your output. A EUR 55.99 breakfast flight and a EUR 55.99 flight that lands near midnight are not the same offer — and if your change detector diffs on price, it will tell your users nothing changed. Diff on the pair:

def changed(old, new):
    key = lambda r: (r["date"], r["price"], r["departAt"])
    before = {r["date"]: key(r) for r in old if r["type"] == "calendar_day"}
    for r in new:
        if r["type"] != "calendar_day":
            continue              # the free run-summary row, filter it out
        if before.get(r["date"]) != key(r):
            yield r
Enter fullscreen mode Exit fullscreen mode

Two more things the panel taught me

The decline wasn't where I assumed. Split the window in half: 15-31 March went from an average of EUR 51.43 to EUR 50.77 — basically flat, down 1.3%. April went from EUR 52.21 to EUR 40.27, down 22.9%. All of the drop lived in the second month. I have one route and thirteen days, so I am not going to tell you why; I will only say that if you were modelling "fares fall as the date approaches" with one smooth curve, this window would have embarrassed you.

Flags that never fired. 18 snapshots x 47 dates = 846 observations, and soldOut and unavailable were false in all 846. They exist for days Ryanair genuinely cannot sell, so a gap in your chart is a real gap rather than a missing row — but on a trunk route five months out, nothing triggers them. Don't build logic you can't test on this kind of input; point it at a thin seasonal route first.

Calling it

One route, no browser, no proxy — it talks to Ryanair's own fare service over plain HTTP, which is why it finishes in about two seconds:

curl -X POST "https://api.apify.com/v2/acts/lergassy~ryanair-flights-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"mode":"calendar","origin":"STN","destination":"DUB","departureDate":"2027-03-15","months":2,"currency":"EUR"}'
Enter fullscreen mode Exit fullscreen mode

Batch several routes in one run with routes: [{origin, destination, departureDate}, ...], or flip mode to destinations for the cheapest places out of one base, cheapest first. Turn on returnTrip and you get the cheapest fare per day in both directions — 20 November out of Stansted was EUR 20.11 out and EUR 19.99 back, and pairing them by stay length is your job (note the inbound rows carry no bookingUrl; build it from the IATA pair and the date). Pricing is per row, nothing for runs or retries, and every run ends with a free row that tells you exactly what you were charged:

Run summary: 47 row(s) delivered, 0 free; charged fare x47 = $0.01222.
This row is free and is not part of your data - filter it out with type != "notice".
Enter fullscreen mode Exit fullscreen mode

The Actor is here: Ryanair Flights Scraper.

When not to use it. It is one airline — if your question is "who is cheapest on this route", you need Ryanair next to the others, which is a different job (Flight Price Comparison does that one). It returns the fare Ryanair shows on its calendar, which excludes bags, seats and priority, so it is a floor, not a basket total. Ryanair publishes its schedule some months ahead, so ask for twelve months and the far end comes back empty rather than wrong. And it is not a booking API: bookingUrl opens the date in Ryanair's own flow, nothing more.

The thing I'd want to have known before building a price alert on top of it: the expensive mistake isn't a scrape that fails. It's a scrape that succeeds once and gets trusted for a week.

Top comments (0)