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
|
|
@ -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