Add Eurostar Plus prices and NR advance fare support

- Eurostar scraper now fetches both Standard and Plus (PLUS class code)
  prices/seats in a single API call; each service dict gains plus_price
  and plus_seats fields
- GWR fares scraper gains fetch_advance() which makes two sets of
  paginated calls (standard advance + first-class advance) and returns
  cheapest per departure; shared _run_pages() generator reduces
  duplication in fetch()
- New /api/advance_fares/<station_crs>/<travel_date> endpoint returns
  advance fares as JSON, cached for 24 hours
- Results page gains NR ticket selector (Walk-on / Std Advance / 1st
  Advance) and Eurostar selector (Standard / Plus); total column is
  JS-computed from the selected combination with cheapest/priciest
  highlighting
- Load advance prices button fetches the API lazily; if advance fares
  are already cached they are embedded in the page and applied on load
  so the button is hidden automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-11 16:22:24 +01:00
parent 5583a20143
commit 89a536dfd3
8 changed files with 515 additions and 83 deletions

34
app.py
View file

@ -2,7 +2,7 @@
Combine GWR BristolPaddington trains with Eurostar St Pancrasdestination trains.
"""
from flask import Flask, render_template, redirect, url_for, request, abort
from flask import Flask, render_template, redirect, url_for, request, abort, jsonify
from datetime import date, timedelta
from pathlib import Path
import os
@ -155,10 +155,12 @@ def results(station_crs, slug, travel_date):
rtt_cache_key = f"rtt_{station_crs}_{travel_date}"
es_cache_key = f"eurostar_{travel_date}_{destination}"
gwr_fares_cache_key = f"gwr_fares_{station_crs}_{travel_date}"
gwr_advance_cache_key = f"gwr_advance_{station_crs}_{travel_date}"
cached_rtt = get_cached(rtt_cache_key)
cached_es = get_cached(es_cache_key, ttl=24 * 3600)
cached_gwr_fares = get_cached(gwr_fares_cache_key, ttl=30 * 24 * 3600)
cached_advance_fares = get_cached(gwr_advance_cache_key, ttl=24 * 3600)
from_cache = bool(cached_rtt and cached_es)
error = None
@ -197,7 +199,12 @@ def results(station_crs, slug, travel_date):
eurostar_trains = eurostar_services
eurostar_prices = {
s["depart_st_pancras"]: {"price": s.get("price"), "seats": s.get("seats")}
s["depart_st_pancras"]: {
"price": s.get("price"),
"seats": s.get("seats"),
"plus_price": s.get("plus_price"),
"plus_seats": s.get("plus_seats"),
}
for s in eurostar_services
}
@ -210,12 +217,14 @@ def results(station_crs, slug, travel_date):
gwr_fares,
)
# Annotate each trip with Eurostar Standard price, seats, and total cost
# Annotate each trip with Eurostar prices and total cost (walk-on + standard)
for trip in trips:
es = eurostar_prices.get(trip["depart_st_pancras"], {})
es_price = es.get("price")
trip["eurostar_price"] = es_price
trip["eurostar_seats"] = es.get("seats")
trip["eurostar_plus_price"] = es.get("plus_price")
trip["eurostar_plus_seats"] = es.get("plus_seats")
gwr_p = trip.get("ticket_price")
trip["total_price"] = (
gwr_p + es_price if (gwr_p is not None and es_price is not None) else None
@ -241,6 +250,8 @@ def results(station_crs, slug, travel_date):
es = eurostar_prices.get(svc["depart_st_pancras"], {})
svc["eurostar_price"] = es.get("price")
svc["eurostar_seats"] = es.get("seats")
svc["eurostar_plus_price"] = es.get("plus_price")
svc["eurostar_plus_seats"] = es.get("plus_seats")
# Only keep unreachable services that depart before the first reachable Eurostar.
# Services after the first reachable one are omitted (they aren't "Too early").
@ -301,10 +312,27 @@ def results(station_crs, slug, travel_date):
default_max_connection=default_max,
url_min_connection=url_min,
url_max_connection=url_max,
cached_advance_fares=cached_advance_fares,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
valid_max_connections=sorted(VALID_MAX_CONNECTIONS),
)
@app.route("/api/advance_fares/<station_crs>/<travel_date>")
def api_advance_fares(station_crs, travel_date):
if station_crs not in STATION_BY_CRS:
abort(404)
cache_key = f"gwr_advance_{station_crs}_{travel_date}"
cached = get_cached(cache_key, ttl=24 * 3600)
if cached is not None:
return jsonify(cached)
try:
fares = gwr_fares_scraper.fetch_advance(station_crs, travel_date)
set_cached(cache_key, fares)
return jsonify(fares)
except Exception as e:
return jsonify({"error": str(e)}), 500
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0")