Add multi-station support, GWR fares API, and Circle line improvements

- Support any station with direct trains to Paddington; station CRS code
  is now part of the URL (/results/<crs>/<slug>/<date>)
- Load station list from data/direct_to_paddington.tsv; show dropdown on
  index page; 404 for unknown station codes
- Fetch live GWR walk-on fares via api.gwr.com for all stations (SSS/SVS/SDS
  with restrictions already applied per train); cache 30 days
- Scrape Paddington arrival platform numbers from RTT
- Show unreachable morning Eurostars (before first reachable service only)
- Circle line: show actual KX St Pancras arrival times (not check-in estimate)
  and add a second backup service in the transfer column
- Widen page max-width to 1100px for longer station names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-06 20:22:44 +01:00
parent 71be0dd8cf
commit 3c787b33d3
12 changed files with 810 additions and 262 deletions

183
app.py
View file

@ -1,55 +1,75 @@
""" """
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
from flask import Flask, render_template, redirect, url_for, request, abort
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path
import os import os
from cache import get_cached, set_cached from cache import get_cached, set_cached
import scraper.eurostar as eurostar_scraper import scraper.eurostar as eurostar_scraper
import scraper.gwr_fares as gwr_fares_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
RTT_PADDINGTON_URL = ( RTT_PADDINGTON_URL = (
"https://www.realtimetrains.co.uk/search/detailed/" "https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:PAD/from/gb-nr:BRI/{date}/0000-2359" "gb-nr:PAD/from/gb-nr:{crs}/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt" "?stp=WVS&show=pax-calls&order=wtt"
) )
RTT_BRISTOL_URL = ( RTT_STATION_URL = (
"https://www.realtimetrains.co.uk/search/detailed/" "https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:BRI/to/gb-nr:PAD/{date}/0000-2359" "gb-nr:{crs}/to/gb-nr:PAD/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt" "?stp=WVS&show=pax-calls&order=wtt"
) )
app = Flask(__name__, instance_relative_config=False) app = Flask(__name__, instance_relative_config=False)
app.config.from_object('config.default') app.config.from_object("config.default")
_local = os.path.join(os.path.dirname(__file__), 'config', 'local.py') _local = os.path.join(os.path.dirname(__file__), "config", "local.py")
if os.path.exists(_local): if os.path.exists(_local):
app.config.from_pyfile(_local) app.config.from_pyfile(_local)
import cache import cache
import circle_line import circle_line
cache.CACHE_DIR = app.config['CACHE_DIR']
circle_line._TXC_XML = app.config['CIRCLE_LINE_XML'] cache.CACHE_DIR = app.config["CACHE_DIR"]
circle_line._TXC_XML = app.config["CIRCLE_LINE_XML"]
def _load_stations():
tsv = Path(__file__).parent / "data" / "direct_to_paddington.tsv"
stations = []
for line in tsv.read_text().splitlines():
line = line.strip()
if "\t" in line:
name, crs = line.split("\t", 1)
stations.append((name, crs))
return sorted(stations, key=lambda x: x[0])
STATIONS = _load_stations()
STATION_BY_CRS = {crs: name for name, crs in STATIONS}
DESTINATIONS = { DESTINATIONS = {
'paris': 'Paris Gare du Nord', "paris": "Paris Gare du Nord",
'brussels': 'Brussels Midi', "brussels": "Brussels Midi",
'lille': 'Lille Europe', "lille": "Lille Europe",
'amsterdam': 'Amsterdam Centraal', "amsterdam": "Amsterdam Centraal",
'rotterdam': 'Rotterdam Centraal', "rotterdam": "Rotterdam Centraal",
'cologne': 'Cologne Hbf', "cologne": "Cologne Hbf",
} }
@app.route('/') @app.route("/")
def index(): def index():
today = date.today().isoformat() today = date.today().isoformat()
return render_template( return render_template(
'index.html', "index.html",
destinations=DESTINATIONS, destinations=DESTINATIONS,
today=today, today=today,
stations=STATIONS,
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),
) )
@ -59,53 +79,70 @@ VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120}
VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180} VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180}
@app.route('/search') @app.route("/search")
def search(): def search():
slug = request.args.get('destination', '') slug = request.args.get("destination", "")
travel_date = request.args.get('travel_date', '') travel_date = request.args.get("travel_date", "")
station_crs = request.args.get("station_crs", "BRI")
if station_crs not in STATION_BY_CRS:
station_crs = "BRI"
try: try:
min_conn = int(request.args.get('min_connection', 50)) min_conn = int(request.args.get("min_connection", 50))
except ValueError: except ValueError:
min_conn = 50 min_conn = 50
if min_conn not in VALID_MIN_CONNECTIONS: if min_conn not in VALID_MIN_CONNECTIONS:
min_conn = 50 min_conn = 50
try: try:
max_conn = int(request.args.get('max_connection', 110)) max_conn = int(request.args.get("max_connection", 110))
except ValueError: except ValueError:
max_conn = 110 max_conn = 110
if max_conn not in VALID_MAX_CONNECTIONS: if max_conn not in VALID_MAX_CONNECTIONS:
max_conn = 110 max_conn = 110
if slug in DESTINATIONS and travel_date: if slug in DESTINATIONS and travel_date:
return redirect(url_for('results', slug=slug, travel_date=travel_date, min_connection=min_conn, max_connection=max_conn)) return redirect(
return redirect(url_for('index')) url_for(
"results",
station_crs=station_crs,
slug=slug,
travel_date=travel_date,
min_connection=min_conn,
max_connection=max_conn,
)
)
return redirect(url_for("index"))
@app.route('/results/<slug>/<travel_date>') @app.route("/results/<station_crs>/<slug>/<travel_date>")
def results(slug, travel_date): def results(station_crs, slug, travel_date):
departure_station_name = STATION_BY_CRS.get(station_crs)
if departure_station_name is None:
abort(404)
destination = DESTINATIONS.get(slug) destination = DESTINATIONS.get(slug)
if not destination or not travel_date: if not destination or not travel_date:
return redirect(url_for('index')) return redirect(url_for("index"))
try: try:
min_connection = int(request.args.get('min_connection', 50)) min_connection = int(request.args.get("min_connection", 50))
except ValueError: except ValueError:
min_connection = 50 min_connection = 50
if min_connection not in VALID_MIN_CONNECTIONS: if min_connection not in VALID_MIN_CONNECTIONS:
min_connection = 50 min_connection = 50
try: try:
max_connection = int(request.args.get('max_connection', 110)) max_connection = int(request.args.get("max_connection", 110))
except ValueError: except ValueError:
max_connection = 110 max_connection = 110
if max_connection not in VALID_MAX_CONNECTIONS: if max_connection not in VALID_MAX_CONNECTIONS:
max_connection = 110 max_connection = 110
user_agent = request.headers.get('User-Agent', rtt_scraper.DEFAULT_UA) user_agent = request.headers.get("User-Agent", rtt_scraper.DEFAULT_UA)
rtt_cache_key = f"rtt_{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}"
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)
from_cache = bool(cached_rtt and cached_es) from_cache = bool(cached_rtt and cached_es)
error = None error = None
@ -114,7 +151,7 @@ def results(slug, travel_date):
gwr_trains = cached_rtt gwr_trains = cached_rtt
else: else:
try: try:
gwr_trains = rtt_scraper.fetch(travel_date, user_agent) gwr_trains = rtt_scraper.fetch(travel_date, user_agent, station_crs)
set_cached(rtt_cache_key, gwr_trains) set_cached(rtt_cache_key, gwr_trains)
except Exception as e: except Exception as e:
gwr_trains = [] gwr_trains = []
@ -131,26 +168,51 @@ 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_gwr_fares:
gwr_fares = cached_gwr_fares
else:
try:
gwr_fares = gwr_fares_scraper.fetch(station_crs, travel_date)
set_cached(gwr_fares_cache_key, gwr_fares)
except Exception as e:
gwr_fares = {}
msg = f"Could not fetch GWR fares: {e}"
error = f"{error}; {msg}" if error else msg
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")}
for s in eurostar_services for s in eurostar_services
} }
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,
gwr_fares,
)
# Annotate each trip with Eurostar Standard price, seats, and total cost # Annotate each trip with Eurostar Standard price, seats, and total cost
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['total_price'] = trip['ticket_price'] + es_price if es_price is not None else None gwr_p = trip.get("ticket_price")
trip["total_price"] = (
gwr_p + es_price if (gwr_p is not None and es_price is not None) else None
)
# If the API returned journeys but every price is None, tickets aren't on sale yet # If the API returned journeys but every price is None, tickets aren't on sale yet
no_prices_note = None no_prices_note = None
if eurostar_prices and all(v.get('price') is None for v in eurostar_prices.values()): if eurostar_prices and all(
no_prices_note = 'Eurostar prices not yet available — tickets may not be on sale yet.' 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( unreachable_morning_services = find_unreachable_morning_eurostars(
gwr_trains, gwr_trains,
@ -160,27 +222,40 @@ def results(slug, travel_date):
max_connection, max_connection,
) )
for svc in unreachable_morning_services: for svc in unreachable_morning_services:
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")
# Only keep unreachable services that depart before the first reachable Eurostar.
# Services after the first reachable one are omitted (they aren't "Too early").
if trips:
first_es_depart = min(t["depart_st_pancras"] for t in trips)
unreachable_morning_services = [
s
for s in unreachable_morning_services
if s["depart_st_pancras"] < first_es_depart
]
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], + [
key=lambda row: row['depart_st_pancras'], {"row_type": "unreachable", **service}
for service in unreachable_morning_services
],
key=lambda row: row["depart_st_pancras"],
) )
dt = date.fromisoformat(travel_date) dt = date.fromisoformat(travel_date)
prev_date = (dt - timedelta(days=1)).isoformat() prev_date = (dt - timedelta(days=1)).isoformat()
next_date = (dt + timedelta(days=1)).isoformat() next_date = (dt + timedelta(days=1)).isoformat()
travel_date_display = dt.strftime('%A %-d %B %Y') travel_date_display = dt.strftime("%A %-d %B %Y")
eurostar_url = eurostar_scraper.search_url(destination, travel_date) eurostar_url = eurostar_scraper.search_url(destination, travel_date)
rtt_url = RTT_PADDINGTON_URL.format(date=travel_date) rtt_url = RTT_PADDINGTON_URL.format(crs=station_crs, date=travel_date)
rtt_bristol_url = RTT_BRISTOL_URL.format(date=travel_date) rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date)
return render_template( return render_template(
'results.html', "results.html",
trips=trips, trips=trips,
result_rows=result_rows, result_rows=result_rows,
unreachable_morning_services=unreachable_morning_services, unreachable_morning_services=unreachable_morning_services,
@ -188,6 +263,8 @@ def results(slug, travel_date):
destination=destination, destination=destination,
travel_date=travel_date, travel_date=travel_date,
slug=slug, slug=slug,
station_crs=station_crs,
departure_station_name=departure_station_name,
prev_date=prev_date, prev_date=prev_date,
next_date=next_date, next_date=next_date,
travel_date_display=travel_date_display, travel_date_display=travel_date_display,
@ -198,7 +275,7 @@ def results(slug, travel_date):
no_prices_note=no_prices_note, no_prices_note=no_prices_note,
eurostar_url=eurostar_url, eurostar_url=eurostar_url,
rtt_url=rtt_url, rtt_url=rtt_url,
rtt_bristol_url=rtt_bristol_url, rtt_station_url=rtt_station_url,
min_connection=min_connection, min_connection=min_connection,
max_connection=max_connection, max_connection=max_connection,
valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
@ -206,5 +283,5 @@ def results(slug, travel_date):
) )
if __name__ == '__main__': if __name__ == "__main__":
app.run(debug=True) app.run(debug=True, host="0.0.0.0")

View file

@ -135,6 +135,19 @@ def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None:
The caller is responsible for adding any walk time from the GWR platform The caller is responsible for adding any walk time from the GWR platform
before passing *earliest_board*. before passing *earliest_board*.
""" """
services = upcoming_services(earliest_board, count=1)
return services[0] if services else None
def upcoming_services(
earliest_board: datetime, count: int = 2
) -> list[tuple[datetime, datetime]]:
"""
Return up to *count* Circle line services from Paddington (H&C Line) to
King's Cross St Pancras, starting from *earliest_board*.
Each element is (depart_paddington, arrive_kings_cross) as datetimes.
"""
timetable = _get_timetable()[_day_type(earliest_board.weekday())] timetable = _get_timetable()[_day_type(earliest_board.weekday())]
board_secs = ( board_secs = (
earliest_board.hour * 3600 earliest_board.hour * 3600
@ -142,7 +155,13 @@ def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None:
+ earliest_board.second + earliest_board.second
) )
midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0) midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0)
results = []
for pad_secs, kxp_secs in timetable: for pad_secs, kxp_secs in timetable:
if pad_secs >= board_secs: if pad_secs >= board_secs:
return midnight + timedelta(seconds=pad_secs), midnight + timedelta(seconds=kxp_secs) results.append((
return None midnight + timedelta(seconds=pad_secs),
midnight + timedelta(seconds=kxp_secs),
))
if len(results) == count:
break
return results

View file

@ -0,0 +1,129 @@
Shenfield SNF
Brentwood BRE
Harold Wood HRO
Gidea Park GDP
Romford RMF
Chadwell Heath CTH
Goodmayes GMY
Seven Kings SVK
Ilford IFD
Manor Park MNP
Forest Gate FOG
Maryland MYL
Stratford (London) SRA
Whitechapel ZLW
London Liverpool Street LST
Farringdon ZFD
Tottenham Court Road TCR
Bond Street BDS
London Paddington PAD
Heathrow Airport Terminal 5 HWV
Heathrow Central HXX
Hayes & Harlington HAY
Southall STL
Hanwell HAN
West Ealing WEA
Ealing Broadway EAL
Acton Main Line AML
Abbey Wood ABW
Woolwich WWC
Custom House CUS
Canary Wharf CWX
Reading RDG
Twyford TWY
Maidenhead MAI
Taplow TAP
Burnham BNM
Slough SLO
Langley LNY
Iver IVR
West Drayton WDT
Oxford OXF
Didcot Parkway DID
Heathrow Airport Terminal 4 HAF
Cholsey CHO
Goring & Streatley GOR
Pangbourne PAN
Tilehurst TLH
Bristol Temple Meads BRI
Bath Spa BTH
Chippenham CPM
Swindon SWI
Hereford HFD
Ledbury LED
Colwall CWL
Great Malvern GMV
Malvern Link MVL
Worcester Foregate Street WOF
Worcester Shrub Hill WOS
Worcestershire Parkway WOP
Pershore PSH
Evesham EVE
Honeybourne HYB
Moreton-in-Marsh MIM
Kingham KGM
Charlbury CBY
Hanborough HND
Penzance PNZ
St Erth SER
Camborne CBN
Redruth RED
Truro TRU
St Austell SAU
Par PAR
Bodmin Parkway BOD
Liskeard LSK
Plymouth PLY
Totnes TOT
Newton Abbot NTA
Exeter St Davids EXD
Taunton TAU
Bedwyn BDW
Hungerford HGD
Kintbury KIT
Newbury NBY
Thatcham THA
Theale THE
Reading West RDW
Swansea SWA
Neath NTH
Port Talbot Parkway PTA
Bridgend BGN
Cardiff Central CDF
Newport (South Wales) NWP
Bristol Parkway BPW
Newbury Racecourse NRC
Midgham MDG
Aldermaston AMT
Cheltenham Spa CNM
Gloucester GCR
Stonehouse SHU
Stroud STD
Kemble KEM
Frome FRO
Westbury WSB
Pewsey PEW
Weston-super-Mare WSM
Weston Milton WNM
Worle WOR
Yatton YAT
Nailsea & Backwell NLS
Tiverton Parkway TVP
Castle Cary CLC
Bridgwater BWT
Highbridge & Burnham HIG
Carmarthen CMN
Ferryside FYS
Kidwelly KWL
Pembrey & Burry Port PBY
Llanelli LLE
Paignton PGN
Torquay TQY
Torre TRR
Teignmouth TGM
Dawlish DWL
Banbury BAN
Greenford GFD
South Greenford SGN
Castle Bar Park CBP
Drayton Green DRG
1 Shenfield SNF
2 Brentwood BRE
3 Harold Wood HRO
4 Gidea Park GDP
5 Romford RMF
6 Chadwell Heath CTH
7 Goodmayes GMY
8 Seven Kings SVK
9 Ilford IFD
10 Manor Park MNP
11 Forest Gate FOG
12 Maryland MYL
13 Stratford (London) SRA
14 Whitechapel ZLW
15 London Liverpool Street LST
16 Farringdon ZFD
17 Tottenham Court Road TCR
18 Bond Street BDS
19 London Paddington PAD
20 Heathrow Airport Terminal 5 HWV
21 Heathrow Central HXX
22 Hayes & Harlington HAY
23 Southall STL
24 Hanwell HAN
25 West Ealing WEA
26 Ealing Broadway EAL
27 Acton Main Line AML
28 Abbey Wood ABW
29 Woolwich WWC
30 Custom House CUS
31 Canary Wharf CWX
32 Reading RDG
33 Twyford TWY
34 Maidenhead MAI
35 Taplow TAP
36 Burnham BNM
37 Slough SLO
38 Langley LNY
39 Iver IVR
40 West Drayton WDT
41 Oxford OXF
42 Didcot Parkway DID
43 Heathrow Airport Terminal 4 HAF
44 Cholsey CHO
45 Goring & Streatley GOR
46 Pangbourne PAN
47 Tilehurst TLH
48 Bristol Temple Meads BRI
49 Bath Spa BTH
50 Chippenham CPM
51 Swindon SWI
52 Hereford HFD
53 Ledbury LED
54 Colwall CWL
55 Great Malvern GMV
56 Malvern Link MVL
57 Worcester Foregate Street WOF
58 Worcester Shrub Hill WOS
59 Worcestershire Parkway WOP
60 Pershore PSH
61 Evesham EVE
62 Honeybourne HYB
63 Moreton-in-Marsh MIM
64 Kingham KGM
65 Charlbury CBY
66 Hanborough HND
67 Penzance PNZ
68 St Erth SER
69 Camborne CBN
70 Redruth RED
71 Truro TRU
72 St Austell SAU
73 Par PAR
74 Bodmin Parkway BOD
75 Liskeard LSK
76 Plymouth PLY
77 Totnes TOT
78 Newton Abbot NTA
79 Exeter St Davids EXD
80 Taunton TAU
81 Bedwyn BDW
82 Hungerford HGD
83 Kintbury KIT
84 Newbury NBY
85 Thatcham THA
86 Theale THE
87 Reading West RDW
88 Swansea SWA
89 Neath NTH
90 Port Talbot Parkway PTA
91 Bridgend BGN
92 Cardiff Central CDF
93 Newport (South Wales) NWP
94 Bristol Parkway BPW
95 Newbury Racecourse NRC
96 Midgham MDG
97 Aldermaston AMT
98 Cheltenham Spa CNM
99 Gloucester GCR
100 Stonehouse SHU
101 Stroud STD
102 Kemble KEM
103 Frome FRO
104 Westbury WSB
105 Pewsey PEW
106 Weston-super-Mare WSM
107 Weston Milton WNM
108 Worle WOR
109 Yatton YAT
110 Nailsea & Backwell NLS
111 Tiverton Parkway TVP
112 Castle Cary CLC
113 Bridgwater BWT
114 Highbridge & Burnham HIG
115 Carmarthen CMN
116 Ferryside FYS
117 Kidwelly KWL
118 Pembrey & Burry Port PBY
119 Llanelli LLE
120 Paignton PGN
121 Torquay TQY
122 Torre TRR
123 Teignmouth TGM
124 Dawlish DWL
125 Banbury BAN
126 Greenford GFD
127 South Greenford SGN
128 Castle Bar Park CBP
129 Drayton Green DRG

212
data/pad_origins.json Normal file
View file

@ -0,0 +1,212 @@
{
"Shenfield": {
"name": "Shenfield",
"crs": "",
"service_id": "G24729",
"service_date": "2026-04-20",
"processed": true
},
"Heathrow Airport Terminal 5": {
"name": "Heathrow Airport Terminal 5",
"crs": "",
"service_id": "G24322",
"service_date": "2026-04-20",
"processed": true
},
"Abbey Wood": {
"name": "Abbey Wood",
"crs": "",
"service_id": "G24730",
"service_date": "2026-04-20",
"processed": true
},
"Reading": {
"name": "Reading",
"crs": "",
"service_id": "G21939",
"service_date": "2026-04-20",
"processed": true
},
"Oxford": {
"name": "Oxford",
"crs": "",
"service_id": "P22703",
"service_date": "2026-04-20",
"processed": true
},
"Heathrow Airport Terminal 4": {
"name": "Heathrow Airport Terminal 4",
"crs": "",
"service_id": "G21944",
"service_date": "2026-04-20",
"processed": true
},
"Didcot Parkway": {
"name": "Didcot Parkway",
"crs": "",
"service_id": "P33203",
"service_date": "2026-04-20",
"processed": true
},
"Bristol Temple Meads": {
"name": "Bristol Temple Meads",
"crs": "",
"service_id": "P22595",
"service_date": "2026-04-20",
"processed": true
},
"Hereford": {
"name": "Hereford",
"crs": "",
"service_id": "P22860",
"service_date": "2026-04-20",
"processed": true
},
"Penzance": {
"name": "Penzance",
"crs": "",
"service_id": "P29703",
"service_date": "2026-04-20",
"processed": true
},
"Stratford (London)": {
"name": "Stratford (London)",
"crs": "",
"service_id": "G23396",
"service_date": "2026-04-21",
"processed": true
},
"Maidenhead": {
"name": "Maidenhead",
"crs": "",
"service_id": "G23904",
"service_date": "2026-04-21",
"processed": true
},
"Gidea Park": {
"name": "Gidea Park",
"crs": "",
"service_id": "G23407",
"service_date": "2026-04-21",
"processed": true
},
"Bedwyn": {
"name": "Bedwyn",
"crs": "",
"service_id": "P30238",
"service_date": "2026-04-21",
"processed": true
},
"Swansea": {
"name": "Swansea",
"crs": "",
"service_id": "P22786",
"service_date": "2026-04-21",
"processed": true
},
"Worcester Shrub Hill": {
"name": "Worcester Shrub Hill",
"crs": "",
"service_id": "P22841",
"service_date": "2026-04-21",
"processed": true
},
"Newbury": {
"name": "Newbury",
"crs": "",
"service_id": "P30241",
"service_date": "2026-04-21",
"processed": true
},
"Cheltenham Spa": {
"name": "Cheltenham Spa",
"crs": "",
"service_id": "P22821",
"service_date": "2026-04-21",
"processed": true
},
"Frome": {
"name": "Frome",
"crs": "",
"service_id": "P22597",
"service_date": "2026-04-21",
"processed": true
},
"Weston-super-Mare": {
"name": "Weston-super-Mare",
"crs": "",
"service_id": "P22584",
"service_date": "2026-04-21",
"processed": true
},
"Plymouth": {
"name": "Plymouth",
"crs": "",
"service_id": "P22598",
"service_date": "2026-04-21",
"processed": true
},
"Taunton": {
"name": "Taunton",
"crs": "",
"service_id": "P29610",
"service_date": "2026-04-21",
"processed": true
},
"Great Malvern": {
"name": "Great Malvern",
"crs": "",
"service_id": "P22844",
"service_date": "2026-04-21",
"processed": true
},
"Carmarthen": {
"name": "Carmarthen",
"crs": "",
"service_id": "P22797",
"service_date": "2026-04-21",
"processed": true
},
"Cardiff Central": {
"name": "Cardiff Central",
"crs": "",
"service_id": "P22798",
"service_date": "2026-04-21",
"processed": true
},
"Paignton": {
"name": "Paignton",
"crs": "",
"service_id": "P22603",
"service_date": "2026-04-21",
"processed": true
},
"Exeter St Davids": {
"name": "Exeter St Davids",
"crs": "",
"service_id": "P22606",
"service_date": "2026-04-21",
"processed": true
},
"Worcester Foregate Street": {
"name": "Worcester Foregate Street",
"crs": "",
"service_id": "P22852",
"service_date": "2026-04-21",
"processed": true
},
"Banbury": {
"name": "Banbury",
"crs": "",
"service_id": "P30629",
"service_date": "2026-04-21",
"processed": true
},
"Greenford": {
"name": "Greenford",
"crs": "",
"service_id": "P31884",
"service_date": "2026-04-21",
"processed": true
}
}

125
scraper/gwr_fares.py Normal file
View file

@ -0,0 +1,125 @@
"""
Fetch GWR walk-on single fares from any station to London Paddington.
Uses the GWR journey search API (same API as www.gwr.com ticket search).
Returns per-train cheapest standard-class fare with restrictions already applied.
Cache for 30 days fares rarely change.
"""
import httpx
_API_URL = "https://api.gwr.com/api/shopping/journeysearch"
# API key is embedded in the GWR web app (appvalues.prod.json)
_API_KEY = "OgovGqAlLp4gWAhL7DQLo7pMCt8GHi2U4SPFiZgG"
_PAD_CODE = "GBQQP" # London Paddington cluster code as used by GWR website
_WANTED_CODES = {"SSS", "SVS", "SDS"}
_MAX_PAGES = 20
def _headers() -> dict:
return {
"user-agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
),
"accept": "application/json, text/plain, */*",
"channel": "WEB",
"content-type": "application/json",
"apikey": _API_KEY,
"origin": "https://www.gwr.com",
"referer": "https://www.gwr.com/",
}
def _request_body(
station_crs: str,
travel_date: str,
conversation_token: str | None,
later: bool,
) -> dict:
return {
"IsNextOutward": False,
"IsPreviousOutward": False,
"IsNextReturn": False,
"IsPreviousReturn": False,
"campaignCode": "",
"validationCode": "",
"locfrom": f"GB{station_crs}",
"locto": _PAD_CODE,
"datetimedepart": f"{travel_date}T00:00:00",
"outwarddepartafter": True,
"datetimereturn": None,
"returndepartafter": False,
"directServicesOnly": False,
"firstclass": False,
"standardclass": True,
"adults": 1,
"children": 0,
"openreturn": False,
"via": None,
"avoid": None,
"isEarlierSearch": False,
"isLaterSearch": later,
"isEarlierSearchReturn": False,
"isLaterSearchReturn": False,
"railcards": [],
"conversationToken": conversation_token,
}
def fetch(station_crs: str, travel_date: str) -> dict[str, dict]:
"""
Fetch GWR 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 ticket
per departure (with restrictions already applied by GWR) is kept.
"""
result: dict[str, dict] = {}
with httpx.Client(headers=_headers(), timeout=30) as client:
conversation_token = None
later = False
for _ in range(_MAX_PAGES):
body = _request_body(station_crs, travel_date, conversation_token, later)
resp = client.post(_API_URL, json=body)
resp.raise_for_status()
data = resp.json().get("data", {})
conversation_token = data.get("conversationToken")
for journey in data.get("outwardOpenPureReturnFare", []):
dep_iso = journey.get("departureTime", "")
dep_time = dep_iso[11:16] # "HH:MM" from "2026-04-10T09:08:00"
if not dep_time or dep_time in result:
continue
cheapest = None
for fare in journey.get("journeyFareDetails", []):
code = fare.get("ticketTypeCode")
if code not in _WANTED_CODES:
continue
if not fare.get("isStandardClass"):
continue
price_pence = fare.get("fare", 0)
if cheapest is None or price_pence < cheapest["price_pence"]:
cheapest = {
"ticket": fare.get("ticketType", ""),
"price": price_pence / 100,
"price_pence": price_pence,
"code": code,
}
if cheapest:
result[dep_time] = {
"ticket": cheapest["ticket"],
"price": cheapest["price"],
"code": cheapest["code"],
}
if not data.get("showLaterOutward", False):
break
later = True
return result

View file

@ -10,14 +10,14 @@ import re
import httpx import httpx
import lxml.html import lxml.html
BRI_TO_PAD = ( _TO_PAD_TMPL = (
"https://www.realtimetrains.co.uk/search/detailed/" "https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:BRI/to/gb-nr:PAD/{date}/0000-2359" "gb-nr:{crs}/to/gb-nr:PAD/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt" "?stp=WVS&show=pax-calls&order=wtt"
) )
PAD_FROM_BRI = ( _PAD_FROM_TMPL = (
"https://www.realtimetrains.co.uk/search/detailed/" "https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:PAD/from/gb-nr:BRI/{date}/0000-2359" "gb-nr:PAD/from/gb-nr:{crs}/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt" "?stp=WVS&show=pax-calls&order=wtt"
) )
@ -68,19 +68,48 @@ def _parse_services(html: str, time_selector: str) -> dict[str, str]:
return result return result
def fetch(date: str, user_agent: str = DEFAULT_UA) -> list[dict]: def _parse_arrivals(html: str) -> dict[str, dict]:
"""Fetch GWR trains; returns [{'depart_bristol', 'arrive_paddington', 'headcode'}].""" """Return {train_id: {'time': ..., 'platform': ...}} from a PAD arrivals page."""
root = lxml.html.fromstring(html)
sl = root.cssselect('div.servicelist')
if not sl:
return {}
result = {}
for svc in sl[0].cssselect('a.service'):
tid_els = svc.cssselect('div.tid')
time_els = svc.cssselect('div.time.plan.a')
if not (tid_els and time_els):
continue
time_text = time_els[0].text_content().strip()
if not time_text:
continue
plat_els = svc.cssselect('div.platform')
platform = plat_els[0].text_content().strip() if plat_els else ''
result[tid_els[0].text_content().strip()] = {
'time': _fmt(time_text),
'platform': platform,
}
return result
def fetch(date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI') -> list[dict]:
"""Fetch trains from station_crs to PAD; returns [{'depart_bristol', 'arrive_paddington', 'headcode', 'arrive_platform'}]."""
headers = _browser_headers(user_agent) headers = _browser_headers(user_agent)
with httpx.Client(headers=headers, follow_redirects=True, timeout=30) as client: with httpx.Client(headers=headers, follow_redirects=True, timeout=30) as client:
r_bri = client.get(BRI_TO_PAD.format(date=date)) r_bri = client.get(_TO_PAD_TMPL.format(crs=station_crs, date=date))
r_pad = client.get(PAD_FROM_BRI.format(date=date)) r_pad = client.get(_PAD_FROM_TMPL.format(crs=station_crs, date=date))
departures = _parse_services(r_bri.text, 'div.time.plan.d') departures = _parse_services(r_bri.text, 'div.time.plan.d')
arrivals = _parse_services(r_pad.text, 'div.time.plan.a') arrivals = _parse_arrivals(r_pad.text)
trains = [ trains = [
{'depart_bristol': dep, 'arrive_paddington': arr, 'headcode': tid} {
'depart_bristol': dep,
'arrive_paddington': arrivals[tid]['time'],
'arrive_platform': arrivals[tid]['platform'],
'headcode': tid,
}
for tid, dep in departures.items() for tid, dep in departures.items()
if (arr := arrivals.get(tid)) if tid in arrivals
] ]
return sorted(trains, key=lambda t: t['depart_bristol']) return sorted(trains, key=lambda t: t['depart_bristol'])

View file

@ -43,7 +43,7 @@
} }
main { main {
max-width: 960px; max-width: 1100px;
margin: 2rem auto; margin: 2rem auto;
padding: 0 1rem; padding: 0 1rem;
} }

View file

@ -3,12 +3,13 @@
<div class="card"> <div class="card">
<h2>Plan your journey</h2> <h2>Plan your journey</h2>
<form method="get" action="{{ url_for('search') }}"> <form method="get" action="{{ url_for('search') }}">
<div class="form-group"> <div class="form-group-lg">
<span class="field-label">Departure point</span> <label for="station_crs" class="field-label">Departure point</label>
<div class="fixed-station" aria-label="Departure point"> <select id="station_crs" name="station_crs" class="form-control">
<strong>Bristol Temple Meads</strong> {% for name, crs in stations %}
<span>Fixed starting station for all journeys</span> <option value="{{ crs }}" {% if crs == 'BRI' %}selected{% endif %}>{{ name }} ({{ crs }})</option>
</div> {% endfor %}
</select>
</div> </div>
<div class="form-group"> <div class="form-group">

View file

@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Bristol to {{ destination }} via Eurostar{% endblock %} {% block title %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %}
{% block og_title %}Bristol to {{ destination }} via Eurostar{% endblock %} {% block og_title %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %}
{% block og_description %}Train options from Bristol Temple Meads to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %} {% block og_description %}Train options from {{ departure_station_name }} to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
{% block twitter_title %}Bristol to {{ destination }} via Eurostar{% endblock %} {% block twitter_title %}{{ departure_station_name }} 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 {{ departure_station_name }} to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
{% block content %} {% block content %}
<p class="back-link"> <p class="back-link">
@ -12,13 +12,13 @@
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<h2> <h2>
Bristol Temple Meads &rarr; {{ destination }} {{ departure_station_name }} &rarr; {{ destination }}
</h2> </h2>
<div class="date-nav"> <div class="date-nav">
<a href="{{ url_for('results', slug=slug, travel_date=prev_date, min_connection=min_connection, max_connection=max_connection) }}" <a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, min_connection=min_connection, max_connection=max_connection) }}"
class="btn-nav">&larr; Prev</a> class="btn-nav">&larr; Prev</a>
<strong>{{ travel_date_display }}</strong> <strong>{{ travel_date_display }}</strong>
<a href="{{ url_for('results', slug=slug, travel_date=next_date, min_connection=min_connection, max_connection=max_connection) }}" <a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, min_connection=min_connection, max_connection=max_connection) }}"
class="btn-nav">Next &rarr;</a> class="btn-nav">Next &rarr;</a>
</div> </div>
<div class="switcher-section"> <div class="switcher-section">
@ -30,7 +30,7 @@
{% else %} {% else %}
<a <a
class="chip-link" class="chip-link"
href="{{ url_for('results', slug=destination_slug, travel_date=travel_date, min_connection=min_connection, max_connection=max_connection) }}" href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, min_connection=min_connection, max_connection=max_connection) }}"
>{{ destination_name }}</a> >{{ destination_name }}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -66,19 +66,13 @@
function applyConnectionFilter() { function applyConnectionFilter() {
var min = document.getElementById('min_conn_select').value; var min = document.getElementById('min_conn_select').value;
var max = document.getElementById('max_conn_select').value; var max = document.getElementById('max_conn_select').value;
window.location = '{{ url_for('results', slug=slug, travel_date=travel_date) }}?min_connection=' + min + '&max_connection=' + max; window.location = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}?min_connection=' + min + '&max_connection=' + max;
} }
</script> </script>
<p class="card-meta"> <p class="card-meta">
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }} {{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
&nbsp;&middot;&nbsp; &nbsp;&middot;&nbsp;
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }} {{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
{% if unreachable_morning_services %}
&nbsp;&middot;&nbsp;
<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 %} {% if from_cache %}
&nbsp;&middot;&nbsp; <span class="text-muted text-sm">(cached)</span> &nbsp;&middot;&nbsp; <span class="text-muted text-sm">(cached)</span>
{% endif %} {% endif %}
@ -100,7 +94,7 @@
<table class="results-table"> <table class="results-table">
<thead> <thead>
<tr> <tr>
<th class="nowrap">Bristol</th> <th class="nowrap">{{ departure_station_name }}</th>
<th class="nowrap">Paddington</th> <th class="nowrap">Paddington</th>
<th class="nowrap">GWR Fare</th> <th class="nowrap">GWR Fare</th>
<th class="col-transfer nowrap">Transfer</th> <th class="col-transfer nowrap">Transfer</th>
@ -141,14 +135,26 @@
<td> <td>
{{ row.arrive_paddington }} {{ row.arrive_paddington }}
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span> <span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
{% if row.arrive_platform %}<br><span class="text-xs text-muted">Plat {{ row.arrive_platform }}</span>{% endif %}
</td> </td>
<td class="nowrap"> <td class="nowrap">
{% if row.ticket_price is not none %}
£{{ "%.2f"|format(row.ticket_price) }} £{{ "%.2f"|format(row.ticket_price) }}
<br><span class="text-xs text-muted">{{ row.ticket_name }}</span> <br><span class="text-xs text-muted">{{ row.ticket_name }}</span>
{% else %}
<span class="text-muted">&ndash;</span>
{% endif %}
</td> </td>
<td class="col-transfer nowrap" style="color:#4a5568"> <td class="col-transfer nowrap" style="color:#4a5568">
{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %} {{ 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> {% if row.circle_services %}
{% set c = row.circle_services[0] %}
<br><span class="text-xs text-muted">Circle {{ c.depart }} → KX {{ c.arrive_kx }}</span>
{% if row.circle_services | length > 1 %}
{% set c2 = row.circle_services[1] %}
<br><span class="text-xs text-muted" style="opacity:0.7">next {{ c2.depart }} → KX {{ c2.arrive_kx }}</span>
{% endif %}
{% endif %}
</td> </td>
<td class="font-bold"> <td class="font-bold">
{{ row.depart_st_pancras }} {{ row.depart_st_pancras }}
@ -184,8 +190,8 @@
{% else %} {% else %}
<td class="font-bold">&mdash;</td> <td class="font-bold">&mdash;</td>
<td>&mdash;</td> <td>&mdash;</td>
<td>&mdash;</td>
<td class="col-transfer">&mdash;</td> <td class="col-transfer">&mdash;</td>
<td>n/a</td>
<td class="font-bold"> <td class="font-bold">
{{ row.depart_st_pancras }} {{ row.depart_st_pancras }}
{% 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 %} {% 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 %}
@ -206,7 +212,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="font-bold"> <td class="font-bold">
<span title="No same-day Bristol connection" class="text-dimmed nowrap">Too early</span> <span title="Too early to reach from {{ departure_station_name }}" class="text-dimmed nowrap">Too early</span>
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
@ -217,11 +223,12 @@
<p class="footnote"> <p class="footnote">
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 for Bristol Temple Meads&nbsp;&rarr;&nbsp;Paddington. GWR walk-on single prices from
<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 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_bristol_url }}" target="_blank" rel="noopener">Bristol departures on RTT</a> <a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} departures on RTT</a>
&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>
</p> </p>

View file

@ -6,16 +6,21 @@ def _client():
return app_module.app.test_client() return app_module.app.test_client()
def _stub_data(monkeypatch, prices=None): def _stub_data(monkeypatch, prices=None, gwr_fares=None):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) 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, 'set_cached', lambda key, data: None)
monkeypatch.setattr( monkeypatch.setattr(
app_module.rtt_scraper, app_module.rtt_scraper,
'fetch', 'fetch',
lambda travel_date, user_agent: [ lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
], ],
) )
monkeypatch.setattr(
app_module.gwr_fares_scraper,
'fetch',
lambda station_crs, travel_date: gwr_fares or {'07:00': {'ticket': 'Anytime Day Single', 'price': 138.70, 'code': 'SDS'}},
)
p = (prices or {}).get('10:01', {}) p = (prices or {}).get('10:01', {})
monkeypatch.setattr( monkeypatch.setattr(
app_module.eurostar_scraper, app_module.eurostar_scraper,
@ -33,7 +38,7 @@ def _stub_data(monkeypatch, prices=None):
) )
def test_index_shows_fixed_departure_and_destination_radios(): def test_index_shows_station_dropdown_and_destination_radios():
client = _client() client = _client()
resp = client.get('/') resp = client.get('/')
@ -42,6 +47,7 @@ def test_index_shows_fixed_departure_and_destination_radios():
assert resp.status_code == 200 assert resp.status_code == 200
assert 'Departure point' in html assert 'Departure point' in html
assert 'Bristol Temple Meads' in html assert 'Bristol Temple Meads' in html
assert 'name="station_crs"' in html
assert html.count('type="radio"') == len(app_module.DESTINATIONS) assert html.count('type="radio"') == len(app_module.DESTINATIONS)
assert 'destination-rotterdam' in html assert 'destination-rotterdam' in html
@ -49,11 +55,11 @@ def test_index_shows_fixed_departure_and_destination_radios():
def test_search_redirects_to_results_with_selected_params(): def test_search_redirects_to_results_with_selected_params():
client = _client() client = _client()
resp = client.get('/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120') resp = client.get('/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120&station_crs=BRI')
assert resp.status_code == 302 assert resp.status_code == 302
assert resp.headers['Location'].endswith( assert resp.headers['Location'].endswith(
'/results/rotterdam/2026-04-10?min_connection=60&max_connection=120' '/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120'
) )
@ -61,14 +67,14 @@ def test_results_shows_same_day_destination_switcher(monkeypatch):
_stub_data(monkeypatch) _stub_data(monkeypatch)
client = _client() client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
assert 'Switch destination for Friday 10 April 2026' in html assert 'Switch destination for Friday 10 April 2026' in html
assert '<span class="chip-current">Paris Gare du Nord</span>' in html assert '<span class="chip-current">Paris Gare du Nord</span>' in html
assert '/results/brussels/2026-04-10?min_connection=60&amp;max_connection=120' in html assert '/results/BRI/brussels/2026-04-10?min_connection=60&amp;max_connection=120' in html
assert '/results/rotterdam/2026-04-10?min_connection=60&amp;max_connection=120' in html assert '/results/BRI/rotterdam/2026-04-10?min_connection=60&amp;max_connection=120' in html
assert 'ES 9014' in html assert 'ES 9014' in html
@ -76,26 +82,27 @@ def test_results_title_and_social_meta_include_destination(monkeypatch):
_stub_data(monkeypatch) _stub_data(monkeypatch)
client = _client() client = _client()
resp = client.get('/results/lille/2026-04-10?min_connection=60&max_connection=120') resp = client.get('/results/BRI/lille/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
assert '<title>Bristol to Lille Europe via Eurostar</title>' in html assert '<title>Bristol Temple Meads to Lille Europe via Eurostar</title>' in html
assert '<meta property="og:title" content="Bristol to Lille Europe via Eurostar">' in html assert '<meta property="og:title" content="Bristol Temple Meads to Lille Europe via Eurostar">' in html
assert ( assert (
'<meta property="og:description" content="Train options from Bristol Temple Meads ' '<meta property="og:description" content="Train options from Bristol Temple Meads '
'to Lille Europe on Friday 10 April 2026 via Paddington, St Pancras, and Eurostar.">' 'to Lille Europe on Friday 10 April 2026 via Paddington, St Pancras, and Eurostar.">'
) in html ) in html
assert '<meta property="og:url" content="http://localhost/results/lille/2026-04-10?min_connection=60&amp;max_connection=120">' in html assert '<meta property="og:url" content="http://localhost/results/BRI/lille/2026-04-10?min_connection=60&amp;max_connection=120">' in html
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, ttl=None: None) 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, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr( monkeypatch.setattr(
app_module.rtt_scraper, app_module.rtt_scraper,
'fetch', 'fetch',
lambda travel_date, user_agent: [ lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:30', 'headcode': '1A01'}, {'depart_bristol': '07:00', 'arrive_paddington': '08:30', 'headcode': '1A01'},
{'depart_bristol': '07:05', 'arrive_paddington': '08:36', 'headcode': '1A02'}, {'depart_bristol': '07:05', 'arrive_paddington': '08:36', 'headcode': '1A02'},
{'depart_bristol': '07:10', 'arrive_paddington': '08:46', 'headcode': '1A03'}, {'depart_bristol': '07:10', 'arrive_paddington': '08:46', 'headcode': '1A03'},
@ -116,7 +123,7 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
) )
client = _client() client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
@ -130,13 +137,18 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
assert '5h 10m 🐢' not in html assert '5h 10m 🐢' not in html
def test_results_shows_unreachable_morning_eurostar_services(monkeypatch): def test_results_shows_only_pre_first_reachable_unreachable_services(monkeypatch):
# GWR arrives 08:45; min=60 → earliest viable Eurostar 09:45; max=120 → latest 10:45.
# 09:30 too early → shown as "Too early"
# 10:15 reachable → shown as a trip (needs circle line XML, so not tested here)
# 12:30 after first reachable → hidden
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) 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, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr( monkeypatch.setattr(
app_module.rtt_scraper, app_module.rtt_scraper,
'fetch', 'fetch',
lambda travel_date, user_agent: [ lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
], ],
) )
@ -151,15 +163,13 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
) )
client = _client() client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
assert '2 Eurostar services unavailable from Bristol' in html assert 'ES 9001' in html # before first reachable → shown
assert '09:30' in html
assert 'ES 9001' in html
assert 'Too early' in html assert 'Too early' in html
assert html.index('09:30') < html.index('10:15') assert 'ES 9003' not in html # after first reachable → hidden
def test_results_shows_eurostar_price_and_total(monkeypatch): def test_results_shows_eurostar_price_and_total(monkeypatch):
@ -167,7 +177,7 @@ def test_results_shows_eurostar_price_and_total(monkeypatch):
_stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}}) _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}})
client = _client() client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
@ -175,13 +185,16 @@ def test_results_shows_eurostar_price_and_total(monkeypatch):
assert '£197.70' in html # Anytime £138.70 + ES £59 assert '£197.70' in html # Anytime £138.70 + ES £59
def test_results_can_show_only_unreachable_morning_services(monkeypatch): def test_results_shows_unreachable_service_when_no_trips(monkeypatch):
# Only one Eurostar at 09:30; GWR arrives 08:45 with min=60 → unreachable.
# No trips at all, so the unreachable service is shown as "Too early".
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) 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, 'set_cached', lambda key, data: None)
monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {})
monkeypatch.setattr( monkeypatch.setattr(
app_module.rtt_scraper, app_module.rtt_scraper,
'fetch', 'fetch',
lambda travel_date, user_agent: [ lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
], ],
) )
@ -194,11 +207,10 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
) )
client = _client() client = _client()
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120')
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
assert resp.status_code == 200 assert resp.status_code == 200
assert 'No valid journeys found.' not in html assert 'ES 9001' in html
assert '1 Eurostar service unavailable from Bristol' in html
assert '09:30' in html
assert 'Too early' in html assert 'Too early' in html
assert 'No valid journeys found.' not in html

View file

@ -1,5 +1,5 @@
import pytest import pytest
from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration, cheapest_gwr_ticket from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration
DATE = '2026-03-30' DATE = '2026-03-30'
@ -23,7 +23,7 @@ def test_fmt_duration_minutes_only():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
GWR_FAST = {'depart_bristol': '07:00', 'arrive_paddington': '08:45'} # 1h 45m GWR_FAST = {'depart_bristol': '07:00', 'arrive_paddington': '08:45'} # 1h 45m
GWR_SLOW = {'depart_bristol': '07:00', 'arrive_paddington': '09:26'} # 2h 26m — over limit GWR_SLOW = {'depart_bristol': '07:00', 'arrive_paddington': '09:26'} # 2h 26m — connection too short for ES_PARIS
ES_PARIS = {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', 'destination': 'Paris Gare du Nord'} ES_PARIS = {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', 'destination': 'Paris Gare du Nord'}
ES_EARLY = {'depart_st_pancras': '09:00', 'arrive_destination': '12:00', 'destination': 'Paris Gare du Nord'} ES_EARLY = {'depart_st_pancras': '09:00', 'arrive_destination': '12:00', 'destination': 'Paris Gare du Nord'}
@ -41,7 +41,7 @@ def test_valid_trip_is_returned():
def test_gwr_too_slow_excluded(): def test_gwr_too_slow_excluded():
# 2h 26m GWR journey exceeds MAX_GWR_MINUTES (110) # arrive 09:26, Eurostar 10:01 → 35 min connection < 50 min minimum
trips = combine_trips([GWR_SLOW], [ES_PARIS], DATE) trips = combine_trips([GWR_SLOW], [ES_PARIS], DATE)
assert trips == [] assert trips == []
@ -148,56 +148,6 @@ def test_find_unreachable_eurostars_excludes_connectable_services():
assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30'] assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30']
# ---------------------------------------------------------------------------
# cheapest_gwr_ticket — Bristol Temple Meads → Paddington
# ---------------------------------------------------------------------------
# 2026-03-30 is a Monday; 2026-03-28 is a Saturday
def test_cheapest_ticket_weekday_super_off_peak_morning():
# 05:00 on Monday: dep ≤ 05:04 → Super Off-Peak
t = cheapest_gwr_ticket('05:00', '2026-03-30')
assert t['ticket'] == 'Super Off-Peak'
assert t['price'] == 45.00
def test_cheapest_ticket_weekday_anytime_window():
# 07:00 on Monday: 05:0508:25 → Anytime only
t = cheapest_gwr_ticket('07:00', '2026-03-30')
assert t['ticket'] == 'Anytime'
assert t['price'] == 138.70
def test_cheapest_ticket_weekday_off_peak():
# 08:30 on Monday: dep ≥ 08:26 but < 09:58 → Off-Peak
t = cheapest_gwr_ticket('08:30', '2026-03-30')
assert t['ticket'] == 'Off-Peak'
assert t['price'] == 63.60
def test_cheapest_ticket_weekday_super_off_peak_late():
# 10:00 on Monday: dep ≥ 09:58 → Super Off-Peak
t = cheapest_gwr_ticket('10:00', '2026-03-30')
assert t['ticket'] == 'Super Off-Peak'
assert t['price'] == 45.00
def test_cheapest_ticket_boundary_super_off_peak_cutoff():
# 05:04 is last valid minute for early Super Off-Peak
assert cheapest_gwr_ticket('05:04', '2026-03-30')['ticket'] == 'Super Off-Peak'
# 05:05 falls into the Anytime window (off-peak starts at 08:26)
assert cheapest_gwr_ticket('05:05', '2026-03-30')['ticket'] == 'Anytime'
def test_cheapest_ticket_boundary_off_peak_start():
assert cheapest_gwr_ticket('08:25', '2026-03-30')['ticket'] == 'Anytime'
assert cheapest_gwr_ticket('08:26', '2026-03-30')['ticket'] == 'Off-Peak'
def test_cheapest_ticket_boundary_super_off_peak_resumes():
assert cheapest_gwr_ticket('09:57', '2026-03-30')['ticket'] == 'Off-Peak'
assert cheapest_gwr_ticket('09:58', '2026-03-30')['ticket'] == 'Super Off-Peak'
def test_cheapest_ticket_weekend_always_super_off_peak():
# Saturday — no restrictions
t = cheapest_gwr_ticket('07:00', '2026-03-28')
assert t['ticket'] == 'Super Off-Peak'
assert t['price'] == 45.00
def test_combine_trips_includes_ticket_fields(): def test_combine_trips_includes_ticket_fields():
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) trips = combine_trips([GWR_FAST], [ES_PARIS], DATE)
assert len(trips) == 1 assert len(trips) == 1
@ -206,6 +156,18 @@ def test_combine_trips_includes_ticket_fields():
assert 'ticket_price' in t assert 'ticket_price' in t
assert 'ticket_code' in t assert 'ticket_code' in t
def test_combine_trips_uses_gwr_fares_when_provided():
fares = {'07:00': {'ticket': 'Super Off-Peak Single', 'price': 49.30, 'code': 'SSS'}}
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares=fares)
assert len(trips) == 1
assert trips[0]['ticket_price'] == 49.30
assert trips[0]['ticket_code'] == 'SSS'
def test_combine_trips_ticket_price_none_when_no_fares():
trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares={})
assert len(trips) == 1
assert trips[0]['ticket_price'] is None
def test_find_unreachable_eurostars_returns_empty_when_all_connectable(): def test_find_unreachable_eurostars_returns_empty_when_all_connectable():
gwr = [ gwr = [

View file

@ -1,71 +1,39 @@
""" """
Combine GWR BristolPaddington trains with Eurostar St Pancrasdestination trains. Combine GWR stationPaddington trains with Eurostar St Pancrasdestination trains.
""" """
from datetime import datetime, timedelta, time as _time
from datetime import datetime, timedelta
import circle_line import circle_line
MIN_CONNECTION_MINUTES = 50 MIN_CONNECTION_MINUTES = 50
MAX_CONNECTION_MINUTES = 110 MAX_CONNECTION_MINUTES = 110
MAX_GWR_MINUTES = 110 DATE_FMT = "%Y-%m-%d"
DATE_FMT = '%Y-%m-%d' TIME_FMT = "%H:%M"
TIME_FMT = '%H:%M'
PAD_WALK_TO_UNDERGROUND_MINUTES = 7 # GWR platform → Paddington (H&C Line) platform PAD_WALK_TO_UNDERGROUND_MINUTES = 8 # 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):
# Super Off-Peak (£45.00, SSS): not valid if 05:05 ≤ dep ≤ 09:57
# Off-Peak (£63.60, SVS): not valid if dep < 08:26, except 02:0005:10
# Anytime (£138.70, SDS): no restrictions
_TICKET_SUPER_OFF_PEAK = {'ticket': 'Super Off-Peak', 'price': 45.00, 'code': 'SSS'}
_TICKET_OFF_PEAK = {'ticket': 'Off-Peak', 'price': 63.60, 'code': 'SVS'}
_TICKET_ANYTIME = {'ticket': 'Anytime', 'price': 138.70, 'code': 'SDS'}
def cheapest_gwr_ticket(depart_time: str, travel_date: str) -> dict:
"""
Return the cheapest walk-on single for Bristol Temple Meads Paddington.
Weekday (MonFri) restrictions derived from the SSS/SVS ticket conditions:
Super Off-Peak valid: dep 05:04 or dep 09:58
Off-Peak valid: 02:00 dep 05:10 or dep 08:26
Weekends: no restrictions always Super Off-Peak.
"""
dt = datetime.strptime(f"{travel_date} {depart_time}", f"{DATE_FMT} {TIME_FMT}")
if dt.weekday() >= 5: # Saturday or Sunday
return _TICKET_SUPER_OFF_PEAK
dep = dt.time()
if dep <= _time(5, 4) or dep >= _time(9, 58):
return _TICKET_SUPER_OFF_PEAK
if dep >= _time(8, 26):
return _TICKET_OFF_PEAK
return _TICKET_ANYTIME
def _parse_dt(date: str, time: str) -> datetime: def _parse_dt(date: str, time: str) -> datetime:
return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}") return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}")
def _circle_line_times(arrive_paddington: datetime) -> tuple[str, str] | tuple[None, None]: def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
""" """
Given GWR arrival at Paddington, return (circle_line_depart_str, arrive_checkin_str). Given GWR arrival at Paddington, return up to 2 upcoming Circle line services
as [{'depart': 'HH:MM', 'arrive_kx': 'HH:MM'}, ...].
Adds PAD_WALK_TO_UNDERGROUND_MINUTES to get the earliest boarding time, looks up Each entry gives the departure from Paddington (H&C Line) and the actual
the next real Circle Line service from the timetable, then adds KX_WALK_TO_CHECKIN_MINUTES arrival at King's Cross St Pancras underground station.
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) earliest_board = arrive_paddington + timedelta(
result = circle_line.next_service(earliest_board) minutes=PAD_WALK_TO_UNDERGROUND_MINUTES
if result is None: )
return None, None services = circle_line.upcoming_services(earliest_board, count=2)
circle_depart, arrive_kx = result return [
arrive_checkin = arrive_kx + timedelta(minutes=KX_WALK_TO_CHECKIN_MINUTES) {"depart": dep.strftime(TIME_FMT), "arrive_kx": arr.strftime(TIME_FMT)}
return circle_depart.strftime(TIME_FMT), arrive_checkin.strftime(TIME_FMT) for dep, arr in services
]
def _fmt_duration(minutes: int) -> str: def _fmt_duration(minutes: int) -> str:
@ -85,15 +53,15 @@ def _is_viable_connection(
max_connection_minutes: int, max_connection_minutes: int,
) -> tuple[datetime, datetime, datetime, datetime] | None: ) -> tuple[datetime, datetime, datetime, datetime] | None:
try: try:
arr_pad = _parse_dt(travel_date, gwr['arrive_paddington']) arr_pad = _parse_dt(travel_date, gwr["arrive_paddington"])
dep_bri = _parse_dt(travel_date, gwr['depart_bristol']) dep_bri = _parse_dt(travel_date, gwr["depart_bristol"])
dep_stp = _parse_dt(travel_date, eurostar['depart_st_pancras']) dep_stp = _parse_dt(travel_date, eurostar["depart_st_pancras"])
arr_dest = _parse_dt(travel_date, eurostar['arrive_destination']) arr_dest = _parse_dt(travel_date, eurostar["arrive_destination"])
except (ValueError, KeyError): except (ValueError, KeyError):
return None return None
if int((arr_pad - dep_bri).total_seconds() / 60) > MAX_GWR_MINUTES: if arr_pad < dep_bri:
return None arr_pad += timedelta(days=1)
if arr_dest < dep_stp: if arr_dest < dep_stp:
arr_dest += timedelta(days=1) arr_dest += timedelta(days=1)
@ -113,6 +81,7 @@ def combine_trips(
travel_date: str, travel_date: str,
min_connection_minutes: int = MIN_CONNECTION_MINUTES, min_connection_minutes: int = MIN_CONNECTION_MINUTES,
max_connection_minutes: int = MAX_CONNECTION_MINUTES, max_connection_minutes: int = MAX_CONNECTION_MINUTES,
gwr_fares: dict | None = None,
) -> list[dict]: ) -> list[dict]:
""" """
Return a list of valid combined trips, sorted by Bristol departure time. Return a list of valid combined trips, sorted by Bristol departure time.
@ -146,31 +115,37 @@ def combine_trips(
total_mins = int((arr_dest - dep_bri).total_seconds() / 60) total_mins = int((arr_dest - dep_bri).total_seconds() / 60)
# Destination time is CET/CEST, departure is GMT/BST; Europe is always 1h ahead. # 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 eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
ticket = cheapest_gwr_ticket(gwr['depart_bristol'], travel_date) fare = (gwr_fares or {}).get(gwr["depart_bristol"])
circle_depart, arrive_checkin = _circle_line_times(arr_pad) circle_svcs = _circle_line_services(arr_pad)
trips.append({ trips.append(
'depart_bristol': gwr['depart_bristol'], {
'arrive_paddington': gwr['arrive_paddington'], "depart_bristol": gwr["depart_bristol"],
'headcode': gwr.get('headcode', ''), "arrive_paddington": gwr["arrive_paddington"],
'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)), "arrive_platform": gwr.get("arrive_platform", ""),
'connection_minutes': int((dep_stp - arr_pad).total_seconds() / 60), "headcode": gwr.get("headcode", ""),
'connection_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)), "gwr_duration": _fmt_duration(
'circle_line_depart': circle_depart, int((arr_pad - dep_bri).total_seconds() / 60)
'circle_arrive_checkin': arrive_checkin, ),
'depart_st_pancras': es['depart_st_pancras'], "connection_minutes": int((dep_stp - arr_pad).total_seconds() / 60),
'arrive_destination': es['arrive_destination'], "connection_duration": _fmt_duration(
'eurostar_duration': _fmt_duration(eurostar_mins), int((dep_stp - arr_pad).total_seconds() / 60)
'train_number': es.get('train_number', ''), ),
'total_duration': _fmt_duration(total_mins), "circle_services": circle_svcs,
'total_minutes': total_mins, "depart_st_pancras": es["depart_st_pancras"],
'destination': es['destination'], "arrive_destination": es["arrive_destination"],
'ticket_name': ticket['ticket'], "eurostar_duration": _fmt_duration(eurostar_mins),
'ticket_price': ticket['price'], "train_number": es.get("train_number", ""),
'ticket_code': ticket['code'], "total_duration": _fmt_duration(total_mins),
}) "total_minutes": total_mins,
"destination": es["destination"],
"ticket_name": fare["ticket"] if fare else None,
"ticket_price": fare["price"] if fare else None,
"ticket_code": fare["code"] if fare else None,
}
)
break # Only the earliest valid Eurostar per GWR departure break # Only the earliest valid Eurostar per GWR departure
trips.sort(key=lambda t: (t['depart_bristol'], t['depart_st_pancras'])) trips.sort(key=lambda t: (t["depart_bristol"], t["depart_st_pancras"]))
return trips return trips
@ -196,11 +171,11 @@ def find_unreachable_morning_eurostars(
): ):
continue continue
dep_stp = _parse_dt(travel_date, es['depart_st_pancras']) dep_stp = _parse_dt(travel_date, es["depart_st_pancras"])
arr_dest = _parse_dt(travel_date, es['arrive_destination']) arr_dest = _parse_dt(travel_date, es["arrive_destination"])
if arr_dest < dep_stp: if arr_dest < dep_stp:
arr_dest += timedelta(days=1) arr_dest += timedelta(days=1)
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60 eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
unreachable.append({**es, 'eurostar_duration': _fmt_duration(eurostar_mins)}) unreachable.append({**es, "eurostar_duration": _fmt_duration(eurostar_mins)})
return sorted(unreachable, key=lambda s: s['depart_st_pancras']) return sorted(unreachable, key=lambda s: s["depart_st_pancras"])