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:
parent
5583a20143
commit
89a536dfd3
8 changed files with 515 additions and 83 deletions
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue