Show Eurostar Standard prices and total journey cost on results page

Fetches prices via the site-api.eurostar.com GraphQL gateway
(NewBookingSearch operation, discovered with Playwright). Adds
fetch_prices() to scraper/eurostar.py using requests, caches results,
annotates each trip with eurostar_price and total_price, and shows an
ES Std column plus total cost (duration + price) in the results table.
The Transfer column is hidden on small screens for mobile usability.

Closes #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-04 10:38:09 +01:00
parent 804fcedfad
commit 0dee942e16
4 changed files with 191 additions and 12 deletions

29
app.py
View file

@ -8,6 +8,7 @@ from cache import get_cached, set_cached
import scraper.eurostar as eurostar_scraper import scraper.eurostar as eurostar_scraper
import scraper.realtime_trains as rtt_scraper import scraper.realtime_trains as rtt_scraper
from trip_planner import combine_trips, find_unreachable_morning_eurostars from trip_planner import combine_trips, find_unreachable_morning_eurostars
from scraper.eurostar import fetch_prices as fetch_eurostar_prices
RTT_PADDINGTON_URL = ( RTT_PADDINGTON_URL = (
"https://www.realtimetrains.co.uk/search/detailed/" "https://www.realtimetrains.co.uk/search/detailed/"
@ -80,10 +81,12 @@ def results(slug, travel_date):
rtt_cache_key = f"rtt_{travel_date}" rtt_cache_key = f"rtt_{travel_date}"
es_cache_key = f"eurostar_{travel_date}_{destination}" es_cache_key = f"eurostar_{travel_date}_{destination}"
prices_cache_key = f"eurostar_prices_{travel_date}_{destination}"
cached_rtt = get_cached(rtt_cache_key) cached_rtt = get_cached(rtt_cache_key)
cached_es = get_cached(es_cache_key) cached_es = get_cached(es_cache_key)
from_cache = bool(cached_rtt and cached_es) cached_prices = get_cached(prices_cache_key)
from_cache = bool(cached_rtt and cached_es and cached_prices)
error = None error = None
@ -108,7 +111,28 @@ def results(slug, travel_date):
msg = f"Could not fetch Eurostar times: {e}" msg = f"Could not fetch Eurostar times: {e}"
error = f"{error}; {msg}" if error else msg error = f"{error}; {msg}" if error else msg
if cached_prices:
eurostar_prices = cached_prices
else:
try:
eurostar_prices = fetch_eurostar_prices(destination, travel_date)
set_cached(prices_cache_key, eurostar_prices)
except Exception as e:
eurostar_prices = {}
msg = f"Could not fetch Eurostar prices: {e}"
error = f"{error}; {msg}" if error else msg
trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection) trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection)
# Annotate each trip with Eurostar Standard price and total cost
for trip in trips:
es_price = eurostar_prices.get(trip['depart_st_pancras'])
trip['eurostar_price'] = es_price
if es_price is not None:
trip['total_price'] = trip['ticket_price'] + es_price
else:
trip['total_price'] = None
unreachable_morning_services = find_unreachable_morning_eurostars( unreachable_morning_services = find_unreachable_morning_eurostars(
gwr_trains, gwr_trains,
eurostar_trains, eurostar_trains,
@ -116,6 +140,9 @@ def results(slug, travel_date):
min_connection, min_connection,
max_connection, max_connection,
) )
for svc in unreachable_morning_services:
svc['eurostar_price'] = eurostar_prices.get(svc['depart_st_pancras'])
result_rows = sorted( result_rows = sorted(
[{'row_type': 'trip', **trip} for trip in trips] [{'row_type': 'trip', **trip} for trip in trips]
+ [{'row_type': 'unreachable', **service} for service in unreachable_morning_services], + [{'row_type': 'unreachable', **service} for service in unreachable_morning_services],

View file

@ -1,7 +1,7 @@
""" """
Scrape Eurostar timetable via httpx. Scrape Eurostar timetable via httpx and fetch prices via the GraphQL API.
The route-specific timetable pages are Next.js SSR all departure data is Timetable: route-specific pages are Next.js SSR all departure data is
embedded in <script id="__NEXT_DATA__"> as JSON, so no browser / JS needed. embedded in <script id="__NEXT_DATA__"> as JSON, so no browser / JS needed.
URL pattern: URL pattern:
@ -12,10 +12,19 @@ Data path: props.pageProps.pageData.liveDepartures[]
.origin.model.scheduledDepartureDateTime London departure .origin.model.scheduledDepartureDateTime London departure
.destination.model.scheduledArrivalDateTime destination arrival .destination.model.scheduledArrivalDateTime destination arrival
(already filtered to the requested stop, not the final stop) (already filtered to the requested stop, not the final stop)
Prices: POST https://site-api.eurostar.com/gateway (GraphQL, operationName
NewBookingSearch). The `journeys[].fares[]` array contains one entry per
class of service; we extract the Eurostar Standard (classOfService.code ==
"STANDARD") displayPrice for 1 adult, in GBP.
""" """
import json import json
import random
import re import re
import string
import httpx import httpx
import requests
DEFAULT_UA = ( DEFAULT_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
@ -90,3 +99,105 @@ def fetch(destination: str, travel_date: str,
r = client.get(url, params={'date': travel_date}) r = client.get(url, params={'date': travel_date})
r.raise_for_status() r.raise_for_status()
return _parse(r.text, destination) return _parse(r.text, destination)
# ---------------------------------------------------------------------------
# Price fetching via site-api.eurostar.com GraphQL
# ---------------------------------------------------------------------------
_GATEWAY_URL = 'https://site-api.eurostar.com/gateway'
# Minimal query requesting only timing + Eurostar Standard fare price.
# Variable names and inline argument names match what the site sends so the
# server-side query planner sees a familiar shape.
_GQL_PRICES = (
"query NewBookingSearch("
"$origin:String!,$destination:String!,$outbound:String!,"
"$currency:Currency!,$adult:Int,"
"$filteredClassesOfService:[ClassOfServiceEnum]"
"){"
"journeySearch("
"outboundDate:$outbound origin:$origin destination:$destination"
" adults:$adult currency:$currency"
" productFamilies:[\"PUB\"] contractCode:\"EIL_ALL\""
" adults16Plus:0 children:0 youths:0 children4Only:0 children5To11:0"
" infants:0 adultsWheelchair:0 childrenWheelchair:0 guideDogs:0"
" wheelchairCompanions:0 nonWheelchairCompanions:0"
" isAftersales:false multipleFlexibility:true showAllSummatedFares:false"
" seniorsAges:[] prioritiseShortHaulODTrains:true"
"){"
"outbound{"
"journeys("
"hideIndirectTrainsWhenDisruptedAndCancelled:false"
" hideDepartedTrains:true"
" hideExternalCarrierTrains:true"
" hideDirectExternalCarrierTrains:true"
"){"
"timing{departureTime:departs __typename}"
"fares(filteredClassesOfService:$filteredClassesOfService){"
"classOfService{code __typename}"
"prices{displayPrice __typename}"
"seats __typename"
"}"
"__typename"
"}"
"__typename"
"}"
"__typename"
"}"
"}"
)
def _generate_cid() -> str:
chars = string.ascii_letters + string.digits
return 'SRCH-' + ''.join(random.choices(chars, k=22))
def fetch_prices(destination: str, travel_date: str) -> dict[str, int | None]:
"""
Return Eurostar Standard prices for every departure on travel_date.
Result: {depart_st_pancras: price_gbp_int_or_None}
None means the class is sold out or unavailable for that departure.
"""
dest_id = DESTINATION_STATION_IDS[destination]
headers = {
'User-Agent': DEFAULT_UA,
'Content-Type': 'application/json',
'Accept': '*/*',
'Accept-Language': 'en-GB',
'Referer': 'https://www.eurostar.com/',
'x-platform': 'web',
'x-market-code': 'uk',
'x-source-url': 'search-app/',
'cid': _generate_cid(),
}
payload = {
'operationName': 'NewBookingSearch',
'variables': {
'origin': ORIGIN_STATION_ID,
'destination': dest_id,
'outbound': travel_date,
'currency': 'GBP',
'adult': 1,
'filteredClassesOfService': ['STANDARD'],
},
'query': _GQL_PRICES,
}
resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20)
resp.raise_for_status()
data = resp.json()
prices: dict[str, int | None] = {}
journeys = data['data']['journeySearch']['outbound']['journeys']
for journey in journeys:
dep = journey['timing']['departureTime']
price = None
for fare in journey['fares']:
if fare['classOfService']['code'] == 'STANDARD':
p = fare.get('prices')
if p and p.get('displayPrice'):
price = int(p['displayPrice'])
break
prices[dep] = price
return prices

View file

@ -5,6 +5,11 @@
{% block twitter_title %}Bristol to {{ destination }} via Eurostar{% endblock %} {% block twitter_title %}Bristol to {{ destination }} via Eurostar{% endblock %}
{% block twitter_description %}Train options from Bristol Temple Meads to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %} {% block twitter_description %}Train options from Bristol Temple Meads to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
{% block content %} {% block content %}
<style>
@media (max-width: 640px) {
.col-transfer { display: none; }
}
</style>
<p style="margin-bottom:1rem"> <p style="margin-bottom:1rem">
<a href="{{ url_for('index') }}">&larr; New search</a> <a href="{{ url_for('index') }}">&larr; New search</a>
@ -100,10 +105,10 @@
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Bristol</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">Bristol</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Paddington</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">Paddington</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">GWR Fare</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">GWR Fare</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Transfer</th> <th class="col-transfer" style="padding:0.6rem 0.8rem;white-space:nowrap">Transfer</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Depart St&nbsp;Pancras</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">Depart St&nbsp;Pancras</th>
<th style="padding:0.6rem 0.8rem">{{ destination }} <th style="padding:0.6rem 0.8rem">{{ destination }}</th>
</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">ES Std</th>
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Total</th> <th style="padding:0.6rem 0.8rem;white-space:nowrap">Total</th>
</tr> </tr>
</thead> </thead>
@ -138,7 +143,7 @@
£{{ "%.2f"|format(row.ticket_price) }} £{{ "%.2f"|format(row.ticket_price) }}
<br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span> <br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span>
</td> </td>
<td style="padding:0.6rem 0.8rem;color:#4a5568"> <td class="col-transfer" style="padding:0.6rem 0.8rem;color:#4a5568">
{{ row.connection_duration }} {{ row.connection_duration }}
</td> </td>
<td style="padding:0.6rem 0.8rem;font-weight:600"> <td style="padding:0.6rem 0.8rem;font-weight:600">
@ -149,7 +154,14 @@
{{ row.arrive_destination }} {{ row.arrive_destination }}
<span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span> <span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span>
</td> </td>
<td style="padding:0.6rem 0.8rem;font-weight:600"> <td style="padding:0.6rem 0.8rem;white-space:nowrap">
{% if row.eurostar_price is not none %}
£{{ row.eurostar_price }}
{% else %}
<span style="color:#718096">&ndash;</span>
{% endif %}
</td>
<td style="padding:0.6rem 0.8rem;font-weight:600;white-space:nowrap">
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %} {% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
<span style="color:#276749" title="Fastest option">{{ row.total_duration }} ⚡</span> <span style="color:#276749" title="Fastest option">{{ row.total_duration }} ⚡</span>
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %} {% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
@ -157,11 +169,14 @@
{% else %} {% else %}
<span style="color:#00539f">{{ row.total_duration }}</span> <span style="color:#00539f">{{ row.total_duration }}</span>
{% endif %} {% endif %}
{% if row.total_price is not none %}
<br><span style="font-size:0.8rem;font-weight:700;color:#276749">£{{ "%.2f"|format(row.total_price) }}</span>
{% endif %}
</td> </td>
{% else %} {% else %}
<td style="padding:0.6rem 0.8rem;font-weight:600">&mdash;</td> <td style="padding:0.6rem 0.8rem;font-weight:600">&mdash;</td>
<td style="padding:0.6rem 0.8rem">&mdash;</td> <td style="padding:0.6rem 0.8rem">&mdash;</td>
<td style="padding:0.6rem 0.8rem">&mdash;</td> <td class="col-transfer" style="padding:0.6rem 0.8rem">&mdash;</td>
<td style="padding:0.6rem 0.8rem">Unavailable</td> <td style="padding:0.6rem 0.8rem">Unavailable</td>
<td style="padding:0.6rem 0.8rem;font-weight:600"> <td style="padding:0.6rem 0.8rem;font-weight:600">
{{ row.depart_st_pancras }} {{ row.depart_st_pancras }}
@ -171,8 +186,15 @@
{{ row.arrive_destination }} {{ row.arrive_destination }}
<span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span> <span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span>
</td> </td>
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
{% if row.eurostar_price is not none %}
<span style="color:#a0aec0">£{{ row.eurostar_price }}</span>
{% else %}
<span style="color:#a0aec0">&ndash;</span>
{% endif %}
</td>
<td style="padding:0.6rem 0.8rem;font-weight:600"> <td style="padding:0.6rem 0.8rem;font-weight:600">
<span title="No same-day Bristol connection">Unavailable from Bristol</span> <span title="No same-day Bristol connection" style="color:#a0aec0">Unavailable from Bristol</span>
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
@ -186,7 +208,8 @@
{% if unreachable_morning_services %} {% if unreachable_morning_services %}
Morning means Eurostar departures before 12:00 from St&nbsp;Pancras. Morning means Eurostar departures before 12:00 from St&nbsp;Pancras.
{% endif %} {% endif %}
Eurostar times are from the general timetable and may vary; always check GWR walk-on single prices for Bristol Temple Meads&nbsp;&rarr;&nbsp;Paddington.
Eurostar Standard 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_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a> <a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>

View file

@ -6,7 +6,7 @@ def _client():
return app_module.app.test_client() return app_module.app.test_client()
def _stub_data(monkeypatch): def _stub_data(monkeypatch, prices=None):
monkeypatch.setattr(app_module, 'get_cached', lambda key: None) monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr( monkeypatch.setattr(
@ -33,6 +33,8 @@ def _stub_data(monkeypatch):
'timetable_url', 'timetable_url',
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}', lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
) )
_prices = prices if prices is not None else {}
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: _prices)
def test_index_shows_fixed_departure_and_destination_radios(): def test_index_shows_fixed_departure_and_destination_radios():
@ -94,6 +96,7 @@ def test_results_title_and_social_meta_include_destination(monkeypatch):
def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypatch): def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key: None) monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
monkeypatch.setattr( monkeypatch.setattr(
app_module.rtt_scraper, app_module.rtt_scraper,
'fetch', 'fetch',
@ -165,6 +168,7 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
def test_results_shows_unreachable_morning_eurostar_services(monkeypatch): def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key: None) monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
monkeypatch.setattr( monkeypatch.setattr(
app_module.rtt_scraper, app_module.rtt_scraper,
'fetch', 'fetch',
@ -215,9 +219,23 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
assert html.index('09:30') < html.index('10:15') assert html.index('09:30') < html.index('10:15')
def test_results_shows_eurostar_price_and_total(monkeypatch):
# 07:00 on Friday 2026-04-10 → Anytime £138.70 (weekday, 05:0508:25 window)
_stub_data(monkeypatch, prices={'10:01': 59})
client = _client()
resp = client.get('/results/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 # Eurostar Standard price
assert '£197.70' in html # Anytime £138.70 + ES £59
def test_results_can_show_only_unreachable_morning_services(monkeypatch): def test_results_can_show_only_unreachable_morning_services(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key: None) monkeypatch.setattr(app_module, 'get_cached', lambda key: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
monkeypatch.setattr( monkeypatch.setattr(
app_module.rtt_scraper, app_module.rtt_scraper,
'fetch', 'fetch',