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:
parent
71be0dd8cf
commit
3c787b33d3
12 changed files with 810 additions and 262 deletions
183
app.py
183
app.py
|
|
@ -1,55 +1,75 @@
|
||||||
"""
|
"""
|
||||||
Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains.
|
Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination 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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
129
data/direct_to_paddington.tsv
Normal file
129
data/direct_to_paddington.tsv
Normal 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
|
||||||
|
212
data/pad_origins.json
Normal file
212
data/pad_origins.json
Normal 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
125
scraper/gwr_fares.py
Normal 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
|
||||||
|
|
@ -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'])
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
max-width: 960px;
|
max-width: 1100px;
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 → {{ destination }}
|
{{ departure_station_name }} → {{ 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">← Prev</a>
|
class="btn-nav">← 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 →</a>
|
class="btn-nav">Next →</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 }}
|
||||||
·
|
·
|
||||||
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
|
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
|
||||||
{% if unreachable_morning_services %}
|
|
||||||
·
|
|
||||||
<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 %}
|
||||||
· <span class="text-muted text-sm">(cached)</span>
|
· <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">–</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">—</td>
|
<td class="font-bold">—</td>
|
||||||
<td>—</td>
|
<td>—</td>
|
||||||
|
<td>—</td>
|
||||||
<td class="col-transfer">—</td>
|
<td class="col-transfer">—</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 → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
||||||
GWR walk-on single prices for Bristol Temple Meads → 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.
|
||||||
·
|
·
|
||||||
<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>
|
||||||
·
|
·
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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&max_connection=120' in html
|
assert '/results/BRI/brussels/2026-04-10?min_connection=60&max_connection=120' in html
|
||||||
assert '/results/rotterdam/2026-04-10?min_connection=60&max_connection=120' in html
|
assert '/results/BRI/rotterdam/2026-04-10?min_connection=60&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&max_connection=120">' in html
|
assert '<meta property="og:url" content="http://localhost/results/BRI/lille/2026-04-10?min_connection=60&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
|
||||||
|
|
|
||||||
|
|
@ -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:05–08: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 = [
|
||||||
|
|
|
||||||
143
trip_planner.py
143
trip_planner.py
|
|
@ -1,71 +1,39 @@
|
||||||
"""
|
"""
|
||||||
Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains.
|
Combine GWR station→Paddington trains with Eurostar St Pancras→destination 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 (Mon–Fri 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:00–05: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 (Mon–Fri) 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"])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue