Compare commits

...

7 commits

Author SHA1 Message Date
71be0dd8cf Move inline styles to CSS classes; update README
Extract repeated inline styles from templates into named CSS classes in
base.html: layout helpers, buttons, form groups, alert boxes, results table
rules, row highlight classes, typography utilities, and empty-state styles.
Remove the per-page <style> block from results.html.

Update README to reflect current destinations, GraphQL data source, Circle
Line timetable, configurable connection range, and GWR fare table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:39:04 +01:00
e6f310f517 Add Cologne Hbf destination; use coin emoji for cheapest journey
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 15:02:01 +01:00
c22a3ea0fc Consolidate to single GraphQL call; show indirect trains; fix price formatting
Replace two-step Eurostar fetch (HTML timetable + GraphQL prices) with a
single GraphQL call that returns timing, train numbers, prices, and seats.
Support indirect services (e.g. Amsterdam) by joining multi-leg train numbers
with ' + ' and keeping the earliest arrival per departure time.
Fix half-pound prices by casting displayPrice to float instead of int.
Wrap each train number segment in white-space:nowrap so 'ES 9132 + ER 9363'
never breaks mid-segment.
Format Eurostar prices with two decimal places.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:46:22 +01:00
05eec29b7d Show Eurostar seat availability and no-prices notice
fetch_prices now returns {'price': ..., 'seats': ...} per departure.
Seat count (labelled "N at this price") is shown below the fare — it
reflects price-band depth rather than total remaining seats. A yellow
notice is shown when the API returns journeys but all prices are null
(tickets not yet on sale).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:12:54 +01:00
cd37f0619b Add config system with TFL_DATA_DIR and CACHE_DIR
config/default.py holds defaults using ~/lib/data/tfl (expanduser, so safe
to commit). app.py loads it then overlays config/local.py if present, pushing
paths into cache and circle_line modules. config/local.py is gitignored for
machine-specific absolute paths (e.g. on the server where www-data runs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:37:44 +01:00
c215456620 Use real Circle Line timetable; add Eurostar duration
Parse Circle Line times from TransXChange XML (output_txc_01CIR_.xml) with
separate weekday/Saturday/Sunday schedules, replacing the approximated
every-10-minutes pattern. Subtract 1 hour timezone offset (CET/CEST vs
GMT/BST) when computing Eurostar journey duration, shown for both viable
and unreachable services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:26:35 +01:00
60674fe663 Add Circle Line timetable info. 2026-04-04 12:58:19 +01:00
14 changed files with 626 additions and 418 deletions

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ venv/
# App
cache/
config/local.py
# Pytest
.pytest_cache/

View file

@ -2,9 +2,9 @@
Source: https://git.4angle.com/edward/bristol-eurostar
Plan a trip from Bristol Temple Meads to Europe on Eurostar.
Plan a trip from Bristol Temple Meads to Europe via Eurostar.
Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras → destination) and shows all valid same-day connections, filtering by journey time and minimum/maximum transfer window at Paddington/St Pancras.
Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras → destination) and shows all valid same-day connections, including Circle Line times for the Paddington → St Pancras transfer. Displays GWR walk-on fares, Eurostar Standard prices, seat availability, and total journey cost.
## Destinations
@ -12,23 +12,42 @@ Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras
- Brussels Midi
- Lille Europe
- Amsterdam Centraal
- Rotterdam Centraal
- Cologne Hbf
## How it works
Train times are fetched from two sources simultaneously:
Train times and prices are fetched from two sources:
- **GWR** — scraped from [Realtime Trains](https://www.realtimetrains.co.uk/) using httpx
- **Eurostar** — scraped from the Eurostar timetable pages via the embedded `__NEXT_DATA__` JSON (no browser required)
- **Eurostar** — fetched from the Eurostar GraphQL API (single call returns timetable, Standard fares, and seat availability)
The Paddington → St Pancras transfer uses a real Circle Line timetable parsed from a TfL TransXChange XML file, accounting for walk time to the platform at Paddington and walk time from the platform to the St Pancras check-in.
Results are cached to disk by date and destination.
## Connection constraints
Configurable via the search form. Defaults:
| | |
|---|---|
| Minimum Paddington → St Pancras | 75 min |
| Maximum Paddington → St Pancras | 2h 20m |
| Maximum Bristol → Paddington | 1h 50m |
| Minimum Paddington → St Pancras | 50 min |
| Maximum Paddington → St Pancras | 110 min |
Valid range: 45120 min (min), 60180 min (max).
## GWR fares
Walk-on single fares for Bristol Temple Meads → Paddington, selected automatically by departure time:
| Ticket | Price | Restriction (weekdays) |
|---|---|---|
| Super Off-Peak | £45.00 | Not valid 05:0509:57 |
| Off-Peak | £63.60 | Not valid before 08:26 |
| Anytime | £138.70 | No restriction |
Weekends always use Super Off-Peak.
## Setup
@ -36,6 +55,19 @@ Results are cached to disk by date and destination.
pip install -e ".[dev]"
```
### Configuration
Copy or create `config/local.py` (gitignored) to override defaults:
```python
CACHE_DIR = '/var/cache/bristol-eurostar'
CIRCLE_LINE_XML = '/path/to/output_txc_01CIR_.xml'
```
Defaults (in `config/default.py`) use `~/lib/data/tfl/`.
The Circle Line XML is a TfL TransXChange timetable file. The Paddington (H&C Line) stop is `9400ZZLUPAH1`; the King's Cross St Pancras stop is `9400ZZLUKSX3`.
## Running
```bash

64
app.py
View file

@ -3,12 +3,12 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination t
"""
from flask import Flask, render_template, redirect, url_for, request
from datetime import date, timedelta
import os
from cache import get_cached, set_cached
import scraper.eurostar as eurostar_scraper
import scraper.realtime_trains as rtt_scraper
from trip_planner import combine_trips, find_unreachable_morning_eurostars
from scraper.eurostar import fetch_prices as fetch_eurostar_prices
RTT_PADDINGTON_URL = (
"https://www.realtimetrains.co.uk/search/detailed/"
@ -22,7 +22,16 @@ RTT_BRISTOL_URL = (
"?stp=WVS&show=pax-calls&order=wtt"
)
app = Flask(__name__)
app = Flask(__name__, instance_relative_config=False)
app.config.from_object('config.default')
_local = os.path.join(os.path.dirname(__file__), 'config', 'local.py')
if os.path.exists(_local):
app.config.from_pyfile(_local)
import cache
import circle_line
cache.CACHE_DIR = app.config['CACHE_DIR']
circle_line._TXC_XML = app.config['CIRCLE_LINE_XML']
DESTINATIONS = {
'paris': 'Paris Gare du Nord',
@ -30,6 +39,7 @@ DESTINATIONS = {
'lille': 'Lille Europe',
'amsterdam': 'Amsterdam Centraal',
'rotterdam': 'Rotterdam Centraal',
'cologne': 'Cologne Hbf',
}
@ -93,12 +103,10 @@ def results(slug, travel_date):
rtt_cache_key = f"rtt_{travel_date}"
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_es = get_cached(es_cache_key)
cached_prices = get_cached(prices_cache_key, ttl=24 * 3600)
from_cache = bool(cached_rtt and cached_es and cached_prices)
cached_es = get_cached(es_cache_key, ttl=24 * 3600)
from_cache = bool(cached_rtt and cached_es)
error = None
@ -113,37 +121,36 @@ def results(slug, travel_date):
error = f"Could not fetch GWR trains: {e}"
if cached_es:
eurostar_trains = cached_es
eurostar_services = cached_es
else:
try:
eurostar_trains = eurostar_scraper.fetch(destination, travel_date, user_agent)
set_cached(es_cache_key, eurostar_trains)
eurostar_services = eurostar_scraper.fetch(destination, travel_date)
set_cached(es_cache_key, eurostar_services)
except Exception as e:
eurostar_trains = []
eurostar_services = []
msg = f"Could not fetch Eurostar times: {e}"
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
eurostar_trains = eurostar_services
eurostar_prices = {
s['depart_st_pancras']: {'price': s.get('price'), 'seats': s.get('seats')}
for s in eurostar_services
}
trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection)
# Annotate each trip with Eurostar Standard price and total cost
# Annotate each trip with Eurostar Standard price, seats, and total cost
for trip in trips:
es_price = eurostar_prices.get(trip['depart_st_pancras'])
es = eurostar_prices.get(trip['depart_st_pancras'], {})
es_price = es.get('price')
trip['eurostar_price'] = es_price
if es_price is not None:
trip['total_price'] = trip['ticket_price'] + es_price
else:
trip['total_price'] = None
trip['eurostar_seats'] = es.get('seats')
trip['total_price'] = trip['ticket_price'] + es_price if es_price is not None else None
# If the API returned journeys but every price is None, tickets aren't on sale yet
no_prices_note = None
if eurostar_prices and all(v.get('price') is None for v in eurostar_prices.values()):
no_prices_note = 'Eurostar prices not yet available — tickets may not be on sale yet.'
unreachable_morning_services = find_unreachable_morning_eurostars(
gwr_trains,
@ -153,7 +160,9 @@ def results(slug, travel_date):
max_connection,
)
for svc in unreachable_morning_services:
svc['eurostar_price'] = eurostar_prices.get(svc['depart_st_pancras'])
es = eurostar_prices.get(svc['depart_st_pancras'], {})
svc['eurostar_price'] = es.get('price')
svc['eurostar_seats'] = es.get('seats')
result_rows = sorted(
[{'row_type': 'trip', **trip} for trip in trips]
@ -186,6 +195,7 @@ def results(slug, travel_date):
eurostar_count=len(eurostar_trains),
from_cache=from_cache,
error=error,
no_prices_note=no_prices_note,
eurostar_url=eurostar_url,
rtt_url=rtt_url,
rtt_bristol_url=rtt_bristol_url,

View file

@ -2,7 +2,7 @@ import json
import os
import time
CACHE_DIR = os.path.join(os.path.dirname(__file__), 'cache')
from config.default import CACHE_DIR # overridden by app config after import
def _cache_path(key: str) -> str:

148
circle_line.py Normal file
View file

@ -0,0 +1,148 @@
"""
Circle Line timetable: Paddington (H&C Line) King's Cross St Pancras.
Parses the TransXChange XML file on first use and caches the result in memory.
"""
import os
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
_PAD_STOP = '9400ZZLUPAH1' # Paddington (H&C Line)
_KXP_STOP = '9400ZZLUKSX3' # King's Cross St Pancras
from config.default import CIRCLE_LINE_XML as _TXC_XML # overridden by app config after import
_NS = {'t': 'http://www.transxchange.org.uk/'}
# Populated on first call to next_service(); maps day-type -> sorted list of
# (pad_depart_seconds, kxp_arrive_seconds) measured from midnight.
_timetable: dict[str, list[tuple[int, int]]] | None = None
def _parse_duration(s: str | None) -> int:
if not s:
return 0
m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', s)
return int(m.group(1) or 0) * 3600 + int(m.group(2) or 0) * 60 + int(m.group(3) or 0)
def _load_timetable() -> dict[str, list[tuple[int, int]]]:
tree = ET.parse(_TXC_XML)
root = tree.getroot()
# Build JPS id -> [(from_stop, to_stop, runtime_secs, wait_secs)]
jps_map: dict[str, list[tuple]] = {}
for jps_el in root.find('t:JourneyPatternSections', _NS):
links = []
for link in jps_el.findall('t:JourneyPatternTimingLink', _NS):
fr = link.find('t:From/t:StopPointRef', _NS)
to = link.find('t:To/t:StopPointRef', _NS)
rt = link.find('t:RunTime', _NS)
wait = link.find('t:From/t:WaitTime', _NS)
links.append((
fr.text if fr is not None else None,
to.text if to is not None else None,
_parse_duration(rt.text if rt is not None else None),
_parse_duration(wait.text if wait is not None else None),
))
jps_map[jps_el.get('id')] = links
def _seconds_to_depart(links, stop):
"""Seconds from journey start until departure from *stop*."""
elapsed = 0
for fr, to, rt, wait in links:
elapsed += wait
if fr == stop:
return elapsed
elapsed += rt
return None
def _seconds_to_arrive(links, stop):
"""Seconds from journey start until arrival at *stop*."""
elapsed = 0
for fr, to, rt, wait in links:
elapsed += wait + rt
if to == stop:
return elapsed
return None
# Map JP id -> (pad_offset_secs, kxp_arrive_offset_secs)
jp_offsets: dict[str, tuple[int, int]] = {}
for svc in root.find('t:Services', _NS):
for jp in svc.findall('.//t:JourneyPattern', _NS):
jps_ref = jp.find('t:JourneyPatternSectionRefs', _NS)
if jps_ref is None:
continue
links = jps_map.get(jps_ref.text, [])
stops = [l[0] for l in links] + ([links[-1][1]] if links else [])
if (
_PAD_STOP in stops
and _KXP_STOP in stops
and stops.index(_PAD_STOP) < stops.index(_KXP_STOP)
):
pad_off = _seconds_to_depart(links, _PAD_STOP)
kxp_off = _seconds_to_arrive(links, _KXP_STOP)
if pad_off is not None and kxp_off is not None:
jp_offsets[jp.get('id')] = (pad_off, kxp_off)
result: dict[str, list[tuple[int, int]]] = {
'MondayToFriday': [],
'Saturday': [],
'Sunday': [],
}
for vj in root.find('t:VehicleJourneys', _NS):
jp_ref = vj.find('t:JourneyPatternRef', _NS)
dep_time = vj.find('t:DepartureTime', _NS)
op = vj.find('t:OperatingProfile', _NS)
if jp_ref is None or dep_time is None or jp_ref.text not in jp_offsets:
continue
pad_off, kxp_off = jp_offsets[jp_ref.text]
h, m, s = map(int, dep_time.text.split(':'))
dep_secs = h * 3600 + m * 60 + s
rdt = op.find('.//t:DaysOfWeek', _NS) if op is not None else None
if rdt is None:
continue
for day_el in rdt:
day_type = day_el.tag.split('}')[-1]
if day_type in result:
result[day_type].append((dep_secs + pad_off, dep_secs + kxp_off))
for key in result:
result[key].sort()
return result
def _get_timetable() -> dict[str, list[tuple[int, int]]]:
global _timetable
if _timetable is None:
_timetable = _load_timetable()
return _timetable
def _day_type(weekday: int) -> str:
if weekday < 5:
return 'MondayToFriday'
return 'Saturday' if weekday == 5 else 'Sunday'
def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None:
"""
Given the earliest time a passenger can board at Paddington (H&C Line),
return (circle_line_depart, arrive_kings_cross) as datetimes, or None if
no service is found before midnight.
The caller is responsible for adding any walk time from the GWR platform
before passing *earliest_board*.
"""
timetable = _get_timetable()[_day_type(earliest_board.weekday())]
board_secs = (
earliest_board.hour * 3600
+ earliest_board.minute * 60
+ earliest_board.second
)
midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0)
for pad_secs, kxp_secs in timetable:
if pad_secs >= board_secs:
return midnight + timedelta(seconds=pad_secs), midnight + timedelta(seconds=kxp_secs)
return None

0
config/__init__.py Normal file
View file

10
config/default.py Normal file
View file

@ -0,0 +1,10 @@
import os
# Directory containing TfL reference data (TransXChange XML files etc.)
TFL_DATA_DIR = os.path.expanduser('~/lib/data/tfl')
# Directory for caching scraped train times
CACHE_DIR = os.path.expanduser('~/lib/data/tfl/cache')
# TransXChange timetable file for the Circle Line
CIRCLE_LINE_XML = os.path.join(TFL_DATA_DIR, 'output_txc_01CIR_.xml')

View file

@ -1,29 +1,14 @@
"""
Scrape Eurostar timetable via httpx and fetch prices via the GraphQL API.
Fetch Eurostar timetable, prices, and seat availability via the GraphQL API.
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.
URL pattern:
https://www.eurostar.com/uk-en/travel-info/timetable/
{origin_id}/{dest_id}/{origin_slug}/{dest_slug}?date=YYYY-MM-DD
Data path: props.pageProps.pageData.liveDepartures[]
.origin.model.scheduledDepartureDateTime London departure
.destination.model.scheduledArrivalDateTime destination arrival
(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.
A single POST to https://site-api.eurostar.com/gateway (operationName
NewBookingSearch) returns departure time, arrival time, train number,
Eurostar Standard fare price, and seats remaining at that price for every
service on the requested date.
"""
import json
import random
import re
import string
import httpx
import requests
DEFAULT_UA = (
@ -32,93 +17,22 @@ DEFAULT_UA = (
)
ORIGIN_STATION_ID = '7015400'
ORIGIN_STATION_SLUG = 'london-st-pancras-intl'
TIMETABLE_BASE_URL = 'https://www.eurostar.com/uk-en/travel-info/timetable'
DESTINATION_STATION_IDS = {
'Paris Gare du Nord': '8727100',
'Brussels Midi': '8814001',
'Lille Europe': '8722326',
'Brussels Midi': '8814001',
'Lille Europe': '8722326',
'Amsterdam Centraal': '8400058',
'Rotterdam Centraal': '8400530',
'Cologne Hbf': '8015458',
}
def _slugify_station_name(name: str) -> str:
return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
def search_url(destination: str, travel_date: str) -> str:
dest_id = DESTINATION_STATION_IDS[destination]
return (
f'https://www.eurostar.com/search/uk-en'
f'?adult=1&origin={ORIGIN_STATION_ID}&destination={dest_id}&outbound={travel_date}'
)
def timetable_url(destination: str) -> str:
dest_id = DESTINATION_STATION_IDS[destination]
dest_slug = _slugify_station_name(destination)
return (
f'{TIMETABLE_BASE_URL}/{ORIGIN_STATION_ID}/{dest_id}/'
f'{ORIGIN_STATION_SLUG}/{dest_slug}'
)
def _hhmm(dt_str: str | None) -> str | None:
"""'2026-03-30 09:34:00''09:34'"""
if not dt_str:
return None
m = re.search(r'(\d{2}):(\d{2}):\d{2}$', dt_str)
return f"{m.group(1)}:{m.group(2)}" if m else None
def _parse(html: str, destination: str) -> list[dict]:
m = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
if not m:
return []
data = json.loads(m.group(1))
departures = data['props']['pageProps']['pageData']['liveDepartures']
services = []
for dep in departures:
dep_time = _hhmm(dep['origin']['model']['scheduledDepartureDateTime'])
arr_time = _hhmm(dep['destination']['model']['scheduledArrivalDateTime'])
if dep_time and arr_time:
carrier = dep.get('model', {}).get('carrier', 'ES')
number = dep.get('model', {}).get('trainNumber', '')
services.append({
'depart_st_pancras': dep_time,
'arrive_destination': arr_time,
'destination': destination,
'train_number': f"{carrier} {number}" if number else '',
})
return sorted(services, key=lambda s: s['depart_st_pancras'])
def fetch(destination: str, travel_date: str,
user_agent: str = DEFAULT_UA) -> list[dict]:
url = timetable_url(destination)
headers = {
'User-Agent': user_agent,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-GB,en;q=0.9',
}
with httpx.Client(headers=headers, follow_redirects=True, timeout=20) as client:
r = client.get(url, params={'date': travel_date})
r.raise_for_status()
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
# Query requesting timing, train identity, and Standard fare price + seats.
# Variable names and argument names match the site's own query so the
# server-side query planner sees a familiar shape.
_GQL_PRICES = (
_GQL_QUERY = (
"query NewBookingSearch("
"$origin:String!,$destination:String!,$outbound:String!,"
"$currency:Currency!,$adult:Int,"
@ -141,71 +55,103 @@ _GQL_PRICES = (
" hideExternalCarrierTrains:true"
" hideDirectExternalCarrierTrains:true"
"){"
"timing{departureTime:departs __typename}"
"timing{departureTime:departs arrivalTime:arrives}"
"fares(filteredClassesOfService:$filteredClassesOfService){"
"classOfService{code __typename}"
"prices{displayPrice __typename}"
"seats __typename"
"classOfService{code}"
"prices{displayPrice}"
"seats "
"legs{serviceName serviceType{code}}"
"}"
"__typename"
"}"
"__typename"
"}"
"__typename"
"}"
"}"
)
def search_url(destination: str, travel_date: str) -> str:
dest_id = DESTINATION_STATION_IDS[destination]
return (
f'https://www.eurostar.com/search/uk-en'
f'?adult=1&origin={ORIGIN_STATION_ID}&destination={dest_id}&outbound={travel_date}'
)
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]:
def _parse_graphql(data: dict, destination: str) -> list[dict]:
"""
Return Eurostar Standard prices for every departure on travel_date.
Parse a NewBookingSearch GraphQL response into a list of service dicts.
Result: {depart_st_pancras: price_gbp_int_or_None}
None means the class is sold out or unavailable for that departure.
Each dict contains: depart_st_pancras, arrive_destination, destination,
train_number, price (float or None), seats (int or None).
The same St Pancras departure can appear multiple times (different
connecting trains); we keep the entry with the earliest arrival.
Multi-leg train numbers are joined with ' + ' (e.g. 'ES 9116 + ER 9329').
"""
best: dict[str, dict] = {}
journeys = data['data']['journeySearch']['outbound']['journeys']
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')
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
return sorted(best.values(), key=lambda s: s['depart_st_pancras'])
def fetch(destination: str, travel_date: str) -> list[dict]:
"""
Return all Eurostar services for destination on travel_date.
Each dict contains timetable info (depart_st_pancras, arrive_destination,
train_number) plus pricing (price, seats) from a single GraphQL call.
"""
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(),
'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'],
'origin': ORIGIN_STATION_ID,
'destination': dest_id,
'outbound': travel_date,
'currency': 'GBP',
'adult': 1,
'filteredClassesOfService': ['STANDARD'],
},
'query': _GQL_PRICES,
'query': _GQL_QUERY,
}
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
return _parse_graphql(resp.json(), destination)

View file

@ -185,9 +185,103 @@
.card {
padding: 1.25rem;
}
.col-transfer { display: none; }
}
a { color: #00539f; }
/* Card helpers */
.card > h2:first-child { margin-top: 0; }
.card-scroll { overflow-x: auto; }
/* Form groups */
.form-group { margin-bottom: 1.2rem; }
.form-group-lg { margin-bottom: 1.5rem; }
/* Buttons */
.btn-primary {
background: #00539f;
color: #fff;
border: none;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
}
.btn-nav {
padding: 0.3rem 0.75rem;
border: 1px solid #cbd5e0;
border-radius: 4px;
text-decoration: none;
color: #00539f;
font-size: 0.9rem;
}
/* Inline select */
.select-inline {
padding: 0.3rem 0.6rem;
font-size: 0.9rem;
border: 1px solid #cbd5e0;
border-radius: 4px;
}
/* Alert boxes */
.alert {
margin-top: 1rem;
padding: 0.75rem 1rem;
border-radius: 4px;
}
.alert-error {
background: #fff5f5;
border: 1px solid #fc8181;
color: #c53030;
}
.alert-warning {
background: #fffbeb;
border: 1px solid #f6e05e;
color: #744210;
}
/* Results page layout */
.back-link { margin-bottom: 1rem; }
.date-nav { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.switcher-section { margin: 0.9rem 0 1rem; }
.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-label { font-size: 0.9rem; font-weight: 600; margin-right: 0.5rem; }
.card-meta { color: #4a5568; margin: 0; }
.footnote { margin-top: 1rem; font-size: 0.82rem; color: #718096; }
/* Results table */
.results-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
.results-table th,
.results-table td { padding: 0.6rem 0.8rem; }
.results-table thead tr { border-bottom: 2px solid #e2e8f0; text-align: left; }
.results-table tbody tr { border-bottom: 1px solid #e2e8f0; }
.row-fast { background: #f0fff4; }
.row-slow { background: #fff5f5; }
.row-alt { background: #f7fafc; }
.row-unreachable { background: #f7fafc; color: #a0aec0; }
/* Empty state */
.empty-state { color: #4a5568; text-align: center; padding: 3rem 2rem; }
.empty-state p { margin: 0; }
.empty-state p:first-child { font-size: 1.1rem; margin-bottom: 0.5rem; }
.empty-state p:last-child { font-size: 0.9rem; }
/* Utilities */
.text-muted { color: #718096; }
.text-dimmed { color: #a0aec0; }
.text-green { color: #276749; }
.text-red { color: #c53030; }
.text-blue { color: #00539f; }
.text-sm { font-size: 0.85rem; }
.text-xs { font-size: 0.75rem; }
.nowrap { white-space: nowrap; }
.font-bold { font-weight: 600; }
.font-normal { font-weight: 400; }
</style>
</head>
<body>

View file

@ -1,9 +1,9 @@
{% extends "base.html" %}
{% block content %}
<div class="card">
<h2 style="margin-top:0">Plan your journey</h2>
<h2>Plan your journey</h2>
<form method="get" action="{{ url_for('search') }}">
<div style="margin-bottom:1.2rem">
<div class="form-group">
<span class="field-label">Departure point</span>
<div class="fixed-station" aria-label="Departure point">
<strong>Bristol Temple Meads</strong>
@ -11,7 +11,7 @@
</div>
</div>
<div style="margin-bottom:1.2rem">
<div class="form-group">
<span class="field-label">Eurostar destination</span>
<div class="destination-grid" role="radiogroup" aria-label="Eurostar destination">
{% for slug, name in destinations.items() %}
@ -33,7 +33,7 @@
</div>
</div>
<div style="margin-bottom:1.5rem">
<div class="form-group-lg">
<label for="travel_date" class="field-label">
Travel date
</label>
@ -42,7 +42,7 @@
class="form-control">
</div>
<div style="margin-bottom:1.2rem">
<div class="form-group">
<label for="min_connection" class="field-label">
Minimum connection time (Paddington &rarr; St&nbsp;Pancras)
</label>
@ -53,7 +53,7 @@
</select>
</div>
<div style="margin-bottom:1.5rem">
<div class="form-group-lg">
<label for="max_connection" class="field-label">
Maximum connection time (Paddington &rarr; St&nbsp;Pancras)
</label>
@ -64,9 +64,7 @@
</select>
</div>
<button type="submit"
style="background:#00539f;color:#fff;border:none;padding:0.75rem 2rem;
font-size:1rem;font-weight:600;border-radius:4px;cursor:pointer">
<button type="submit" class="btn-primary">
Search journeys
</button>
</form>

View file

@ -5,31 +5,24 @@
{% 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 content %}
<style>
@media (max-width: 640px) {
.col-transfer { display: none; }
}
</style>
<p style="margin-bottom:1rem">
<p class="back-link">
<a href="{{ url_for('index') }}">&larr; New search</a>
</p>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin-top:0">
<h2>
Bristol Temple Meads &rarr; {{ destination }}
</h2>
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem">
<div class="date-nav">
<a href="{{ url_for('results', slug=slug, travel_date=prev_date, min_connection=min_connection, max_connection=max_connection) }}"
style="padding:0.3rem 0.75rem;border:1px solid #cbd5e0;border-radius:4px;
text-decoration:none;color:#00539f;font-size:0.9rem">&larr; Prev</a>
class="btn-nav">&larr; Prev</a>
<strong>{{ travel_date_display }}</strong>
<a href="{{ url_for('results', slug=slug, travel_date=next_date, min_connection=min_connection, max_connection=max_connection) }}"
style="padding:0.3rem 0.75rem;border:1px solid #cbd5e0;border-radius:4px;
text-decoration:none;color:#00539f;font-size:0.9rem">Next &rarr;</a>
class="btn-nav">Next &rarr;</a>
</div>
<div style="margin:0.9rem 0 1rem">
<div style="font-size:0.9rem;font-weight:600;margin-bottom:0.45rem">Switch destination for {{ travel_date_display }}</div>
<div class="switcher-section">
<div class="section-label">Switch destination for {{ travel_date_display }}</div>
<div class="chip-row">
{% for destination_slug, destination_name in destinations.items() %}
{% if destination_slug == slug %}
@ -43,26 +36,26 @@
{% endfor %}
</div>
</div>
<div style="margin-top:0.75rem;display:flex;gap:1.5rem;align-items:center">
<div class="filter-row">
<div>
<label for="min_conn_select" style="font-size:0.9rem;font-weight:600;margin-right:0.5rem">
<label for="min_conn_select" class="filter-label">
Min connection:
</label>
<select id="min_conn_select"
onchange="applyConnectionFilter()"
style="padding:0.3rem 0.6rem;font-size:0.9rem;border:1px solid #cbd5e0;border-radius:4px">
class="select-inline">
{% for mins in valid_min_connections %}
<option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
</select>
</div>
<div>
<label for="max_conn_select" style="font-size:0.9rem;font-weight:600;margin-right:0.5rem">
<label for="max_conn_select" class="filter-label">
Max connection:
</label>
<select id="max_conn_select"
onchange="applyConnectionFilter()"
style="padding:0.3rem 0.6rem;font-size:0.9rem;border:1px solid #cbd5e0;border-radius:4px">
class="select-inline">
{% for mins in valid_max_connections %}
<option value="{{ mins }}" {% if mins == max_connection %}selected{% endif %}>{{ mins }} min</option>
{% endfor %}
@ -76,40 +69,45 @@
window.location = '{{ url_for('results', slug=slug, travel_date=travel_date) }}?min_connection=' + min + '&max_connection=' + max;
}
</script>
<p style="color:#4a5568;margin:0">
<p class="card-meta">
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
&nbsp;&middot;&nbsp;
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
{% if unreachable_morning_services %}
&nbsp;&middot;&nbsp;
<span style="color:#718096">
<span class="text-muted">
{{ unreachable_morning_services | length }} Eurostar service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol
</span>
{% endif %}
{% if from_cache %}
&nbsp;&middot;&nbsp; <span style="color:#718096;font-size:0.85rem">(cached)</span>
&nbsp;&middot;&nbsp; <span class="text-muted text-sm">(cached)</span>
{% endif %}
</p>
{% if error %}
<div style="margin-top:1rem;padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fc8181;border-radius:4px;color:#c53030">
<div class="alert alert-error">
<strong>Warning:</strong> {{ error }}
</div>
{% endif %}
{% if no_prices_note %}
<div class="alert alert-warning">
{{ no_prices_note }}
</div>
{% endif %}
</div>
{% if trips or unreachable_morning_services %}
<div class="card" style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:0.95rem">
<div class="card card-scroll">
<table class="results-table">
<thead>
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
<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">GWR Fare</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">{{ destination }}</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>
<tr>
<th class="nowrap">Bristol</th>
<th class="nowrap">Paddington</th>
<th class="nowrap">GWR Fare</th>
<th class="col-transfer nowrap">Transfer</th>
<th class="nowrap">Depart STP</th>
<th>{{ destination }}</th>
<th class="nowrap">ES Std</th>
<th class="nowrap">Total</th>
</tr>
</thead>
<tbody>
@ -124,82 +122,91 @@
{% endif %}
{% for row in result_rows %}
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
{% set row_bg = 'background:#f0fff4' %}
{% set row_class = 'row-fast' %}
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
{% set row_bg = 'background:#fff5f5' %}
{% set row_class = 'row-slow' %}
{% elif row.row_type == 'unreachable' %}
{% set row_bg = 'background:#f7fafc;color:#a0aec0' %}
{% set row_class = 'row-unreachable' %}
{% elif loop.index is odd %}
{% set row_bg = 'background:#f7fafc' %}
{% set row_class = 'row-alt' %}
{% else %}
{% set row_bg = '' %}
{% set row_class = '' %}
{% endif %}
<tr style="border-bottom:1px solid #e2e8f0;{{ row_bg }}">
<tr class="{{ row_class }}">
{% if row.row_type == 'trip' %}
<td style="padding:0.6rem 0.8rem;font-weight:600">
<td class="font-bold">
{{ row.depart_bristol }}
{% if row.headcode %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ row.headcode }}</span>{% endif %}
{% if row.headcode %}<br><span class="text-xs font-normal text-muted">{{ row.headcode }}</span>{% endif %}
</td>
<td style="padding:0.6rem 0.8rem">
<td>
{{ row.arrive_paddington }}
<span style="font-size:0.8rem;color:#718096;white-space:nowrap">({{ row.gwr_duration }})</span>
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
</td>
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
<td class="nowrap">
£{{ "%.2f"|format(row.ticket_price) }}
<br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span>
<br><span class="text-xs text-muted">{{ row.ticket_name }}</span>
</td>
<td class="col-transfer" style="padding:0.6rem 0.8rem;color:#4a5568;white-space:nowrap">
<td class="col-transfer nowrap" style="color:#4a5568">
{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}
<br><span class="text-xs text-muted">Circle {{ row.circle_line_depart }} → STP {{ row.circle_arrive_checkin }}</span>
</td>
<td style="padding:0.6rem 0.8rem;font-weight:600">
<td class="font-bold">
{{ row.depart_st_pancras }}
{% if row.train_number %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ row.train_number }}</span>{% endif %}
{% if row.train_number %}<br><span class="text-xs font-normal text-muted">{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}</span>{% endif %}
</td>
<td style="padding:0.6rem 0.8rem">
<td>
{{ row.arrive_destination }}
<span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span>
<span class="font-normal text-muted" style="font-size:0.85em">(CET)</span>
{% if row.eurostar_duration %}<br><span class="text-sm text-muted nowrap">({{ row.eurostar_duration }})</span>{% endif %}
</td>
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
<td class="nowrap">
{% if row.eurostar_price is not none %}
£{{ row.eurostar_price }}
£{{ "%.2f"|format(row.eurostar_price) }}
{% if row.eurostar_seats is not none %}
<br><span class="text-xs text-muted">{{ row.eurostar_seats }} at this price</span>
{% endif %}
{% else %}
<span style="color:#718096">&ndash;</span>
<span class="text-muted">&ndash;</span>
{% endif %}
</td>
<td style="padding:0.6rem 0.8rem;font-weight:600;white-space:nowrap">
<td class="font-bold nowrap">
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
<span style="color:#276749" title="Fastest journey">{{ row.total_duration }} ⚡</span>
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
<span style="color:#c53030" title="Slowest journey">{{ row.total_duration }} 🐢</span>
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
{% else %}
<span style="color:#00539f">{{ row.total_duration }}</span>
<span class="text-blue">{{ row.total_duration }}</span>
{% 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) }}{% 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>
<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>
{% else %}
<td style="padding:0.6rem 0.8rem;font-weight:600">&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">n/a</td>
<td style="padding:0.6rem 0.8rem;font-weight:600">
<td class="font-bold">&mdash;</td>
<td>&mdash;</td>
<td class="col-transfer">&mdash;</td>
<td>n/a</td>
<td class="font-bold">
{{ row.depart_st_pancras }}
{% if row.train_number %}<br><span style="font-size:0.75rem;font-weight:400;color:#a0aec0">{{ row.train_number }}</span>{% endif %}
{% if row.train_number %}<br><span class="text-xs font-normal text-dimmed">{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}</span>{% endif %}
</td>
<td style="padding:0.6rem 0.8rem">
<td>
{{ row.arrive_destination }}
<span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span>
<span class="font-normal text-dimmed" style="font-size:0.85em">(CET)</span>
{% if row.eurostar_duration %}<br><span class="text-sm text-dimmed nowrap">({{ row.eurostar_duration }})</span>{% endif %}
</td>
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
<td class="nowrap">
{% if row.eurostar_price is not none %}
<span style="color:#a0aec0">£{{ row.eurostar_price }}</span>
<span class="text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span>
{% if row.eurostar_seats is not none %}
<br><span class="text-xs text-dimmed">{{ row.eurostar_seats }} at this price</span>
{% endif %}
{% else %}
<span style="color:#a0aec0">&ndash;</span>
<span class="text-dimmed">&ndash;</span>
{% endif %}
</td>
<td style="padding:0.6rem 0.8rem;font-weight:600">
<span title="No same-day Bristol connection" style="color:#a0aec0">Too early</span>
<td class="font-bold">
<span title="No same-day Bristol connection" class="text-dimmed nowrap">Too early</span>
</td>
{% endif %}
</tr>
@ -208,7 +215,7 @@
</table>
</div>
<p style="margin-top:1rem;font-size:0.82rem;color:#718096">
<p class="footnote">
Paddington &rarr; St&nbsp;Pancras connection: {{ min_connection }}&ndash;{{ max_connection }}&nbsp;min.
GWR walk-on single prices for Bristol Temple Meads&nbsp;&rarr;&nbsp;Paddington.
Eurostar Standard prices are for 1 adult in GBP; always check
@ -220,9 +227,9 @@
</p>
{% else %}
<div class="card" style="color:#4a5568;text-align:center;padding:3rem 2rem">
<p style="font-size:1.1rem;margin:0 0 0.5rem">No valid journeys found.</p>
<p style="font-size:0.9rem;margin:0">
<div class="card empty-state">
<p>No valid journeys found.</p>
<p>
{% if gwr_count == 0 and eurostar_count == 0 %}
Could not retrieve train data. Check your network connection or try again.
{% elif gwr_count == 0 %}

View file

@ -16,25 +16,21 @@ def _stub_data(monkeypatch, prices=None):
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
p = (prices or {}).get('10:01', {})
monkeypatch.setattr(
app_module.eurostar_scraper,
'fetch',
lambda destination, travel_date, user_agent: [
lambda destination, travel_date: [
{
'depart_st_pancras': '10:01',
'arrive_destination': '13:34',
'destination': destination,
'train_number': 'ES 9014',
'price': p.get('price') if isinstance(p, dict) else None,
'seats': p.get('seats') if isinstance(p, dict) else None,
},
],
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'timetable_url',
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():
@ -96,7 +92,6 @@ def test_results_title_and_social_meta_include_destination(monkeypatch):
def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(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, 'fetch_eurostar_prices', lambda dest, date: {})
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
@ -111,44 +106,14 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
monkeypatch.setattr(
app_module.eurostar_scraper,
'fetch',
lambda destination, travel_date, user_agent: [
{
'depart_st_pancras': '09:30',
'arrive_destination': '11:50',
'destination': destination,
'train_number': 'ES 1001',
},
{
'depart_st_pancras': '09:40',
'arrive_destination': '12:00',
'destination': destination,
'train_number': 'ES 1002',
},
{
'depart_st_pancras': '09:50',
'arrive_destination': '12:20',
'destination': destination,
'train_number': 'ES 1003',
},
{
'depart_st_pancras': '10:00',
'arrive_destination': '12:35',
'destination': destination,
'train_number': 'ES 1004',
},
{
'depart_st_pancras': '10:10',
'arrive_destination': '12:45',
'destination': destination,
'train_number': 'ES 1005',
},
lambda destination, travel_date: [
{'depart_st_pancras': '09:30', 'arrive_destination': '11:50', 'destination': destination, 'train_number': 'ES 1001', 'price': None, 'seats': None},
{'depart_st_pancras': '09:40', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 1002', 'price': None, 'seats': None},
{'depart_st_pancras': '09:50', 'arrive_destination': '12:20', 'destination': destination, 'train_number': 'ES 1003', 'price': None, 'seats': None},
{'depart_st_pancras': '10:00', 'arrive_destination': '12:35', 'destination': destination, 'train_number': 'ES 1004', 'price': None, 'seats': None},
{'depart_st_pancras': '10:10', 'arrive_destination': '12:45', 'destination': destination, 'train_number': 'ES 1005', 'price': None, 'seats': None},
],
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'timetable_url',
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
)
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
@ -168,7 +133,6 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
def test_results_shows_unreachable_morning_eurostar_services(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, 'fetch_eurostar_prices', lambda dest, date: {})
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
@ -179,32 +143,12 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
monkeypatch.setattr(
app_module.eurostar_scraper,
'fetch',
lambda destination, travel_date, user_agent: [
{
'depart_st_pancras': '09:30',
'arrive_destination': '12:00',
'destination': destination,
'train_number': 'ES 9001',
},
{
'depart_st_pancras': '10:15',
'arrive_destination': '13:40',
'destination': destination,
'train_number': 'ES 9002',
},
{
'depart_st_pancras': '12:30',
'arrive_destination': '15:55',
'destination': destination,
'train_number': 'ES 9003',
},
lambda destination, travel_date: [
{'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 9001', 'price': None, 'seats': None},
{'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': destination, 'train_number': 'ES 9002', 'price': None, 'seats': None},
{'depart_st_pancras': '12:30', 'arrive_destination': '15:55', 'destination': destination, 'train_number': 'ES 9003', 'price': None, 'seats': None},
],
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'timetable_url',
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
)
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
@ -214,13 +158,13 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
assert '2 Eurostar services unavailable from Bristol' in html
assert '09:30' in html
assert 'ES 9001' in html
assert 'Unavailable from Bristol' in html
assert 'Too early' in html
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})
_stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}})
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
@ -234,7 +178,6 @@ def test_results_shows_eurostar_price_and_total(monkeypatch):
def test_results_can_show_only_unreachable_morning_services(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, 'fetch_eurostar_prices', lambda dest, date: {})
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
@ -245,20 +188,10 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
monkeypatch.setattr(
app_module.eurostar_scraper,
'fetch',
lambda destination, travel_date, user_agent: [
{
'depart_st_pancras': '09:30',
'arrive_destination': '12:00',
'destination': destination,
'train_number': 'ES 9001',
},
lambda destination, travel_date: [
{'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 9001', 'price': None, 'seats': None},
],
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'timetable_url',
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
)
client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
@ -268,4 +201,4 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
assert 'No valid journeys found.' not in html
assert '1 Eurostar service unavailable from Bristol' in html
assert '09:30' in html
assert 'Unavailable from Bristol' in html
assert 'Too early' in html

View file

@ -1,97 +1,92 @@
import json
import pytest
from scraper.eurostar import _hhmm, _parse, timetable_url
from scraper.eurostar import _parse_graphql, search_url
# ---------------------------------------------------------------------------
# _hhmm
# ---------------------------------------------------------------------------
def test_hhmm_parses_datetime_string():
assert _hhmm('2026-03-30 09:34:00') == '09:34'
def test_hhmm_none_input():
assert _hhmm(None) is None
def test_hhmm_empty_string():
assert _hhmm('') is None
def _gql_response(journeys: list) -> dict:
return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}}
# ---------------------------------------------------------------------------
# _parse
# ---------------------------------------------------------------------------
def _make_next_data(departures: list) -> str:
data = {
'props': {
'pageProps': {
'pageData': {
'liveDepartures': departures
}
}
}
}
return f'<script id="__NEXT_DATA__" type="application/json">{json.dumps(data)}</script>'
def _departure(dep_dt: str, arr_dt: str) -> dict:
def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES') -> dict:
return {
'origin': {'model': {'scheduledDepartureDateTime': dep_dt}},
'destination': {'model': {'scheduledArrivalDateTime': arr_dt}},
'timing': {'departureTime': departs, 'arrivalTime': arrives},
'fares': [{
'classOfService': {'code': 'STANDARD'},
'prices': {'displayPrice': price},
'seats': seats,
'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}]
if service_name else [],
}],
}
def test_parse_single_departure():
html = _make_next_data([_departure('2026-03-30 06:01:00', '2026-03-30 09:34:00')])
services = _parse(html, 'Paris Gare du Nord')
# ---------------------------------------------------------------------------
# _parse_graphql
# ---------------------------------------------------------------------------
def test_parse_graphql_single_journey():
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014')])
services = _parse_graphql(data, 'Paris Gare du Nord')
assert len(services) == 1
assert services[0] == {
'depart_st_pancras': '06:01',
'arrive_destination': '09:34',
'destination': 'Paris Gare du Nord',
'train_number': '',
}
s = services[0]
assert s['depart_st_pancras'] == '09:31'
assert s['arrive_destination'] == '12:55'
assert s['destination'] == 'Paris Gare du Nord'
assert s['train_number'] == 'ES 9014'
assert s['price'] == 156.0
assert s['seats'] == 37
def test_parse_results_sorted_by_departure():
html = _make_next_data([
_departure('2026-03-30 10:00:00', '2026-03-30 13:00:00'),
_departure('2026-03-30 07:00:00', '2026-03-30 10:00:00'),
def test_parse_graphql_half_pound_price():
data = _gql_response([_journey('09:01', '14:20', price=192.5, seats=25, service_name='9116')])
services = _parse_graphql(data, 'Amsterdam Centraal')
assert services[0]['price'] == 192.5
def test_parse_graphql_null_price():
data = _gql_response([_journey('06:16', '11:09', price=None, seats=0)])
services = _parse_graphql(data, 'Amsterdam Centraal')
assert services[0]['price'] is None
assert services[0]['seats'] == 0
def test_parse_graphql_sorted_by_departure():
data = _gql_response([
_journey('10:31', '13:55'),
_journey('07:31', '10:59'),
])
services = _parse(html, 'Paris Gare du Nord')
assert services[0]['depart_st_pancras'] == '07:00'
assert services[1]['depart_st_pancras'] == '10:00'
services = _parse_graphql(data, 'Paris Gare du Nord')
assert services[0]['depart_st_pancras'] == '07:31'
assert services[1]['depart_st_pancras'] == '10:31'
def test_parse_skips_entries_with_missing_times():
html = _make_next_data([
_departure(None, '2026-03-30 09:34:00'),
_departure('2026-03-30 08:00:00', None),
_departure('2026-03-30 09:00:00', '2026-03-30 12:00:00'),
def test_parse_graphql_deduplicates_same_departure_time():
data = _gql_response([
_journey('06:16', '11:09', price=None, seats=0),
_journey('06:16', '11:09', price=None, seats=0),
_journey('06:16', '11:09', price=None, seats=0),
])
services = _parse(html, 'Paris Gare du Nord')
services = _parse_graphql(data, 'Amsterdam Centraal')
assert len(services) == 1
assert services[0]['depart_st_pancras'] == '09:00'
def test_parse_no_next_data_returns_empty():
assert _parse('<html><body>nothing here</body></html>', 'Paris Gare du Nord') == []
def test_parse_graphql_no_legs_gives_empty_train_number():
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='')])
services = _parse_graphql(data, 'Paris Gare du Nord')
assert services[0]['train_number'] == ''
def test_parse_empty_departures():
html = _make_next_data([])
assert _parse(html, 'Paris Gare du Nord') == []
def test_parse_graphql_empty_journeys():
data = _gql_response([])
assert _parse_graphql(data, 'Paris Gare du Nord') == []
def test_timetable_url_uses_station_id_table():
assert timetable_url('Paris Gare du Nord') == (
'https://www.eurostar.com/uk-en/travel-info/timetable/'
'7015400/8727100/london-st-pancras-intl/paris-gare-du-nord'
)
def test_timetable_url_slugifies_destination_name():
assert timetable_url('Rotterdam Centraal') == (
'https://www.eurostar.com/uk-en/travel-info/timetable/'
'7015400/8400530/london-st-pancras-intl/rotterdam-centraal'
# ---------------------------------------------------------------------------
# search_url
# ---------------------------------------------------------------------------
def test_search_url():
url = search_url('Paris Gare du Nord', '2026-04-10')
assert url == (
'https://www.eurostar.com/search/uk-en'
'?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10'
)

View file

@ -3,12 +3,17 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination t
"""
from datetime import datetime, timedelta, time as _time
import circle_line
MIN_CONNECTION_MINUTES = 50
MAX_CONNECTION_MINUTES = 110
MAX_GWR_MINUTES = 110
DATE_FMT = '%Y-%m-%d'
TIME_FMT = '%H:%M'
PAD_WALK_TO_UNDERGROUND_MINUTES = 7 # GWR platform → Paddington (H&C Line) platform
KX_WALK_TO_CHECKIN_MINUTES = 8 # King's Cross St Pancras platform → St Pancras check-in
# Bristol Temple Meads → London Paddington walk-on single fares.
# Weekday restrictions (MonFri only):
@ -45,6 +50,24 @@ def _parse_dt(date: str, time: str) -> datetime:
return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}")
def _circle_line_times(arrive_paddington: datetime) -> tuple[str, str] | tuple[None, None]:
"""
Given GWR arrival at Paddington, return (circle_line_depart_str, arrive_checkin_str).
Adds PAD_WALK_TO_UNDERGROUND_MINUTES to get the earliest boarding time, looks up
the next real Circle Line service from the timetable, then adds KX_WALK_TO_CHECKIN_MINUTES
to the Kings Cross arrival to give an estimated St Pancras check-in time.
Returns (None, None) if no service is found.
"""
earliest_board = arrive_paddington + timedelta(minutes=PAD_WALK_TO_UNDERGROUND_MINUTES)
result = circle_line.next_service(earliest_board)
if result is None:
return None, None
circle_depart, arrive_kx = result
arrive_checkin = arrive_kx + timedelta(minutes=KX_WALK_TO_CHECKIN_MINUTES)
return circle_depart.strftime(TIME_FMT), arrive_checkin.strftime(TIME_FMT)
def _fmt_duration(minutes: int) -> str:
h, m = divmod(minutes, 60)
if h and m:
@ -121,7 +144,10 @@ def combine_trips(
dep_bri, arr_pad, dep_stp, arr_dest = connection
total_mins = int((arr_dest - dep_bri).total_seconds() / 60)
# Destination time is CET/CEST, departure is GMT/BST; Europe is always 1h ahead.
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
ticket = cheapest_gwr_ticket(gwr['depart_bristol'], travel_date)
circle_depart, arrive_checkin = _circle_line_times(arr_pad)
trips.append({
'depart_bristol': gwr['depart_bristol'],
'arrive_paddington': gwr['arrive_paddington'],
@ -129,8 +155,11 @@ def combine_trips(
'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)),
'connection_minutes': int((dep_stp - arr_pad).total_seconds() / 60),
'connection_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)),
'circle_line_depart': circle_depart,
'circle_arrive_checkin': arrive_checkin,
'depart_st_pancras': es['depart_st_pancras'],
'arrive_destination': es['arrive_destination'],
'eurostar_duration': _fmt_duration(eurostar_mins),
'train_number': es.get('train_number', ''),
'total_duration': _fmt_duration(total_mins),
'total_minutes': total_mins,
@ -167,6 +196,11 @@ def find_unreachable_morning_eurostars(
):
continue
unreachable.append(es)
dep_stp = _parse_dt(travel_date, es['depart_st_pancras'])
arr_dest = _parse_dt(travel_date, es['arrive_destination'])
if arr_dest < dep_stp:
arr_dest += timedelta(days=1)
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
unreachable.append({**es, 'eurostar_duration': _fmt_duration(eurostar_mins)})
return sorted(unreachable, key=lambda s: s['depart_st_pancras'])