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

View file

@ -68,6 +68,9 @@ _GQL_QUERY = (
"}"
)
_STANDARD = 'STANDARD'
_STANDARD_PLUS = 'PLUS'
def search_url(destination: str, travel_date: str) -> str:
dest_id = DESTINATION_STATION_IDS[destination]
@ -87,7 +90,7 @@ def _parse_graphql(data: dict, destination: str) -> list[dict]:
Parse a NewBookingSearch GraphQL response into a list of service dicts.
Each dict contains: depart_st_pancras, arrive_destination, destination,
train_number, price (float or None), seats (int or None).
train_number, price/seats (Standard), plus_price/plus_seats (Standard Premier).
The same St Pancras departure can appear multiple times (different
connecting trains); we keep the entry with the earliest arrival.
@ -98,26 +101,34 @@ def _parse_graphql(data: dict, destination: str) -> list[dict]:
for journey in journeys:
dep = journey['timing']['departureTime']
arr = journey['timing']['arrivalTime']
for fare in journey['fares']:
if fare['classOfService']['code'] == 'STANDARD':
p = fare.get('prices')
price = float(p['displayPrice']) if p and p.get('displayPrice') else None
seats = fare.get('seats')
std_price = std_seats = plus_price = plus_seats = None
train_number = ''
for fare in (journey.get('fares') or []):
cos = fare['classOfService']['code']
p = fare.get('prices')
price = float(p['displayPrice']) if p and p.get('displayPrice') else None
seats = fare.get('seats')
if not train_number:
legs = fare.get('legs') or []
train_number = ' + '.join(
f"{(leg.get('serviceType') or {}).get('code', 'ES')} {leg['serviceName']}"
for leg in legs if leg.get('serviceName')
)
if dep not in best or arr < best[dep]['arrive_destination']:
best[dep] = {
'depart_st_pancras': dep,
'arrive_destination': arr,
'destination': destination,
'train_number': train_number,
'price': price,
'seats': seats,
}
break
if cos == _STANDARD:
std_price, std_seats = price, seats
elif cos == _STANDARD_PLUS:
plus_price, plus_seats = price, seats
if dep not in best or arr < best[dep]['arrive_destination']:
best[dep] = {
'depart_st_pancras': dep,
'arrive_destination': arr,
'destination': destination,
'train_number': train_number,
'price': std_price,
'seats': std_seats,
'plus_price': plus_price,
'plus_seats': plus_seats,
}
return sorted(best.values(), key=lambda s: s['depart_st_pancras'])
@ -148,7 +159,7 @@ def fetch(destination: str, travel_date: str) -> list[dict]:
'outbound': travel_date,
'currency': 'GBP',
'adult': 1,
'filteredClassesOfService': ['STANDARD'],
'filteredClassesOfService': [_STANDARD, _STANDARD_PLUS],
},
'query': _GQL_QUERY,
}

View file

@ -12,7 +12,7 @@ _API_URL = "https://api.gwr.com/api/shopping/journeysearch"
# API key is embedded in the GWR web app (appvalues.prod.json)
_API_KEY = "OgovGqAlLp4gWAhL7DQLo7pMCt8GHi2U4SPFiZgG"
_PAD_CODE = "GBQQP" # London Paddington cluster code as used by GWR website
_WANTED_CODES = {"SSS", "SVS", "SDS", "CDS"}
_WALKON_CODES = {"SSS", "SVS", "SDS", "CDS"}
_MAX_PAGES = 20
@ -67,59 +67,128 @@ def _request_body(
}
def fetch(station_crs: str, travel_date: str) -> dict[str, dict]:
def _run_pages(station_crs: str, travel_date: str, first_class: bool = False):
"""
Fetch GWR single fares from station_crs to London Paddington on travel_date.
Iterate all pages of GWR journey search results.
Returns {departure_time: {'ticket': name, 'price': float, 'code': code}}
where price is in £ and only the cheapest available standard-class ticket
per departure (with restrictions already applied by GWR) is kept.
Yields (dep_time, fares_list) for each unique departure time seen.
first_class=True switches the request to first class fares.
"""
result: dict[str, dict] = {}
seen: set[str] = set()
with httpx.Client(headers=_headers(), timeout=30) as client:
conversation_token = None
later = False
for _ in range(_MAX_PAGES):
body = _request_body(station_crs, travel_date, conversation_token, later)
if first_class:
body["firstclass"] = True
body["standardclass"] = False
resp = client.post(_API_URL, json=body)
resp.raise_for_status()
data = resp.json().get("data", {})
conversation_token = data.get("conversationToken")
for journey in data.get("outwardOpenPureReturnFare", []):
dep_iso = journey.get("departureTime", "")
dep_time = dep_iso[11:16] # "HH:MM" from "2026-04-10T09:08:00"
if not dep_time or dep_time in result:
if not dep_time or dep_time in seen:
continue
cheapest = None
for fare in journey.get("journeyFareDetails", []):
code = fare.get("ticketTypeCode")
if code not in _WANTED_CODES:
continue
if not fare.get("isStandardClass"):
continue
price_pence = fare.get("fare", 0)
if cheapest is None or price_pence < cheapest["price_pence"]:
cheapest = {
"ticket": fare.get("ticketType", ""),
"price": price_pence / 100,
"price_pence": price_pence,
"code": code,
}
if cheapest:
result[dep_time] = {
"ticket": cheapest["ticket"],
"price": cheapest["price"],
"code": cheapest["code"],
}
seen.add(dep_time)
yield dep_time, journey.get("journeyFareDetails", [])
if not data.get("showLaterOutward", False):
break
later = True
def fetch(station_crs: str, travel_date: str) -> dict[str, dict]:
"""
Fetch GWR walk-on single fares from station_crs to London Paddington on travel_date.
Returns {departure_time: {'ticket': name, 'price': float, 'code': code}}
where price is in £ and only the cheapest available standard-class walk-on
ticket per departure (with restrictions already applied by GWR) is kept.
"""
result: dict[str, dict] = {}
for dep_time, fares in _run_pages(station_crs, travel_date):
cheapest = None
for fare in fares:
code = fare.get("ticketTypeCode")
if code not in _WALKON_CODES:
continue
if not fare.get("isStandardClass"):
continue
price_pence = fare.get("fare", 0)
if cheapest is None or price_pence < cheapest["price_pence"]:
cheapest = {
"ticket": fare.get("ticketType", ""),
"price": price_pence / 100,
"price_pence": price_pence,
"code": code,
}
if cheapest:
result[dep_time] = {
"ticket": cheapest["ticket"],
"price": cheapest["price"],
"code": cheapest["code"],
}
return result
def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]:
"""
Fetch advance fares: cheapest standard advance and first-class advance per departure.
Makes two sets of paginated API calls (standard class, then first class).
Returns {departure_time: {'advance_std': dict or None, 'advance_1st': dict or None}}
where each sub-dict has keys 'ticket', 'price', 'code'.
"""
std_advance: dict[str, dict] = {}
for dep_time, fares in _run_pages(station_crs, travel_date, first_class=False):
cheapest = None
for fare in fares:
code = fare.get("ticketTypeCode")
if code in _WALKON_CODES:
continue # skip walk-on fares
if not fare.get("isStandardClass"):
continue
price_pence = fare.get("fare", 0)
if cheapest is None or price_pence < cheapest["price_pence"]:
cheapest = {
"ticket": fare.get("ticketType", ""),
"price": price_pence / 100,
"price_pence": price_pence,
"code": code,
}
if cheapest:
std_advance[dep_time] = {
"ticket": cheapest["ticket"],
"price": cheapest["price"],
"code": cheapest["code"],
}
first_advance: dict[str, dict] = {}
for dep_time, fares in _run_pages(station_crs, travel_date, first_class=True):
cheapest = None
for fare in fares:
price_pence = fare.get("fare", 0)
if cheapest is None or price_pence < cheapest["price_pence"]:
cheapest = {
"ticket": fare.get("ticketType", ""),
"price": price_pence / 100,
"price_pence": price_pence,
"code": fare.get("ticketTypeCode"),
}
if cheapest:
first_advance[dep_time] = {
"ticket": cheapest["ticket"],
"price": cheapest["price"],
"code": cheapest["code"],
}
all_times = set(std_advance) | set(first_advance)
return {
t: {
"advance_std": std_advance.get(t),
"advance_1st": first_advance.get(t),
}
for t in all_times
}