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. 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 datetime import date, timedelta
from pathlib import Path from pathlib import Path
import os import os
@ -155,10 +155,12 @@ def results(station_crs, slug, travel_date):
rtt_cache_key = f"rtt_{station_crs}_{travel_date}" rtt_cache_key = f"rtt_{station_crs}_{travel_date}"
es_cache_key = f"eurostar_{travel_date}_{destination}" es_cache_key = f"eurostar_{travel_date}_{destination}"
gwr_fares_cache_key = f"gwr_fares_{station_crs}_{travel_date}" 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_rtt = get_cached(rtt_cache_key)
cached_es = get_cached(es_cache_key, ttl=24 * 3600) cached_es = get_cached(es_cache_key, ttl=24 * 3600)
cached_gwr_fares = get_cached(gwr_fares_cache_key, ttl=30 * 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) from_cache = bool(cached_rtt and cached_es)
error = None error = None
@ -197,7 +199,12 @@ def results(station_crs, slug, travel_date):
eurostar_trains = eurostar_services eurostar_trains = eurostar_services
eurostar_prices = { 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 for s in eurostar_services
} }
@ -210,12 +217,14 @@ def results(station_crs, slug, travel_date):
gwr_fares, 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: for trip in trips:
es = eurostar_prices.get(trip["depart_st_pancras"], {}) es = eurostar_prices.get(trip["depart_st_pancras"], {})
es_price = es.get("price") es_price = es.get("price")
trip["eurostar_price"] = es_price trip["eurostar_price"] = es_price
trip["eurostar_seats"] = es.get("seats") 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") gwr_p = trip.get("ticket_price")
trip["total_price"] = ( trip["total_price"] = (
gwr_p + es_price if (gwr_p is not None and es_price is not None) else None 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"], {}) es = eurostar_prices.get(svc["depart_st_pancras"], {})
svc["eurostar_price"] = es.get("price") svc["eurostar_price"] = es.get("price")
svc["eurostar_seats"] = es.get("seats") 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. # Only keep unreachable services that depart before the first reachable Eurostar.
# Services after the first reachable one are omitted (they aren't "Too early"). # 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, default_max_connection=default_max,
url_min_connection=url_min, url_min_connection=url_min,
url_max_connection=url_max, url_max_connection=url_max,
cached_advance_fares=cached_advance_fares,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
valid_max_connections=sorted(VALID_MAX_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__": if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0") app.run(debug=True, host="0.0.0.0")

17
more_prices.md Normal file
View file

@ -0,0 +1,17 @@
Right now the site just shows National Rail (NR) walk on fares in Standard Class and Eurostar Standard prices.
I'd like to see more prices, specifically:
Eurostar Plus
National Rail Standard advance tickets
National Rail First class advance tickets
Optionally we could include First class walk on fares, although they'll be expensive, I wouldn't never buy one.
Our existing Eurostar scraper is already getting the Eurostar Plus prices, we could have a way to make them appear in the UI.
Getting NR advanced fares is more tricky, we need to scrape the GWR booking system, it'll take multiple requests. We shouldn't get this price data by default. We could have a button to get the extra GWR prices then for each NR service show standard walk on, standard advance and 1st class advance prices.
Not sure what to do about total price column, lots of combinations now. Maybe selectors so the user can pick which type of ticket they want for NR plus the type for Eurostar.
I hope that makes some sense, can you have a go implementing. Ask me if I've left anything out.

View file

@ -68,6 +68,9 @@ _GQL_QUERY = (
"}" "}"
) )
_STANDARD = 'STANDARD'
_STANDARD_PLUS = 'PLUS'
def search_url(destination: str, travel_date: str) -> str: def search_url(destination: str, travel_date: str) -> str:
dest_id = DESTINATION_STATION_IDS[destination] 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. Parse a NewBookingSearch GraphQL response into a list of service dicts.
Each dict contains: depart_st_pancras, arrive_destination, destination, 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 The same St Pancras departure can appear multiple times (different
connecting trains); we keep the entry with the earliest arrival. 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: for journey in journeys:
dep = journey['timing']['departureTime'] dep = journey['timing']['departureTime']
arr = journey['timing']['arrivalTime'] arr = journey['timing']['arrivalTime']
for fare in journey['fares']: std_price = std_seats = plus_price = plus_seats = None
if fare['classOfService']['code'] == 'STANDARD': train_number = ''
for fare in (journey.get('fares') or []):
cos = fare['classOfService']['code']
p = fare.get('prices') p = fare.get('prices')
price = float(p['displayPrice']) if p and p.get('displayPrice') else None price = float(p['displayPrice']) if p and p.get('displayPrice') else None
seats = fare.get('seats') seats = fare.get('seats')
if not train_number:
legs = fare.get('legs') or [] legs = fare.get('legs') or []
train_number = ' + '.join( train_number = ' + '.join(
f"{(leg.get('serviceType') or {}).get('code', 'ES')} {leg['serviceName']}" f"{(leg.get('serviceType') or {}).get('code', 'ES')} {leg['serviceName']}"
for leg in legs if leg.get('serviceName') for leg in legs if leg.get('serviceName')
) )
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']: if dep not in best or arr < best[dep]['arrive_destination']:
best[dep] = { best[dep] = {
'depart_st_pancras': dep, 'depart_st_pancras': dep,
'arrive_destination': arr, 'arrive_destination': arr,
'destination': destination, 'destination': destination,
'train_number': train_number, 'train_number': train_number,
'price': price, 'price': std_price,
'seats': seats, 'seats': std_seats,
'plus_price': plus_price,
'plus_seats': plus_seats,
} }
break
return sorted(best.values(), key=lambda s: s['depart_st_pancras']) 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, 'outbound': travel_date,
'currency': 'GBP', 'currency': 'GBP',
'adult': 1, 'adult': 1,
'filteredClassesOfService': ['STANDARD'], 'filteredClassesOfService': [_STANDARD, _STANDARD_PLUS],
}, },
'query': _GQL_QUERY, '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 is embedded in the GWR web app (appvalues.prod.json)
_API_KEY = "OgovGqAlLp4gWAhL7DQLo7pMCt8GHi2U4SPFiZgG" _API_KEY = "OgovGqAlLp4gWAhL7DQLo7pMCt8GHi2U4SPFiZgG"
_PAD_CODE = "GBQQP" # London Paddington cluster code as used by GWR website _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 _MAX_PAGES = 20
@ -67,38 +67,52 @@ 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}} Yields (dep_time, fares_list) for each unique departure time seen.
where price is in £ and only the cheapest available standard-class ticket first_class=True switches the request to first class fares.
per departure (with restrictions already applied by GWR) is kept.
""" """
result: dict[str, dict] = {} seen: set[str] = set()
with httpx.Client(headers=_headers(), timeout=30) as client: with httpx.Client(headers=_headers(), timeout=30) as client:
conversation_token = None conversation_token = None
later = False later = False
for _ in range(_MAX_PAGES): for _ in range(_MAX_PAGES):
body = _request_body(station_crs, travel_date, conversation_token, later) 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 = client.post(_API_URL, json=body)
resp.raise_for_status() resp.raise_for_status()
data = resp.json().get("data", {}) data = resp.json().get("data", {})
conversation_token = data.get("conversationToken") conversation_token = data.get("conversationToken")
for journey in data.get("outwardOpenPureReturnFare", []): for journey in data.get("outwardOpenPureReturnFare", []):
dep_iso = journey.get("departureTime", "") dep_iso = journey.get("departureTime", "")
dep_time = dep_iso[11:16] # "HH:MM" from "2026-04-10T09:08:00" 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 continue
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 cheapest = None
for fare in journey.get("journeyFareDetails", []): for fare in fares:
code = fare.get("ticketTypeCode") code = fare.get("ticketTypeCode")
if code not in _WANTED_CODES: if code not in _WALKON_CODES:
continue continue
if not fare.get("isStandardClass"): if not fare.get("isStandardClass"):
continue continue
@ -110,16 +124,71 @@ def fetch(station_crs: str, travel_date: str) -> dict[str, dict]:
"price_pence": price_pence, "price_pence": price_pence,
"code": code, "code": code,
} }
if cheapest: if cheapest:
result[dep_time] = { result[dep_time] = {
"ticket": cheapest["ticket"], "ticket": cheapest["ticket"],
"price": cheapest["price"], "price": cheapest["price"],
"code": cheapest["code"], "code": cheapest["code"],
} }
if not data.get("showLaterOutward", False):
break
later = True
return result 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
}

View file

@ -248,8 +248,19 @@
.date-nav { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } .date-nav { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.switcher-section { margin: 0.9rem 0 1rem; } .switcher-section { margin: 0.9rem 0 1rem; }
.section-label { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.45rem; } .section-label { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.45rem; }
.filter-row { margin-top: 0.75rem; display: flex; gap: 1.5rem; align-items: center; } .filter-row { margin-top: 0.75rem; display: flex; gap: 1.5rem; align-items: center; flex-wrap: wrap; }
.filter-label { font-size: 0.9rem; font-weight: 600; margin-right: 0.5rem; } .filter-label { font-size: 0.9rem; font-weight: 600; margin-right: 0.5rem; }
.btn-secondary {
padding: 0.3rem 0.75rem;
border: 1px solid #00539f;
border-radius: 4px;
color: #00539f;
background: #fff;
cursor: pointer;
font-size: 0.9rem;
}
.btn-secondary:hover { background: #f8fbff; }
.btn-secondary:disabled { opacity: 0.5; cursor: default; }
.card-meta { color: #4a5568; margin: 0; } .card-meta { color: #4a5568; margin: 0; }
.footnote { margin-top: 1rem; font-size: 0.82rem; color: #718096; } .footnote { margin-top: 1rem; font-size: 0.82rem; color: #718096; }

View file

@ -95,6 +95,28 @@
{% if trips or unreachable_morning_services %} {% if trips or unreachable_morning_services %}
<div class="card"> <div class="card">
{% if trips %}
<div class="filter-row" style="margin-bottom:0.75rem;margin-top:0">
<div>
<label for="nr-type-select" class="filter-label">NR ticket:</label>
<select id="nr-type-select" class="select-inline" onchange="updatePrices()">
<option value="walkon">Walk-on</option>
<option value="advanceStd" disabled>Std Advance</option>
<option value="advanceFirst" disabled>1st Class Advance</option>
</select>
</div>
<div>
<label for="es-type-select" class="filter-label">Eurostar:</label>
<select id="es-type-select" class="select-inline" onchange="updatePrices()">
<option value="esStd">Standard</option>
<option value="esPlus">Plus</option>
</select>
</div>
<button id="load-advance-btn" class="btn-secondary" onclick="loadAdvanceFares()">
Load advance prices
</button>
</div>
{% endif %}
<table class="results-table"> <table class="results-table">
<thead> <thead>
<tr> <tr>
@ -108,11 +130,6 @@
{% if trips %} {% if trips %}
{% set best_mins = trips | map(attribute='total_minutes') | min %} {% set best_mins = trips | map(attribute='total_minutes') | min %}
{% set worst_mins = trips | map(attribute='total_minutes') | max %} {% set worst_mins = trips | map(attribute='total_minutes') | max %}
{% set priced_trips = trips | selectattr('total_price') | list %}
{% if priced_trips | length > 1 %}
{% set min_price = priced_trips | map(attribute='total_price') | min %}
{% set max_price = priced_trips | map(attribute='total_price') | max %}
{% endif %}
{% endif %} {% endif %}
{% for row in result_rows %} {% for row in result_rows %}
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %} {% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
@ -126,7 +143,16 @@
{% else %} {% else %}
{% set row_class = '' %} {% set row_class = '' %}
{% endif %} {% endif %}
<tr class="{{ row_class }}"> <tr class="{{ row_class }}"
{% if row.row_type == 'trip' %}
data-depart="{{ row.depart_bristol }}"
data-walkon="{{ row.ticket_price if row.ticket_price is not none else '' }}"
data-advance-std=""
data-advance-first=""
data-es-std="{{ row.eurostar_price if row.eurostar_price is not none else '' }}"
data-es-plus="{{ row.eurostar_plus_price if row.eurostar_plus_price is not none else '' }}"
{% endif %}
>
{% if row.row_type == 'trip' %} {% if row.row_type == 'trip' %}
<td> <td>
<span class="font-bold nowrap">{{ row.depart_bristol }} &rarr; {{ row.arrive_paddington }}</span> <span class="font-bold nowrap">{{ row.depart_bristol }} &rarr; {{ row.arrive_paddington }}</span>
@ -144,6 +170,12 @@
{% else %} {% else %}
<br><span class="text-sm text-muted">&ndash;</span> <br><span class="text-sm text-muted">&ndash;</span>
{% endif %} {% endif %}
<span class="nr-advance-std-display" style="display:none">
<br><span class="text-xs text-muted">Adv std: </span><span class="text-sm nr-advance-std-text"></span>
</span>
<span class="nr-advance-first-display" style="display:none">
<br><span class="text-xs text-muted">Adv 1st: </span><span class="text-sm nr-advance-first-text"></span>
</span>
</td> </td>
<td class="col-transfer" style="color:#4a5568"> <td class="col-transfer" style="color:#4a5568">
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span> <span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
@ -167,12 +199,14 @@
{% endif %} {% endif %}
{% if row.eurostar_price is not none %} {% if row.eurostar_price is not none %}
<br><span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span> <br><span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>
{% if row.eurostar_seats is not none %} <span class="text-xs text-muted">Std{% if row.eurostar_seats is not none %} &middot; {{ row.eurostar_seats }}{% endif %}</span>
<span class="text-xs text-muted">{{ row.eurostar_seats }} at this price</span>
{% endif %}
{% else %} {% else %}
<br><span class="text-sm text-muted">&ndash;</span> <br><span class="text-sm text-muted">&ndash;</span>
{% endif %} {% endif %}
{% if row.eurostar_plus_price is not none %}
<br><span class="text-sm">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>
<span class="text-xs text-muted">Plus{% if row.eurostar_plus_seats is not none %} &middot; {{ row.eurostar_plus_seats }}{% endif %}</span>
{% endif %}
</td> </td>
<td class="font-bold nowrap"> <td class="font-bold nowrap">
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %} {% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
@ -182,9 +216,7 @@
{% else %} {% else %}
<span class="text-blue">{{ row.total_duration }}</span> <span class="text-blue">{{ row.total_duration }}</span>
{% endif %} {% endif %}
{% if row.total_price is not none %} <span class="total-price-span"></span>
<br><span class="text-sm text-green" style="font-weight:700">£{{ "%.2f"|format(row.total_price) }}{% if min_price is defined and max_price is defined %}{% if row.total_price <= min_price + 10 %} <span title="Cheapest journey">🪙</span>{% elif row.total_price >= max_price - 10 %} <span title="Most expensive journey">💸</span>{% endif %}{% endif %}</span>
{% endif %}
</td> </td>
{% else %} {% else %}
<td> <td>
@ -202,12 +234,14 @@
{% endif %} {% endif %}
{% if row.eurostar_price is not none %} {% if row.eurostar_price is not none %}
<br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span> <br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span>
{% if row.eurostar_seats is not none %} <span class="text-xs text-dimmed">Std{% if row.eurostar_seats is not none %} &middot; {{ row.eurostar_seats }}{% endif %}</span>
<span class="text-xs text-dimmed">{{ row.eurostar_seats }} at this price</span>
{% endif %}
{% else %} {% else %}
<br><span class="text-sm text-dimmed">&ndash;</span> <br><span class="text-sm text-dimmed">&ndash;</span>
{% endif %} {% endif %}
{% if row.eurostar_plus_price is not none %}
<br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>
<span class="text-xs text-dimmed">Plus{% if row.eurostar_plus_seats is not none %} &middot; {{ row.eurostar_plus_seats }}{% endif %}</span>
{% endif %}
</td> </td>
<td class="text-dimmed">&mdash;</td> <td class="text-dimmed">&mdash;</td>
{% endif %} {% endif %}
@ -221,7 +255,7 @@
Paddington &rarr; St&nbsp;Pancras connection: {{ min_connection }}&ndash;{{ max_connection }}&nbsp;min. Paddington &rarr; St&nbsp;Pancras connection: {{ min_connection }}&ndash;{{ max_connection }}&nbsp;min.
GWR walk-on single prices from GWR walk-on single prices from
<a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>. <a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
Eurostar Standard prices are for 1 adult in GBP; always check Eurostar Standard and Plus prices are for 1 adult in GBP; always check
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book. <a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
<a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} departures on RTT</a> <a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} departures on RTT</a>
@ -229,6 +263,117 @@
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a> <a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
</p> </p>
<script>
var stationCrs = {{ station_crs | tojson }};
var travelDate = {{ travel_date | tojson }};
var cachedAdvanceFares = {{ cached_advance_fares | tojson }};
function updatePrices() {
var nrSel = document.getElementById('nr-type-select').value;
var esSel = document.getElementById('es-type-select').value;
var rows = document.querySelectorAll('tr[data-depart]');
var validTotals = [];
rows.forEach(function(row) {
var nrVal = row.dataset[nrSel];
var esVal = row.dataset[esSel];
var totalSpan = row.querySelector('.total-price-span');
if (!totalSpan) return;
var nrPrice = nrVal !== '' && nrVal !== undefined ? parseFloat(nrVal) : null;
var esPrice = esVal !== '' && esVal !== undefined ? parseFloat(esVal) : null;
if (nrPrice !== null && esPrice !== null) {
var total = nrPrice + esPrice;
totalSpan.dataset.total = total;
validTotals.push(total);
} else {
delete totalSpan.dataset.total;
totalSpan.innerHTML = '';
}
});
var minTotal = validTotals.length ? Math.min.apply(null, validTotals) : null;
var maxTotal = validTotals.length ? Math.max.apply(null, validTotals) : null;
rows.forEach(function(row) {
var totalSpan = row.querySelector('.total-price-span');
if (!totalSpan || !('total' in totalSpan.dataset)) return;
var total = parseFloat(totalSpan.dataset.total);
var html = '<br><span class="text-sm text-green" style="font-weight:700">£' + total.toFixed(2);
if (minTotal !== null && maxTotal !== null && validTotals.length > 1) {
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">\uD83E\uDE99</span>';
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">\uD83D\uDCB8</span>';
}
html += '</span>';
totalSpan.innerHTML = html;
});
}
function loadAdvanceFares() {
var btn = document.getElementById('load-advance-btn');
btn.disabled = true;
btn.textContent = 'Loading\u2026';
fetch('/api/advance_fares/' + stationCrs + '/' + travelDate)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
btn.textContent = 'Error';
btn.disabled = false;
return;
}
applyAdvanceFares(data);
updatePrices();
})
.catch(function() {
btn.textContent = 'Error \u2014 try again';
btn.disabled = false;
});
}
function applyAdvanceFares(data) {
var hasStd = false, hasFirst = false;
document.querySelectorAll('tr[data-depart]').forEach(function(row) {
var dep = row.dataset.depart;
var fares = data[dep];
if (!fares) return;
if (fares.advance_std) {
row.dataset.advanceStd = fares.advance_std.price;
var span = row.querySelector('.nr-advance-std-display');
var text = row.querySelector('.nr-advance-std-text');
if (span && text) {
text.textContent = '\u00a3' + fares.advance_std.price.toFixed(2) + ' ' + fares.advance_std.ticket;
span.style.display = '';
}
hasStd = true;
}
if (fares.advance_1st) {
row.dataset.advanceFirst = fares.advance_1st.price;
var span1 = row.querySelector('.nr-advance-first-display');
var text1 = row.querySelector('.nr-advance-first-text');
if (span1 && text1) {
text1.textContent = '\u00a3' + fares.advance_1st.price.toFixed(2) + ' ' + fares.advance_1st.ticket;
span1.style.display = '';
}
hasFirst = true;
}
});
var nrSelect = document.getElementById('nr-type-select');
if (nrSelect) {
if (hasStd) nrSelect.querySelector('[value="advanceStd"]').disabled = false;
if (hasFirst) nrSelect.querySelector('[value="advanceFirst"]').disabled = false;
}
var btn = document.getElementById('load-advance-btn');
if (btn) btn.style.display = 'none';
}
document.addEventListener('DOMContentLoaded', function() {
if (cachedAdvanceFares) applyAdvanceFares(cachedAdvanceFares);
updatePrices();
});
</script>
{% else %} {% else %}
<div class="card empty-state"> <div class="card empty-state">
<p>No valid journeys found.</p> <p>No valid journeys found.</p>

View file

@ -33,6 +33,8 @@ def _stub_data(monkeypatch, prices=None, gwr_fares=None):
'train_number': 'ES 9014', 'train_number': 'ES 9014',
'price': p.get('price') if isinstance(p, dict) else None, 'price': p.get('price') if isinstance(p, dict) else None,
'seats': p.get('seats') if isinstance(p, dict) else None, 'seats': p.get('seats') if isinstance(p, dict) else None,
'plus_price': p.get('plus_price') if isinstance(p, dict) else None,
'plus_seats': p.get('plus_seats') if isinstance(p, dict) else None,
}, },
], ],
) )
@ -173,7 +175,7 @@ def test_results_shows_only_pre_first_reachable_unreachable_services(monkeypatch
def test_results_shows_eurostar_price_and_total(monkeypatch): def test_results_shows_eurostar_price_and_total(monkeypatch):
# 07:00 on Friday 2026-04-10 → Anytime £138.70 (weekday, 05:0508:25 window) # 07:00 on Friday 2026-04-10 → Anytime £138.70 walk-on + ES £59.00
_stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}}) _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}})
client = _client() client = _client()
@ -182,7 +184,10 @@ def test_results_shows_eurostar_price_and_total(monkeypatch):
assert resp.status_code == 200 assert resp.status_code == 200
assert '£59' in html # Eurostar Standard price assert '£59' in html # Eurostar Standard price
assert '£197.70' in html # Anytime £138.70 + ES £59 assert '£138.70' in html # Walk-on price shown in NR cell
# Total (£197.70) is computed client-side; verify data attributes carry the right values
assert 'data-walkon="138.7"' in html
assert 'data-es-std="59"' in html
def test_results_shows_unreachable_service_when_no_trips(monkeypatch): def test_results_shows_unreachable_service_when_no_trips(monkeypatch):
@ -214,3 +219,119 @@ def test_results_shows_unreachable_service_when_no_trips(monkeypatch):
assert 'ES 9001' in html assert 'ES 9001' in html
assert 'Too early' in html assert 'Too early' in html
assert 'No valid journeys found.' not in html assert 'No valid journeys found.' not in html
def test_results_shows_eurostar_plus_price(monkeypatch):
_stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42, 'plus_price': 89, 'plus_seats': 5}})
client = _client()
resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert '£59' in html # Standard price
assert '£89' in html # Plus price
assert 'Plus' in html # Plus label
def test_results_selectors_present(monkeypatch):
_stub_data(monkeypatch)
client = _client()
resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert 'nr-type-select' in html
assert 'es-type-select' in html
assert 'Load advance prices' in html
assert 'Plus' in html
def test_results_preloads_cached_advance_fares(monkeypatch):
advance_data = {
'07:00': {
'advance_std': {'ticket': 'Advance Single', 'price': 45.0, 'code': 'ADV'},
'advance_1st': None,
}
}
def fake_get_cached(key, ttl=None):
if 'gwr_advance' in key:
return advance_data
return None
monkeypatch.setattr(app_module, 'get_cached', fake_get_cached)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper, 'fetch',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr(
app_module.eurostar_scraper, 'fetch',
lambda destination, travel_date: [
{'depart_st_pancras': '10:01', 'arrive_destination': '13:34',
'destination': destination, 'train_number': 'ES 9014',
'price': None, 'seats': None, 'plus_price': None, 'plus_seats': None},
],
)
client = _client()
resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
# Cached advance fares are embedded in the page JS
assert '"advance_std"' in html
assert '45.0' in html
# Button is absent (hidden via cachedAdvanceFares check in JS)
# The JS will hide it on load; the data is present for applyAdvanceFares()
assert 'cachedAdvanceFares' in html
def test_api_advance_fares_returns_json(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
'fetch_advance',
lambda station_crs, travel_date: {
'07:00': {
'advance_std': {'ticket': 'Advance Single', 'price': 45.0, 'code': 'ADV'},
'advance_1st': {'ticket': '1st Advance', 'price': 65.0, 'code': 'AFA'},
}
},
)
client = _client()
resp = client.get('/api/advance_fares/BRI/2026-04-10')
data = resp.get_json()
assert resp.status_code == 200
assert '07:00' in data
assert data['07:00']['advance_std']['price'] == 45.0
assert data['07:00']['advance_1st']['price'] == 65.0
def test_api_advance_fares_404_for_unknown_station(monkeypatch):
client = _client()
resp = client.get('/api/advance_fares/XYZ/2026-04-10')
assert resp.status_code == 404
def test_api_advance_fares_returns_error_on_scraper_failure(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
'fetch_advance',
lambda s, d: (_ for _ in ()).throw(Exception('network error')),
)
client = _client()
resp = client.get('/api/advance_fares/BRI/2026-04-10')
data = resp.get_json()
assert resp.status_code == 500
assert 'error' in data

View file

@ -6,16 +6,25 @@ def _gql_response(journeys: list) -> dict:
return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}} return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}}
def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES') -> dict: def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES',
return { plus_price=None, plus_seats=None) -> dict:
'timing': {'departureTime': departs, 'arrivalTime': arrives}, fares = [{
'fares': [{
'classOfService': {'code': 'STANDARD'}, 'classOfService': {'code': 'STANDARD'},
'prices': {'displayPrice': price}, 'prices': {'displayPrice': price},
'seats': seats, 'seats': seats,
'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}] 'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}]
if service_name else [], if service_name else [],
}], }]
if plus_price is not None or plus_seats is not None:
fares.append({
'classOfService': {'code': 'PLUS'},
'prices': {'displayPrice': plus_price},
'seats': plus_seats,
'legs': [],
})
return {
'timing': {'departureTime': departs, 'arrivalTime': arrives},
'fares': fares,
} }
@ -34,6 +43,27 @@ def test_parse_graphql_single_journey():
assert s['train_number'] == 'ES 9014' assert s['train_number'] == 'ES 9014'
assert s['price'] == 156.0 assert s['price'] == 156.0
assert s['seats'] == 37 assert s['seats'] == 37
assert s['plus_price'] is None
assert s['plus_seats'] is None
def test_parse_graphql_standard_premier_price():
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014',
plus_price=220, plus_seats=12)])
services = _parse_graphql(data, 'Paris Gare du Nord')
assert len(services) == 1
s = services[0]
assert s['price'] == 156.0
assert s['seats'] == 37
assert s['plus_price'] == 220.0
assert s['plus_seats'] == 12
def test_parse_graphql_plus_price_none_when_not_returned():
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37)])
services = _parse_graphql(data, 'Paris Gare du Nord')
assert services[0]['plus_price'] is None
assert services[0]['plus_seats'] is None
def test_parse_graphql_half_pound_price(): def test_parse_graphql_half_pound_price():