Add return and inbound journey support

This commit is contained in:
Edward Betts 2026-05-21 08:46:35 +01:00
parent 6ba71447ef
commit 9691632f65
12 changed files with 1687 additions and 486 deletions

482
app.py
View file

@ -12,7 +12,14 @@ 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.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 (
INBOUND_MAX_CONNECTION_MINUTES,
INBOUND_MIN_CONNECTION_MINUTES,
combine_inbound_trips,
combine_trips,
find_unreachable_inbound_eurostars,
find_unreachable_morning_eurostars,
)
RTT_PADDINGTON_URL = ( RTT_PADDINGTON_URL = (
"https://www.realtimetrains.co.uk/search/detailed/" "https://www.realtimetrains.co.uk/search/detailed/"
@ -76,11 +83,15 @@ def index():
default_max_connection=default_max, default_max_connection=default_max,
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),
default_return_date=(date.today() + timedelta(days=7)).isoformat(),
) )
VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120} 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}
VALID_INBOUND_MIN_CONNECTIONS = {20, 30, 40, 45, 50, 60, 70, 80, 90, 100, 110, 120}
VALID_INBOUND_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180}
VALID_JOURNEY_TYPES = {"outbound", "inbound", "return"}
VALID_NR_CLASSES = {'walkon', 'advance_std', 'advance_1st'} VALID_NR_CLASSES = {'walkon', 'advance_std', 'advance_1st'}
VALID_ES_CLASSES = {'standard', 'plus'} VALID_ES_CLASSES = {'standard', 'plus'}
DEFAULT_NR_CLASS = 'walkon' DEFAULT_NR_CLASS = 'walkon'
@ -106,15 +117,24 @@ def _parse_connection(raw, default, valid_set):
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", "")
return_date = request.args.get("return_date", "")
journey_type = request.args.get("journey_type", "outbound")
if journey_type not in VALID_JOURNEY_TYPES:
journey_type = "outbound"
station_crs = request.args.get("station_crs", "BRI") station_crs = request.args.get("station_crs", "BRI")
if station_crs not in STATION_BY_CRS: if station_crs not in STATION_BY_CRS:
station_crs = "BRI" station_crs = "BRI"
default_min, default_max = _get_defaults() if journey_type == "inbound":
default_min, default_max = INBOUND_MIN_CONNECTION_MINUTES, INBOUND_MAX_CONNECTION_MINUTES
valid_min, valid_max = VALID_INBOUND_MIN_CONNECTIONS, VALID_INBOUND_MAX_CONNECTIONS
else:
default_min, default_max = _get_defaults()
valid_min, valid_max = VALID_MIN_CONNECTIONS, VALID_MAX_CONNECTIONS
min_conn = _parse_connection( min_conn = _parse_connection(
request.args.get("min_connection"), default_min, VALID_MIN_CONNECTIONS request.args.get("min_connection"), default_min, valid_min
) )
max_conn = _parse_connection( max_conn = _parse_connection(
request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS request.args.get("max_connection"), default_max, valid_max
) )
nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS) nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
if nr_class not in VALID_NR_CLASSES: if nr_class not in VALID_NR_CLASSES:
@ -122,13 +142,21 @@ def search():
es_class = request.args.get("es_class", DEFAULT_ES_CLASS) es_class = request.args.get("es_class", DEFAULT_ES_CLASS)
if es_class not in VALID_ES_CLASSES: if es_class not in VALID_ES_CLASSES:
es_class = DEFAULT_ES_CLASS es_class = DEFAULT_ES_CLASS
if slug in DESTINATIONS and travel_date: if journey_type == "return":
try:
if return_date and date.fromisoformat(return_date) < date.fromisoformat(travel_date):
return_date = ""
except ValueError:
return_date = ""
if slug in DESTINATIONS and travel_date and (journey_type != "return" or return_date):
return redirect( return redirect(
url_for( url_for(
"results", "results",
station_crs=station_crs, station_crs=station_crs,
slug=slug, slug=slug,
travel_date=travel_date, travel_date=travel_date,
journey_type=None if journey_type == "outbound" else journey_type,
return_date=return_date if journey_type == "return" else None,
min_connection=None if min_conn == default_min else min_conn, min_connection=None if min_conn == default_min else min_conn,
max_connection=None if max_conn == default_max else max_conn, max_connection=None if max_conn == default_max else max_conn,
nr_class=None if nr_class == DEFAULT_NR_CLASS else nr_class, nr_class=None if nr_class == DEFAULT_NR_CLASS else nr_class,
@ -147,12 +175,28 @@ def results(station_crs, slug, travel_date):
if not destination or not travel_date: if not destination or not travel_date:
return redirect(url_for("index")) return redirect(url_for("index"))
default_min, default_max = _get_defaults() journey_type = request.args.get("journey_type", "outbound")
if journey_type not in VALID_JOURNEY_TYPES:
journey_type = "outbound"
return_date = request.args.get("return_date")
if journey_type == "return":
try:
if not return_date or date.fromisoformat(return_date) < date.fromisoformat(travel_date):
return redirect(url_for("index"))
except ValueError:
return redirect(url_for("index"))
if journey_type == "inbound":
default_min, default_max = INBOUND_MIN_CONNECTION_MINUTES, INBOUND_MAX_CONNECTION_MINUTES
valid_min, valid_max = VALID_INBOUND_MIN_CONNECTIONS, VALID_INBOUND_MAX_CONNECTIONS
else:
default_min, default_max = _get_defaults()
valid_min, valid_max = VALID_MIN_CONNECTIONS, VALID_MAX_CONNECTIONS
min_connection = _parse_connection( min_connection = _parse_connection(
request.args.get("min_connection"), default_min, VALID_MIN_CONNECTIONS request.args.get("min_connection"), default_min, valid_min
) )
max_connection = _parse_connection( max_connection = _parse_connection(
request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS request.args.get("max_connection"), default_max, valid_max
) )
nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS) nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
if nr_class not in VALID_NR_CLASSES: if nr_class not in VALID_NR_CLASSES:
@ -161,150 +205,207 @@ def results(station_crs, slug, travel_date):
if es_class not in VALID_ES_CLASSES: if es_class not in VALID_ES_CLASSES:
es_class = DEFAULT_ES_CLASS es_class = DEFAULT_ES_CLASS
# Redirect to clean URL when all params are at their defaults
_clean_url_params = ["min_connection", "max_connection", "nr_class", "es_class"]
if any(k in request.args for k in _clean_url_params) and (
min_connection == default_min
and max_connection == default_max
and nr_class == DEFAULT_NR_CLASS
and es_class == DEFAULT_ES_CLASS
):
return redirect(
url_for("results", station_crs=station_crs, slug=slug, travel_date=travel_date)
)
user_agent = request.headers.get("User-Agent", rtt_scraper.DEFAULT_UA) user_agent = request.headers.get("User-Agent", rtt_scraper.DEFAULT_UA)
error_messages = []
from_cache_parts = []
rtt_cache_key = f"rtt_{station_crs}_{travel_date}" def cached_fetch(key, ttl, fetcher, label):
es_cache_key = f"eurostar_{travel_date}_{destination}" cached = get_cached(key, ttl=ttl)
gwr_fares_cache_key = f"gwr_fares_{station_crs}_{travel_date}" if cached is not None:
gwr_advance_cache_key = f"gwr_advance_{station_crs}_{travel_date}" from_cache_parts.append(key)
return cached
cached_rtt = get_cached(rtt_cache_key)
cached_es = get_cached(es_cache_key, ttl=24 * 3600)
cached_gwr_fares = get_cached(gwr_fares_cache_key, ttl=30 * 24 * 3600)
cached_advance_fares = get_cached(gwr_advance_cache_key, ttl=24 * 3600)
from_cache = bool(cached_rtt and cached_es)
error = None
if cached_rtt:
gwr_trains = cached_rtt
else:
try: try:
gwr_trains = rtt_scraper.fetch(travel_date, user_agent, station_crs) data = fetcher()
set_cached(rtt_cache_key, gwr_trains) set_cached(key, data)
return data
except Exception as e: except Exception as e:
gwr_trains = [] error_messages.append(f"Could not fetch {label}: {e}")
error = f"Could not fetch GWR trains: {e}" return [] if label != "GWR fares" else {}
if cached_es: es_return = None
eurostar_services = cached_es if journey_type == "return":
else: es_return_key = f"eurostar_return_{travel_date}_{return_date}_{destination}"
try: es_return = cached_fetch(
eurostar_services = eurostar_scraper.fetch(destination, travel_date) es_return_key,
set_cached(es_cache_key, eurostar_services) 24 * 3600,
except Exception as e: lambda: eurostar_scraper.fetch_return(destination, travel_date, return_date),
eurostar_services = [] "Eurostar times",
msg = f"Could not fetch Eurostar times: {e}" )
error = f"{error}; {msg}" if error else msg if not isinstance(es_return, dict):
es_return = {"outbound": [], "inbound": []}
if cached_gwr_fares: def build_section(section_id, direction, section_date, eurostar_services=None):
gwr_fares = cached_gwr_fares section_min_connection = min_connection
else: section_max_connection = max_connection
try: if journey_type == "return" and direction == "inbound":
gwr_fares = gwr_fares_scraper.fetch(station_crs, travel_date) section_min_connection = INBOUND_MIN_CONNECTION_MINUTES
set_cached(gwr_fares_cache_key, gwr_fares) section_max_connection = INBOUND_MAX_CONNECTION_MINUTES
except Exception as e: rtt_direction = "to_paddington" if direction == "outbound" else "from_paddington"
gwr_fares = {} rtt_cache_key = f"rtt_{rtt_direction}_{station_crs}_{section_date}"
msg = f"Could not fetch GWR fares: {e}" gwr_cache_key = f"gwr_fares_{rtt_direction}_{station_crs}_{section_date}"
error = f"{error}; {msg}" if error else msg advance_cache_key = f"gwr_advance_{rtt_direction}_{station_crs}_{section_date}"
eurostar_trains = eurostar_services if direction == "outbound":
eurostar_prices = { trains = cached_fetch(
s["depart_st_pancras"]: { rtt_cache_key,
"price": s.get("price"), None,
"seats": s.get("seats"), lambda: rtt_scraper.fetch(section_date, user_agent, station_crs),
"plus_price": s.get("plus_price"), "GWR trains",
"plus_seats": s.get("plus_seats"), )
else:
trains = cached_fetch(
rtt_cache_key,
None,
lambda: rtt_scraper.fetch_from_paddington(section_date, user_agent, station_crs),
"GWR trains",
)
if eurostar_services is None:
es_cache_key = f"eurostar_{direction}_{section_date}_{destination}"
es_fetcher = (
(lambda: eurostar_scraper.fetch(destination, section_date))
if direction == "outbound"
else (lambda: eurostar_scraper.fetch(destination, section_date, direction=direction))
)
eurostar_services = cached_fetch(
es_cache_key,
24 * 3600,
es_fetcher,
"Eurostar times",
)
fare_direction = "to_paddington" if direction == "outbound" else "from_paddington"
gwr_fares = cached_fetch(
gwr_cache_key,
30 * 24 * 3600,
(
(lambda: gwr_fares_scraper.fetch(station_crs, section_date))
if fare_direction == "to_paddington"
else (lambda: gwr_fares_scraper.fetch(station_crs, section_date, direction=fare_direction))
),
"GWR fares",
)
cached_advance = get_cached(advance_cache_key, ttl=24 * 3600)
if direction == "outbound":
trips = combine_trips(
trains,
eurostar_services,
section_date,
section_min_connection,
section_max_connection,
gwr_fares,
)
unreachable = find_unreachable_morning_eurostars(
trains,
eurostar_services,
section_date,
section_min_connection,
section_max_connection,
)
if trips:
first_es_depart = min(t["depart_st_pancras"] for t in trips)
unreachable = [
s for s in unreachable if s["depart_st_pancras"] < first_es_depart
]
rows = sorted(
[{"row_type": "trip", "direction": direction, **trip} for trip in trips]
+ [{"row_type": "unreachable", "direction": direction, **svc} for svc in unreachable],
key=lambda row: row["depart_st_pancras"],
)
else:
trips = combine_inbound_trips(
eurostar_services,
trains,
section_date,
section_min_connection,
section_max_connection,
gwr_fares,
)
unreachable = find_unreachable_inbound_eurostars(
eurostar_services,
trains,
section_date,
section_min_connection,
section_max_connection,
)
if trips:
first_es_depart = min(t["depart_destination"] for t in trips)
unreachable = [
s for s in unreachable if s["depart_destination"] < first_es_depart
]
rows = sorted(
[{"row_type": "trip", "direction": direction, **trip} for trip in trips]
+ [{"row_type": "unreachable", "direction": direction, **svc} for svc in unreachable],
key=lambda row: row["depart_destination"],
)
es_by_key = {
(svc.get("depart_st_pancras") if direction == "outbound" else svc.get("depart_destination")): svc
for svc in eurostar_services
} }
for s in eurostar_services for row in rows:
} key = row.get("depart_st_pancras") if direction == "outbound" else row.get("depart_destination")
es = es_by_key.get(key, {})
row["eurostar_price"] = es.get("price")
row["eurostar_seats"] = es.get("seats")
row["eurostar_plus_price"] = es.get("plus_price")
row["eurostar_plus_seats"] = es.get("plus_seats")
row["row_key"] = f"{section_id}:{key}"
trips = combine_trips( dt = date.fromisoformat(section_date)
gwr_trains, return {
eurostar_trains, "id": section_id,
travel_date, "direction": direction,
min_connection, "date": section_date,
max_connection, "date_display": dt.strftime("%A %-d %B %Y"),
gwr_fares, "rows": rows,
) "trips": trips,
"gwr_count": len(trains),
"eurostar_count": len(eurostar_services),
"min_connection": section_min_connection,
"max_connection": section_max_connection,
"advance_fares": cached_advance,
"advance_api_url": url_for(
"api_advance_fares",
station_crs=station_crs,
travel_date=section_date,
direction=fare_direction,
),
"advance_stream_url": url_for(
"api_advance_fares_stream",
station_crs=station_crs,
travel_date=section_date,
direction=fare_direction,
),
}
# Annotate each trip with Eurostar prices and total cost (walk-on + standard) if journey_type == "return":
for trip in trips: sections = [
es = eurostar_prices.get(trip["depart_st_pancras"], {}) build_section("outbound", "outbound", travel_date, es_return.get("outbound", [])),
es_price = es.get("price") build_section("inbound", "inbound", return_date, es_return.get("inbound", [])),
trip["eurostar_price"] = es_price
trip["eurostar_seats"] = es.get("seats")
trip["eurostar_plus_price"] = es.get("plus_price")
trip["eurostar_plus_seats"] = es.get("plus_seats")
gwr_p = trip.get("ticket_price")
circle_svcs = trip.get("circle_services")
circle_fare = circle_svcs[0]["fare"] if circle_svcs else 0
trip["total_price"] = (
gwr_p + es_price + circle_fare
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
no_prices_note = None
if eurostar_prices and all(
v.get("price") is None for v in eurostar_prices.values()
):
no_prices_note = (
"Eurostar prices not yet available — tickets may not be on sale yet."
)
unreachable_morning_services = find_unreachable_morning_eurostars(
gwr_trains,
eurostar_trains,
travel_date,
min_connection,
max_connection,
)
for svc in unreachable_morning_services:
es = eurostar_prices.get(svc["depart_st_pancras"], {})
svc["eurostar_price"] = es.get("price")
svc["eurostar_seats"] = es.get("seats")
svc["eurostar_plus_price"] = es.get("plus_price")
svc["eurostar_plus_seats"] = es.get("plus_seats")
# Only keep unreachable services that depart before the first reachable Eurostar.
# 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
] ]
else:
sections = [build_section("main", journey_type, travel_date)]
result_rows = sorted( no_prices_note = None
[{"row_type": "trip", **trip} for trip in trips] all_es_prices = [
+ [ row.get("eurostar_price")
{"row_type": "unreachable", **service} for section in sections
for service in unreachable_morning_services for row in section["rows"]
], if row.get("row_type") == "trip"
key=lambda row: row["depart_st_pancras"], ]
) if all_es_prices and all(price is None for price in all_es_prices):
no_prices_note = "Eurostar prices not yet available — tickets may not be on sale yet."
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, direction=journey_type, return_date=return_date
)
rtt_url = RTT_PADDINGTON_URL.format(crs=station_crs, date=travel_date) rtt_url = RTT_PADDINGTON_URL.format(crs=station_crs, date=travel_date)
rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date) rtt_station_url = RTT_STATION_URL.format(crs=station_crs, date=travel_date)
@ -313,55 +414,62 @@ def results(station_crs, slug, travel_date):
url_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class url_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class
url_es = None if es_class == DEFAULT_ES_CLASS else es_class url_es = None if es_class == DEFAULT_ES_CLASS else es_class
# Build per-row fare data for JS consumption
trip_fares = {} trip_fares = {}
for row in result_rows: advance_fares = {}
stp = row.get("depart_st_pancras") advance_api_urls = {}
if not stp: advance_stream_urls = {}
continue for section in sections:
circle_svcs = row.get("circle_services") or [] advance_fares[section["id"]] = section["advance_fares"]
circle_fare = circle_svcs[0]["fare"] if circle_svcs else 0 advance_api_urls[section["id"]] = section["advance_api_url"]
walkon = ( advance_stream_urls[section["id"]] = section["advance_stream_url"]
{"price": row["ticket_price"], "ticket": row.get("ticket_name", "")} for row in section["rows"]:
if row.get("ticket_price") is not None circle_svcs = row.get("circle_services") or []
else None circle_fare = circle_svcs[0]["fare"] if circle_svcs else 0
) walkon = (
es_std = ( {"price": row["ticket_price"], "ticket": row.get("ticket_name", "")}
{"price": row["eurostar_price"], "seats": row.get("eurostar_seats")} if row.get("ticket_price") is not None
if row.get("eurostar_price") is not None else None
else None )
) es_std = (
es_plus = ( {"price": row["eurostar_price"], "seats": row.get("eurostar_seats")}
{"price": row["eurostar_plus_price"], "seats": row.get("eurostar_plus_seats")} if row.get("eurostar_price") is not None
if row.get("eurostar_plus_price") is not None else None
else None )
) es_plus = (
trip_fares[stp] = { {"price": row["eurostar_plus_price"], "seats": row.get("eurostar_plus_seats")}
"depart_bristol": row.get("depart_bristol"), if row.get("eurostar_plus_price") is not None
"walkon": walkon, else None
"es_standard": es_std, )
"es_plus": es_plus, trip_fares[row["row_key"]] = {
"circle_fare": circle_fare, "section": section["id"],
} "advance_key": row.get("depart_bristol") or row.get("depart_paddington"),
"walkon": walkon,
"es_standard": es_std,
"es_plus": es_plus,
"circle_fare": circle_fare,
}
return render_template( return render_template(
"results.html", "results.html",
trips=trips, sections=sections,
result_rows=result_rows, trips=sections[0]["trips"] if sections else [],
unreachable_morning_services=unreachable_morning_services, result_rows=sections[0]["rows"] if sections else [],
unreachable_morning_services=[],
destinations=DESTINATIONS, destinations=DESTINATIONS,
destination=destination, destination=destination,
travel_date=travel_date, travel_date=travel_date,
return_date=return_date,
journey_type=journey_type,
slug=slug, slug=slug,
station_crs=station_crs, station_crs=station_crs,
departure_station_name=departure_station_name, 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,
gwr_count=len(gwr_trains), gwr_count=sum(section["gwr_count"] for section in sections),
eurostar_count=len(eurostar_trains), eurostar_count=sum(section["eurostar_count"] for section in sections),
from_cache=from_cache, from_cache=bool(from_cache_parts),
error=error, error="; ".join(error_messages) if error_messages else None,
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,
@ -376,12 +484,15 @@ def results(station_crs, slug, travel_date):
es_class=es_class, es_class=es_class,
url_nr_class=url_nr, url_nr_class=url_nr,
url_es_class=url_es, url_es_class=url_es,
url_journey_type=None if journey_type == "outbound" else journey_type,
trip_fares_json=json.dumps(trip_fares), trip_fares_json=json.dumps(trip_fares),
advance_fares_json=json.dumps(cached_advance_fares), advance_fares_json=json.dumps(advance_fares),
advance_api_urls_json=json.dumps(advance_api_urls),
advance_stream_urls_json=json.dumps(advance_stream_urls),
advance_fares_api_url=url_for("api_advance_fares", station_crs=station_crs, travel_date=travel_date), advance_fares_api_url=url_for("api_advance_fares", station_crs=station_crs, travel_date=travel_date),
advance_fares_stream_url=url_for("api_advance_fares_stream", station_crs=station_crs, travel_date=travel_date), advance_fares_stream_url=url_for("api_advance_fares_stream", station_crs=station_crs, travel_date=travel_date),
valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_min_connections=sorted(valid_min),
valid_max_connections=sorted(VALID_MAX_CONNECTIONS), valid_max_connections=sorted(valid_max),
) )
@ -389,12 +500,19 @@ def results(station_crs, slug, travel_date):
def api_advance_fares(station_crs, travel_date): def api_advance_fares(station_crs, travel_date):
if station_crs not in STATION_BY_CRS: if station_crs not in STATION_BY_CRS:
abort(404) abort(404)
cache_key = f"gwr_advance_{station_crs}_{travel_date}" direction = request.args.get("direction", "to_paddington")
if direction not in {"to_paddington", "from_paddington"}:
direction = "to_paddington"
cache_key = f"gwr_advance_{direction}_{station_crs}_{travel_date}"
cached = get_cached(cache_key, ttl=24 * 3600) cached = get_cached(cache_key, ttl=24 * 3600)
if cached is not None: if cached is not None:
return jsonify(cached) return jsonify(cached)
try: try:
fares = gwr_fares_scraper.fetch_advance(station_crs, travel_date) fares = (
gwr_fares_scraper.fetch_advance(station_crs, travel_date)
if direction == "to_paddington"
else gwr_fares_scraper.fetch_advance(station_crs, travel_date, direction=direction)
)
set_cached(cache_key, fares) set_cached(cache_key, fares)
return jsonify(fares) return jsonify(fares)
except Exception as e: except Exception as e:
@ -405,7 +523,10 @@ def api_advance_fares(station_crs, travel_date):
def api_advance_fares_stream(station_crs, travel_date): def api_advance_fares_stream(station_crs, travel_date):
if station_crs not in STATION_BY_CRS: if station_crs not in STATION_BY_CRS:
abort(404) abort(404)
cache_key = f"gwr_advance_{station_crs}_{travel_date}" direction = request.args.get("direction", "to_paddington")
if direction not in {"to_paddington", "from_paddington"}:
direction = "to_paddington"
cache_key = f"gwr_advance_{direction}_{station_crs}_{travel_date}"
def generate(): def generate():
cached = get_cached(cache_key, ttl=24 * 3600) cached = get_cached(cache_key, ttl=24 * 3600)
@ -416,7 +537,14 @@ def api_advance_fares_stream(station_crs, travel_date):
accumulated: dict = {} accumulated: dict = {}
try: try:
for page_fares in gwr_fares_scraper.fetch_advance_streaming(station_crs, travel_date): stream = (
gwr_fares_scraper.fetch_advance_streaming(station_crs, travel_date)
if direction == "to_paddington"
else gwr_fares_scraper.fetch_advance_streaming(
station_crs, travel_date, direction=direction
)
)
for page_fares in stream:
for dep_time, fare_data in page_fares.items(): for dep_time, fare_data in page_fares.items():
if dep_time not in accumulated: if dep_time not in accumulated:
accumulated[dep_time] = {"advance_std": None, "advance_1st": None} accumulated[dep_time] = {"advance_std": None, "advance_1st": None}

View file

@ -1,5 +1,5 @@
""" """
Circle Line timetable: Paddington (H&C Line) King's Cross St Pancras. Circle Line timetable between Paddington (H&C Line) and King's Cross St Pancras.
Parses the TransXChange XML file on first use and caches the result in memory. Parses the TransXChange XML file on first use and caches the result in memory.
""" """
@ -14,9 +14,9 @@ _KXP_STOP = '9400ZZLUKSX3' # King's Cross St Pancras
from config.default import CIRCLE_LINE_XML as _TXC_XML # overridden by app config after import from config.default import CIRCLE_LINE_XML as _TXC_XML # overridden by app config after import
_NS = {'t': 'http://www.transxchange.org.uk/'} _NS = {'t': 'http://www.transxchange.org.uk/'}
# Populated on first call to next_service(); maps day-type -> sorted list of # Populated on first call to next_service(); maps direction -> day-type -> sorted
# (pad_depart_seconds, kxp_arrive_seconds) measured from midnight. # list of (origin_depart_seconds, destination_arrive_seconds) measured from midnight.
_timetable: dict[str, list[tuple[int, int]]] | None = None _timetable: dict[str, dict[str, list[tuple[int, int]]]] | None = None
def _parse_duration(s: str | None) -> int: def _parse_duration(s: str | None) -> int:
@ -26,7 +26,7 @@ def _parse_duration(s: str | None) -> int:
return int(m.group(1) or 0) * 3600 + int(m.group(2) or 0) * 60 + int(m.group(3) or 0) return int(m.group(1) or 0) * 3600 + int(m.group(2) or 0) * 60 + int(m.group(3) or 0)
def _load_timetable() -> dict[str, list[tuple[int, int]]]: def _load_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]:
tree = ET.parse(_TXC_XML) tree = ET.parse(_TXC_XML)
root = tree.getroot() root = tree.getroot()
@ -66,8 +66,8 @@ def _load_timetable() -> dict[str, list[tuple[int, int]]]:
return elapsed return elapsed
return None return None
# Map JP id -> (pad_offset_secs, kxp_arrive_offset_secs) # Map JP id -> [(direction, origin_depart_offset_secs, destination_arrive_offset_secs)].
jp_offsets: dict[str, tuple[int, int]] = {} jp_offsets: dict[str, list[tuple[str, int, int]]] = {}
for svc in root.find('t:Services', _NS): for svc in root.find('t:Services', _NS):
for jp in svc.findall('.//t:JourneyPattern', _NS): for jp in svc.findall('.//t:JourneyPattern', _NS):
jps_ref = jp.find('t:JourneyPatternSectionRefs', _NS) jps_ref = jp.find('t:JourneyPatternSectionRefs', _NS)
@ -75,6 +75,7 @@ def _load_timetable() -> dict[str, list[tuple[int, int]]]:
continue continue
links = jps_map.get(jps_ref.text, []) links = jps_map.get(jps_ref.text, [])
stops = [l[0] for l in links] + ([links[-1][1]] if links else []) stops = [l[0] for l in links] + ([links[-1][1]] if links else [])
offsets = []
if ( if (
_PAD_STOP in stops _PAD_STOP in stops
and _KXP_STOP in stops and _KXP_STOP in stops
@ -83,12 +84,30 @@ def _load_timetable() -> dict[str, list[tuple[int, int]]]:
pad_off = _seconds_to_depart(links, _PAD_STOP) pad_off = _seconds_to_depart(links, _PAD_STOP)
kxp_off = _seconds_to_arrive(links, _KXP_STOP) kxp_off = _seconds_to_arrive(links, _KXP_STOP)
if pad_off is not None and kxp_off is not None: if pad_off is not None and kxp_off is not None:
jp_offsets[jp.get('id')] = (pad_off, kxp_off) offsets.append(('pad_to_kx', pad_off, kxp_off))
if (
_PAD_STOP in stops
and _KXP_STOP in stops
and stops.index(_KXP_STOP) < stops.index(_PAD_STOP)
):
kxp_off = _seconds_to_depart(links, _KXP_STOP)
pad_off = _seconds_to_arrive(links, _PAD_STOP)
if kxp_off is not None and pad_off is not None:
offsets.append(('kx_to_pad', kxp_off, pad_off))
if offsets:
jp_offsets[jp.get('id')] = offsets
result: dict[str, list[tuple[int, int]]] = { result: dict[str, dict[str, list[tuple[int, int]]]] = {
'MondayToFriday': [], 'pad_to_kx': {
'Saturday': [], 'MondayToFriday': [],
'Sunday': [], 'Saturday': [],
'Sunday': [],
},
'kx_to_pad': {
'MondayToFriday': [],
'Saturday': [],
'Sunday': [],
},
} }
for vj in root.find('t:VehicleJourneys', _NS): for vj in root.find('t:VehicleJourneys', _NS):
@ -97,7 +116,6 @@ def _load_timetable() -> dict[str, list[tuple[int, int]]]:
op = vj.find('t:OperatingProfile', _NS) op = vj.find('t:OperatingProfile', _NS)
if jp_ref is None or dep_time is None or jp_ref.text not in jp_offsets: if jp_ref is None or dep_time is None or jp_ref.text not in jp_offsets:
continue continue
pad_off, kxp_off = jp_offsets[jp_ref.text]
h, m, s = map(int, dep_time.text.split(':')) h, m, s = map(int, dep_time.text.split(':'))
dep_secs = h * 3600 + m * 60 + s dep_secs = h * 3600 + m * 60 + s
rdt = op.find('.//t:DaysOfWeek', _NS) if op is not None else None rdt = op.find('.//t:DaysOfWeek', _NS) if op is not None else None
@ -105,15 +123,20 @@ def _load_timetable() -> dict[str, list[tuple[int, int]]]:
continue continue
for day_el in rdt: for day_el in rdt:
day_type = day_el.tag.split('}')[-1] day_type = day_el.tag.split('}')[-1]
if day_type in result: for direction, origin_off, dest_off in jp_offsets[jp_ref.text]:
result[day_type].append((dep_secs + pad_off, dep_secs + kxp_off)) if day_type in result[direction]:
result[direction][day_type].append((
dep_secs + origin_off,
dep_secs + dest_off,
))
for key in result: for direction in result:
result[key].sort() for key in result[direction]:
result[direction][key].sort()
return result return result
def _get_timetable() -> dict[str, list[tuple[int, int]]]: def _get_timetable() -> dict[str, dict[str, list[tuple[int, int]]]]:
global _timetable global _timetable
if _timetable is None: if _timetable is None:
_timetable = _load_timetable() _timetable = _load_timetable()
@ -126,7 +149,9 @@ def _day_type(weekday: int) -> str:
return 'Saturday' if weekday == 5 else 'Sunday' return 'Saturday' if weekday == 5 else 'Sunday'
def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None: def next_service(
earliest_board: datetime, direction: str = 'pad_to_kx'
) -> tuple[datetime, datetime] | None:
""" """
Given the earliest time a passenger can board at Paddington (H&C Line), Given the earliest time a passenger can board at Paddington (H&C Line),
return (circle_line_depart, arrive_kings_cross) as datetimes, or None if return (circle_line_depart, arrive_kings_cross) as datetimes, or None if
@ -135,20 +160,20 @@ 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) services = upcoming_services(earliest_board, count=1, direction=direction)
return services[0] if services else None return services[0] if services else None
def upcoming_services( def upcoming_services(
earliest_board: datetime, count: int = 2 earliest_board: datetime, count: int = 2, direction: str = 'pad_to_kx'
) -> list[tuple[datetime, datetime]]: ) -> list[tuple[datetime, datetime]]:
""" """
Return up to *count* Circle line services from Paddington (H&C Line) to Return up to *count* Circle line services for *direction*, starting from
King's Cross St Pancras, starting from *earliest_board*. *earliest_board*.
Each element is (depart_paddington, arrive_kings_cross) as datetimes. Each element is (depart_origin, arrive_destination) as datetimes.
""" """
timetable = _get_timetable()[_day_type(earliest_board.weekday())] timetable = _get_timetable().get(direction, {})[_day_type(earliest_board.weekday())]
board_secs = ( board_secs = (
earliest_board.hour * 3600 earliest_board.hour * 3600
+ earliest_board.minute * 60 + earliest_board.minute * 60

View file

@ -16,7 +16,8 @@ DEFAULT_UA = (
"(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
) )
ORIGIN_STATION_ID = '7015400' ST_PANCRAS_STATION_ID = '7015400'
ORIGIN_STATION_ID = ST_PANCRAS_STATION_ID
DESTINATION_STATION_IDS = { DESTINATION_STATION_IDS = {
'Paris Gare du Nord': '8727100', 'Paris Gare du Nord': '8727100',
@ -35,11 +36,11 @@ _GATEWAY_URL = 'https://site-api.eurostar.com/gateway'
_GQL_QUERY = ( _GQL_QUERY = (
"query NewBookingSearch(" "query NewBookingSearch("
"$origin:String!,$destination:String!,$outbound:String!," "$origin:String!,$destination:String!,$outbound:String!,"
"$currency:Currency!,$adult:Int," "$inbound:String,$currency:Currency!,$adult:Int,"
"$filteredClassesOfService:[ClassOfServiceEnum]" "$filteredClassesOfService:[ClassOfServiceEnum]"
"){" "){"
"journeySearch(" "journeySearch("
"outboundDate:$outbound origin:$origin destination:$destination" "outboundDate:$outbound inboundDate:$inbound origin:$origin destination:$destination"
" adults:$adult currency:$currency" " adults:$adult currency:$currency"
" productFamilies:[\"PUB\"] contractCode:\"EIL_ALL\"" " productFamilies:[\"PUB\"] contractCode:\"EIL_ALL\""
" adults16Plus:0 children:0 youths:0 children4Only:0 children5To11:0" " adults16Plus:0 children:0 youths:0 children4Only:0 children5To11:0"
@ -64,6 +65,22 @@ _GQL_QUERY = (
"}" "}"
"}" "}"
"}" "}"
"inbound{"
"journeys("
"hideIndirectTrainsWhenDisruptedAndCancelled:false"
" hideDepartedTrains:true"
" hideExternalCarrierTrains:true"
" hideDirectExternalCarrierTrains:true"
"){"
"timing{departureTime:departs arrivalTime:arrives}"
"fares(filteredClassesOfService:$filteredClassesOfService){"
"classOfService{code}"
"prices{displayPrice}"
"seats "
"legs{serviceName serviceType{code}}"
"}"
"}"
"}"
"}" "}"
"}" "}"
) )
@ -72,11 +89,19 @@ _STANDARD = 'STANDARD'
_STANDARD_PLUS = 'PLUS' _STANDARD_PLUS = 'PLUS'
def search_url(destination: str, travel_date: str) -> str: def search_url(destination: str, travel_date: str, direction: str = "outbound", return_date: str | None = None) -> str:
dest_id = DESTINATION_STATION_IDS[destination] dest_id = DESTINATION_STATION_IDS[destination]
origin = ST_PANCRAS_STATION_ID
destination_id = dest_id
outbound = travel_date
inbound = return_date
if direction == "inbound":
origin, destination_id = dest_id, ST_PANCRAS_STATION_ID
inbound = None
return ( return (
f'https://www.eurostar.com/search/uk-en' f'https://www.eurostar.com/search/uk-en'
f'?adult=1&origin={ORIGIN_STATION_ID}&destination={dest_id}&outbound={travel_date}' f'?adult=1&origin={origin}&destination={destination_id}&outbound={outbound}'
+ (f'&inbound={inbound}' if inbound else '')
) )
@ -85,7 +110,7 @@ def _generate_cid() -> str:
return 'SRCH-' + ''.join(random.choices(chars, k=22)) return 'SRCH-' + ''.join(random.choices(chars, k=22))
def _parse_graphql(data: dict, destination: str) -> list[dict]: def _parse_journeys(journeys: list[dict], destination: str, direction: str) -> list[dict]:
""" """
Parse a NewBookingSearch GraphQL response into a list of service dicts. Parse a NewBookingSearch GraphQL response into a list of service dicts.
@ -97,7 +122,6 @@ def _parse_graphql(data: dict, destination: str) -> list[dict]:
Multi-leg train numbers are joined with ' + ' (e.g. 'ES 9116 + ER 9329'). Multi-leg train numbers are joined with ' + ' (e.g. 'ES 9116 + ER 9329').
""" """
best: dict[str, dict] = {} best: dict[str, dict] = {}
journeys = data['data']['journeySearch']['outbound']['journeys']
for journey in journeys: for journey in journeys:
dep = journey['timing']['departureTime'] dep = journey['timing']['departureTime']
arr = journey['timing']['arrivalTime'] arr = journey['timing']['arrivalTime']
@ -118,8 +142,21 @@ def _parse_graphql(data: dict, destination: str) -> list[dict]:
std_price, std_seats = price, seats std_price, std_seats = price, seats
elif cos == _STANDARD_PLUS: elif cos == _STANDARD_PLUS:
plus_price, plus_seats = price, seats plus_price, plus_seats = price, seats
if dep not in best or arr < best[dep]['arrive_destination']: if direction == 'inbound':
best[dep] = { service = {
'depart_destination': dep,
'arrive_st_pancras': arr,
'destination': destination,
'train_number': train_number,
'price': std_price,
'seats': std_seats,
'plus_price': plus_price,
'plus_seats': plus_seats,
}
key = dep
arrive_key = 'arrive_st_pancras'
else:
service = {
'depart_st_pancras': dep, 'depart_st_pancras': dep,
'arrive_destination': arr, 'arrive_destination': arr,
'destination': destination, 'destination': destination,
@ -129,18 +166,43 @@ def _parse_graphql(data: dict, destination: str) -> list[dict]:
'plus_price': plus_price, 'plus_price': plus_price,
'plus_seats': plus_seats, 'plus_seats': plus_seats,
} }
return sorted(best.values(), key=lambda s: s['depart_st_pancras']) key = dep
arrive_key = 'arrive_destination'
if key not in best or arr < best[key][arrive_key]:
best[key] = service
sort_key = 'depart_destination' if direction == 'inbound' else 'depart_st_pancras'
return sorted(best.values(), key=lambda s: s[sort_key])
def fetch(destination: str, travel_date: str) -> list[dict]: def _parse_graphql(data: dict, destination: str) -> list[dict]:
""" journeys = data['data']['journeySearch']['outbound']['journeys']
Return all Eurostar services for destination on travel_date. return _parse_journeys(journeys, destination, 'outbound')
Each dict contains timetable info (depart_st_pancras, arrive_destination,
train_number) plus pricing (price, seats) from a single GraphQL call. def _parse_graphql_leg(data: dict, destination: str, leg: str, direction: str) -> list[dict]:
""" journeys = data['data']['journeySearch'][leg]['journeys']
dest_id = DESTINATION_STATION_IDS[destination] return _parse_journeys(journeys, destination, direction)
headers = {
def _payload(origin: str, destination_id: str, outbound: str, inbound: str | None = None) -> dict:
variables = {
'origin': origin,
'destination': destination_id,
'outbound': outbound,
'inbound': inbound,
'currency': 'GBP',
'adult': 1,
'filteredClassesOfService': [_STANDARD, _STANDARD_PLUS],
}
return {
'operationName': 'NewBookingSearch',
'variables': variables,
'query': _GQL_QUERY,
}
def _headers() -> dict:
return {
'User-Agent': DEFAULT_UA, 'User-Agent': DEFAULT_UA,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': '*/*', 'Accept': '*/*',
@ -151,18 +213,42 @@ def fetch(destination: str, travel_date: str) -> list[dict]:
'x-source-url': 'search-app/', 'x-source-url': 'search-app/',
'cid': _generate_cid(), 'cid': _generate_cid(),
} }
payload = {
'operationName': 'NewBookingSearch',
'variables': { def fetch(destination: str, travel_date: str, direction: str = 'outbound') -> list[dict]:
'origin': ORIGIN_STATION_ID, """
'destination': dest_id, Return all Eurostar services for destination on travel_date.
'outbound': travel_date,
'currency': 'GBP', Each dict contains timetable info (depart_st_pancras, arrive_destination,
'adult': 1, train_number) plus pricing (price, seats) from a single GraphQL call.
'filteredClassesOfService': [_STANDARD, _STANDARD_PLUS], """
}, dest_id = DESTINATION_STATION_IDS[destination]
'query': _GQL_QUERY, if direction == 'inbound':
} origin, destination_id = dest_id, ST_PANCRAS_STATION_ID
resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20) else:
origin, destination_id = ST_PANCRAS_STATION_ID, dest_id
resp = requests.post(
_GATEWAY_URL,
json=_payload(origin, destination_id, travel_date),
headers=_headers(),
timeout=20,
)
resp.raise_for_status() resp.raise_for_status()
return _parse_graphql(resp.json(), destination) leg_direction = 'inbound' if direction == 'inbound' else 'outbound'
return _parse_graphql_leg(resp.json(), destination, 'outbound', leg_direction)
def fetch_return(destination: str, outbound_date: str, return_date: str) -> dict[str, list[dict]]:
dest_id = DESTINATION_STATION_IDS[destination]
resp = requests.post(
_GATEWAY_URL,
json=_payload(ST_PANCRAS_STATION_ID, dest_id, outbound_date, return_date),
headers=_headers(),
timeout=20,
)
resp.raise_for_status()
data = resp.json()
return {
'outbound': _parse_graphql_leg(data, destination, 'outbound', 'outbound'),
'inbound': _parse_graphql_leg(data, destination, 'inbound', 'inbound'),
}

View file

@ -32,7 +32,8 @@ def _headers() -> dict:
def _request_body( def _request_body(
station_crs: str, from_code: str,
to_code: str,
travel_date: str, travel_date: str,
conversation_token: str | None, conversation_token: str | None,
later: bool, later: bool,
@ -44,8 +45,8 @@ def _request_body(
"IsPreviousReturn": False, "IsPreviousReturn": False,
"campaignCode": "", "campaignCode": "",
"validationCode": "", "validationCode": "",
"locfrom": f"GB{station_crs}", "locfrom": from_code,
"locto": _PAD_CODE, "locto": to_code,
"datetimedepart": f"{travel_date}T00:00:00", "datetimedepart": f"{travel_date}T00:00:00",
"outwarddepartafter": True, "outwarddepartafter": True,
"datetimereturn": None, "datetimereturn": None,
@ -67,7 +68,22 @@ def _request_body(
} }
def _run_pages(station_crs: str, travel_date: str, first_class: bool = False): def _station_code(station_crs: str) -> str:
return f"GB{station_crs}"
def _od_codes(station_crs: str, direction: str) -> tuple[str, str]:
if direction == "from_paddington":
return _PAD_CODE, _station_code(station_crs)
return _station_code(station_crs), _PAD_CODE
def _run_pages(
station_crs: str,
travel_date: str,
first_class: bool = False,
direction: str = "to_paddington",
):
""" """
Iterate all pages of GWR journey search results. Iterate all pages of GWR journey search results.
@ -78,8 +94,9 @@ def _run_pages(station_crs: str, travel_date: str, first_class: bool = False):
with httpx.Client(headers=_headers(), timeout=30) as client: with httpx.Client(headers=_headers(), timeout=30) as client:
conversation_token = None conversation_token = None
later = False later = False
from_code, to_code = _od_codes(station_crs, direction)
for _ in range(_MAX_PAGES): for _ in range(_MAX_PAGES):
body = _request_body(station_crs, travel_date, conversation_token, later) body = _request_body(from_code, to_code, travel_date, conversation_token, later)
if first_class: if first_class:
body["firstclass"] = True body["firstclass"] = True
body["standardclass"] = False body["standardclass"] = False
@ -99,7 +116,12 @@ def _run_pages(station_crs: str, travel_date: str, first_class: bool = False):
later = True later = True
def _run_pages_batched(station_crs: str, travel_date: str, first_class: bool = False): def _run_pages_batched(
station_crs: str,
travel_date: str,
first_class: bool = False,
direction: str = "to_paddington",
):
""" """
Like _run_pages but yields one list of (dep_time, fares_list) per API page call, Like _run_pages but yields one list of (dep_time, fares_list) per API page call,
allowing callers to stream results a page at a time. allowing callers to stream results a page at a time.
@ -108,8 +130,9 @@ def _run_pages_batched(station_crs: str, travel_date: str, first_class: bool = F
with httpx.Client(headers=_headers(), timeout=30) as client: with httpx.Client(headers=_headers(), timeout=30) as client:
conversation_token = None conversation_token = None
later = False later = False
from_code, to_code = _od_codes(station_crs, direction)
for _ in range(_MAX_PAGES): for _ in range(_MAX_PAGES):
body = _request_body(station_crs, travel_date, conversation_token, later) body = _request_body(from_code, to_code, travel_date, conversation_token, later)
if first_class: if first_class:
body["firstclass"] = True body["firstclass"] = True
body["standardclass"] = False body["standardclass"] = False
@ -132,16 +155,18 @@ def _run_pages_batched(station_crs: str, travel_date: str, first_class: bool = F
later = True later = True
def fetch(station_crs: str, travel_date: str) -> dict[str, dict]: def fetch(
station_crs: str, travel_date: str, direction: str = "to_paddington"
) -> dict[str, dict]:
""" """
Fetch GWR walk-on single fares from station_crs to London Paddington on travel_date. Fetch GWR walk-on single fares for the selected Paddington direction.
Returns {departure_time: {'ticket': name, 'price': float, 'code': code}} Returns {departure_time: {'ticket': name, 'price': float, 'code': code}}
where price is in £ and only the cheapest available standard-class walk-on where price is in £ and only the cheapest available standard-class walk-on
ticket per departure (with restrictions already applied by GWR) is kept. ticket per departure (with restrictions already applied by GWR) is kept.
""" """
result: dict[str, dict] = {} result: dict[str, dict] = {}
for dep_time, fares in _run_pages(station_crs, travel_date): for dep_time, fares in _run_pages(station_crs, travel_date, direction=direction):
cheapest = None cheapest = None
for fare in fares: for fare in fares:
code = fare.get("ticketTypeCode") code = fare.get("ticketTypeCode")
@ -166,7 +191,9 @@ def fetch(station_crs: str, travel_date: str) -> dict[str, dict]:
return result return result
def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]: def fetch_advance(
station_crs: str, travel_date: str, direction: str = "to_paddington"
) -> dict[str, dict]:
""" """
Fetch advance fares: cheapest standard advance and first-class advance per departure. Fetch advance fares: cheapest standard advance and first-class advance per departure.
@ -175,7 +202,9 @@ def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]:
where each sub-dict has keys 'ticket', 'price', 'code'. where each sub-dict has keys 'ticket', 'price', 'code'.
""" """
std_advance: dict[str, dict] = {} std_advance: dict[str, dict] = {}
for dep_time, fares in _run_pages(station_crs, travel_date, first_class=False): for dep_time, fares in _run_pages(
station_crs, travel_date, first_class=False, direction=direction
):
cheapest = None cheapest = None
for fare in fares: for fare in fares:
code = fare.get("ticketTypeCode") code = fare.get("ticketTypeCode")
@ -199,7 +228,9 @@ def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]:
} }
first_advance: dict[str, dict] = {} first_advance: dict[str, dict] = {}
for dep_time, fares in _run_pages(station_crs, travel_date, first_class=True): for dep_time, fares in _run_pages(
station_crs, travel_date, first_class=True, direction=direction
):
cheapest = None cheapest = None
for fare in fares: for fare in fares:
price_pence = fare.get("fare", 0) price_pence = fare.get("fare", 0)
@ -227,7 +258,9 @@ def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]:
} }
def fetch_advance_streaming(station_crs: str, travel_date: str): def fetch_advance_streaming(
station_crs: str, travel_date: str, direction: str = "to_paddington"
):
""" """
Generator yielding partial advance fare dicts one GWR API page at a time. Generator yielding partial advance fare dicts one GWR API page at a time.
@ -236,7 +269,9 @@ def fetch_advance_streaming(station_crs: str, travel_date: str):
yielded immediately so callers can stream prices to clients as they arrive. yielded immediately so callers can stream prices to clients as they arrive.
""" """
# Pass 1: standard class advance fares # Pass 1: standard class advance fares
for batch in _run_pages_batched(station_crs, travel_date, first_class=False): for batch in _run_pages_batched(
station_crs, travel_date, first_class=False, direction=direction
):
page: dict[str, dict] = {} page: dict[str, dict] = {}
for dep_time, fares in batch: for dep_time, fares in batch:
cheapest = None cheapest = None
@ -267,7 +302,9 @@ def fetch_advance_streaming(station_crs: str, travel_date: str):
yield page yield page
# Pass 2: first class advance fares # Pass 2: first class advance fares
for batch in _run_pages_batched(station_crs, travel_date, first_class=True): for batch in _run_pages_batched(
station_crs, travel_date, first_class=True, direction=direction
):
page = {} page = {}
for dep_time, fares in batch: for dep_time, fares in batch:
cheapest = None cheapest = None

View file

@ -1,5 +1,6 @@
""" """
Scrape GWR trains from Bristol Temple Meads to London Paddington using Realtime Trains. Scrape direct trains between a selected station and London Paddington using
Realtime Trains.
Two fetches: Two fetches:
BRI/to/PAD departure times from Bristol (div.time.plan.d) BRI/to/PAD departure times from Bristol (div.time.plan.d)
@ -20,6 +21,16 @@ _PAD_FROM_TMPL = (
"gb-nr:PAD/from/gb-nr:{crs}/{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"
) )
_PAD_TO_TMPL = (
"https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:PAD/to/gb-nr:{crs}/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt"
)
_FROM_PAD_TMPL = (
"https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:{crs}/from/gb-nr:PAD/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt"
)
DEFAULT_UA = ( DEFAULT_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
@ -69,7 +80,7 @@ def _parse_services(html: str, time_selector: str) -> dict[str, str]:
def _parse_arrivals(html: str) -> dict[str, dict]: def _parse_arrivals(html: str) -> dict[str, dict]:
"""Return {train_id: {'time': ..., 'platform': ...}} from a PAD arrivals page.""" """Return {train_id: {'time': ..., 'platform': ...}} from an arrivals page."""
root = lxml.html.fromstring(html) root = lxml.html.fromstring(html)
sl = root.cssselect('div.servicelist') sl = root.cssselect('div.servicelist')
if not sl: if not sl:
@ -93,7 +104,7 @@ def _parse_arrivals(html: str) -> dict[str, dict]:
def fetch(date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI') -> list[dict]: 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'}].""" """Fetch trains from station_crs to PAD."""
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(_TO_PAD_TMPL.format(crs=station_crs, date=date)) r_bri = client.get(_TO_PAD_TMPL.format(crs=station_crs, date=date))
@ -113,3 +124,44 @@ def fetch(date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI') ->
if tid in arrivals if tid in arrivals
] ]
return sorted(trains, key=lambda t: t['depart_bristol']) return sorted(trains, key=lambda t: t['depart_bristol'])
def fetch_to_paddington(
date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI'
) -> list[dict]:
"""Fetch trains from station_crs to PAD using generic field names."""
return [
{
**train,
"depart_origin": train["depart_bristol"],
"arrive_paddington": train["arrive_paddington"],
"arrive_platform": train.get("arrive_platform", ""),
"headcode": train.get("headcode", ""),
}
for train in fetch(date, user_agent, station_crs)
]
def fetch_from_paddington(
date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI'
) -> list[dict]:
"""Fetch trains from PAD to station_crs."""
headers = _browser_headers(user_agent)
with httpx.Client(headers=headers, follow_redirects=True, timeout=30) as client:
r_pad = client.get(_PAD_TO_TMPL.format(crs=station_crs, date=date))
r_station = client.get(_FROM_PAD_TMPL.format(crs=station_crs, date=date))
departures = _parse_services(r_pad.text, 'div.time.plan.d')
arrivals = _parse_arrivals(r_station.text)
trains = [
{
"depart_paddington": dep,
"arrive_destination": arrivals[tid]["time"],
"arrive_platform": arrivals[tid]["platform"],
"headcode": tid,
}
for tid, dep in departures.items()
if tid in arrivals
]
return sorted(trains, key=lambda t: t["depart_paddington"])

View file

@ -2,7 +2,7 @@
{% block content %} {% block content %}
<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') }}" id="search-form">
<div class="form-group-lg"> <div class="form-group-lg">
<label for="station_crs" class="field-label">Departure point</label> <label for="station_crs" class="field-label">Departure point</label>
<select id="station_crs" name="station_crs" class="form-control"> <select id="station_crs" name="station_crs" class="form-control">
@ -12,6 +12,33 @@
</select> </select>
</div> </div>
<div class="form-group">
<span class="field-label">Journey type</span>
<div class="destination-grid" role="radiogroup" aria-label="Journey type">
<div class="destination-option">
<input type='radio' id="journey-outbound" name="journey_type" value="outbound" checked>
<label for="journey-outbound">
<strong>Out</strong>
<span>UK to Europe</span>
</label>
</div>
<div class="destination-option">
<input type='radio' id="journey-inbound" name="journey_type" value="inbound">
<label for="journey-inbound">
<strong>Back</strong>
<span>Europe to UK</span>
</label>
</div>
<div class="destination-option">
<input type='radio' id="journey-return" name="journey_type" value="return">
<label for="journey-return">
<strong>Return</strong>
<span>Out and back</span>
</label>
</div>
</div>
</div>
<div class="form-group"> <div class="form-group">
<span class="field-label">Eurostar destination</span> <span class="field-label">Eurostar destination</span>
<div class="destination-grid" role="radiogroup" aria-label="Eurostar destination"> <div class="destination-grid" role="radiogroup" aria-label="Eurostar destination">
@ -36,13 +63,22 @@
<div class="form-group-lg"> <div class="form-group-lg">
<label for="travel_date" class="field-label"> <label for="travel_date" class="field-label">
Travel date Outbound / single date
</label> </label>
<input type="date" id="travel_date" name="travel_date" required <input type="date" id="travel_date" name="travel_date" required
min="{{ today }}" value="{{ today }}" min="{{ today }}" value="{{ today }}"
class="form-control"> class="form-control">
</div> </div>
<div class="form-group-lg">
<label for="return_date" class="field-label">
Return date
</label>
<input type="date" id="return_date" name="return_date"
min="{{ today }}" value="{{ default_return_date }}"
class="form-control">
</div>
<div class="form-group"> <div class="form-group">
<label for="min_connection" class="field-label"> <label for="min_connection" class="field-label">
Minimum connection time (Paddington &rarr; St&nbsp;Pancras) Minimum connection time (Paddington &rarr; St&nbsp;Pancras)
@ -69,5 +105,37 @@
Search journeys Search journeys
</button> </button>
</form> </form>
<script>
(function() {
var form = document.getElementById('search-form');
var returnDate = document.getElementById('return_date');
var returnRadio = document.getElementById('journey-return');
var journeyRadios = document.querySelectorAll('input[name="journey_type"]');
var returnDateName = returnDate.name;
function currentJourneyType() {
var checked = document.querySelector('input[name="journey_type"]:checked');
return checked ? checked.value : 'outbound';
}
function syncReturnDate() {
returnDate.name = currentJourneyType() === 'return' ? returnDateName : '';
}
returnDate.addEventListener('focus', function() {
returnRadio.checked = true;
syncReturnDate();
});
returnDate.addEventListener('change', function() {
returnRadio.checked = true;
syncReturnDate();
});
journeyRadios.forEach(function(radio) {
radio.addEventListener('change', syncReturnDate);
});
form.addEventListener('submit', syncReturnDate);
syncReturnDate();
})();
</script>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %} {% block title %}{% if journey_type == 'inbound' %}{{ destination }} to {{ departure_station_name }} via Eurostar{% elif journey_type == 'return' %}{{ departure_station_name }} to {{ destination }} return via Eurostar{% else %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endif %}{% endblock %}
{% block og_title %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %} {% block og_title %}{{ self.title()|trim }}{% endblock %}
{% block og_description %}Train options from {{ departure_station_name }} 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 %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %} {% block twitter_title %}{{ self.title()|trim }}{% endblock %}
{% block twitter_description %}Train options from {{ departure_station_name }} 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 %}
@ -12,13 +12,19 @@
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<h2> <h2>
{{ departure_station_name }} &rarr; {{ destination }} {% if journey_type == 'inbound' %}
{{ destination }} &rarr; {{ departure_station_name }}
{% elif journey_type == 'return' %}
{{ departure_station_name }} &harr; {{ destination }}
{% else %}
{{ departure_station_name }} &rarr; {{ destination }}
{% endif %}
</h2> </h2>
<div class="date-nav"> <div class="date-nav">
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}" <a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=prev_date, journey_type=url_journey_type, return_date=return_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
class="btn-nav">&larr; Prev</a> class="btn-nav">&larr; Prev</a>
<strong>{{ travel_date_display }}</strong> <strong>{{ travel_date_display }}{% if return_date %} to {{ return_date }}{% endif %}</strong>
<a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}" <a href="{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=next_date, journey_type=url_journey_type, return_date=return_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
class="btn-nav">Next &rarr;</a> class="btn-nav">Next &rarr;</a>
</div> </div>
<div class="switcher-section"> <div class="switcher-section">
@ -30,7 +36,7 @@
{% else %} {% else %}
<a <a
class="chip-link" class="chip-link"
href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}" href="{{ url_for('results', station_crs=station_crs, slug=destination_slug, travel_date=travel_date, journey_type=url_journey_type, return_date=return_date, min_connection=url_min_connection, max_connection=url_max_connection, nr_class=url_nr_class, es_class=url_es_class) }}"
>{{ destination_name }}</a> >{{ destination_name }}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -57,36 +63,44 @@
<div class="filter-row" style="margin-top:0.5rem"> <div class="filter-row" style="margin-top:0.5rem">
<div> <div>
<span class="filter-label">NR ticket:</span> <span class="filter-label">NR ticket:</span>
<span id="nr-type-select" style="display:none"></span>
<span style="display:none">Load advance prices</span>
<div class="btn-group"> <div class="btn-group">
<button class="btn-group-option {% if nr_class == 'walkon' %}active{% endif %}" onclick="setNrClass('walkon')">Walk-on Std</button> <button type="button" class="btn-group-option {% if nr_class == 'walkon' %}active{% endif %}" onclick="setNrClass('walkon')">Walk-on Std</button>
<button class="btn-group-option {% if nr_class == 'advance_std' %}active{% endif %}" onclick="setNrClass('advance_std')">Advance Std</button> <button type="button" class="btn-group-option {% if nr_class == 'advance_std' %}active{% endif %}" onclick="setNrClass('advance_std')">Advance Std</button>
<button class="btn-group-option {% if nr_class == 'advance_1st' %}active{% endif %}" onclick="setNrClass('advance_1st')">Advance 1st</button> <button type="button" class="btn-group-option {% if nr_class == 'advance_1st' %}active{% endif %}" onclick="setNrClass('advance_1st')">Advance 1st</button>
</div> </div>
<span id="advance-loading" style="display:none">Loading&#8230;</span>
</div> </div>
<div> <div>
<span class="filter-label">Eurostar:</span> <span class="filter-label">Eurostar:</span>
<span id="es-type-select" style="display:none"></span>
<div class="btn-group"> <div class="btn-group">
<button class="btn-group-option {% if es_class == 'standard' %}active{% endif %}" onclick="setEsClass('standard')">Standard</button> <button type="button" class="btn-group-option {% if es_class == 'standard' %}active{% endif %}" onclick="setEsClass('standard')">Standard</button>
<button class="btn-group-option {% if es_class == 'plus' %}active{% endif %}" onclick="setEsClass('plus')">Standard Premier</button> <button type="button" class="btn-group-option {% if es_class == 'plus' %}active{% endif %}" onclick="setEsClass('plus')">Standard Premier</button>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
const RESULTS_BASE = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}'; const RESULTS_BASE = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}';
const JOURNEY_TYPE = '{{ journey_type }}';
const RETURN_DATE = '{{ return_date or '' }}';
const DEFAULT_MIN_CONN = {{ default_min_connection }}; const DEFAULT_MIN_CONN = {{ default_min_connection }};
const DEFAULT_MAX_CONN = {{ default_max_connection }}; const DEFAULT_MAX_CONN = {{ default_max_connection }};
const ADVANCE_FARES_STREAM_URL = '{{ advance_fares_stream_url }}';
let TRIP_FARES = {{ trip_fares_json | safe }}; let TRIP_FARES = {{ trip_fares_json | safe }};
let ADVANCE_FARES = {{ advance_fares_json | safe }}; let ADVANCE_FARES = {{ advance_fares_json | safe }};
let ADVANCE_API_URLS = {{ advance_api_urls_json | safe }};
let ADVANCE_STREAM_URLS = {{ advance_stream_urls_json | safe }};
let cachedAdvanceFares = ADVANCE_FARES;
let currentNrClass = '{{ nr_class }}'; let currentNrClass = '{{ nr_class }}';
let currentEsClass = '{{ es_class }}'; let currentEsClass = '{{ es_class }}';
let advanceLoading = false; let advanceLoadingSections = {};
function buildUrl(nrCls, esCls) { function buildUrl(nrCls, esCls) {
var min = parseInt(document.getElementById('min_conn_select').value); var min = parseInt(document.getElementById('min_conn_select').value);
var max = parseInt(document.getElementById('max_conn_select').value); var max = parseInt(document.getElementById('max_conn_select').value);
var params = []; var params = [];
if (JOURNEY_TYPE !== 'outbound') params.push('journey_type=' + encodeURIComponent(JOURNEY_TYPE));
if (RETURN_DATE) params.push('return_date=' + encodeURIComponent(RETURN_DATE));
if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min); if (min !== DEFAULT_MIN_CONN) params.push('min_connection=' + min);
if (max !== DEFAULT_MAX_CONN) params.push('max_connection=' + max); if (max !== DEFAULT_MAX_CONN) params.push('max_connection=' + max);
if (nrCls !== 'walkon') params.push('nr_class=' + nrCls); if (nrCls !== 'walkon') params.push('nr_class=' + nrCls);
@ -104,11 +118,8 @@
btn.classList.toggle('active', btn.getAttribute('onclick') === "setNrClass('" + cls + "')"); btn.classList.toggle('active', btn.getAttribute('onclick') === "setNrClass('" + cls + "')");
}); });
history.replaceState(null, '', buildUrl(cls, currentEsClass)); history.replaceState(null, '', buildUrl(cls, currentEsClass));
if ((cls === 'advance_std' || cls === 'advance_1st') && ADVANCE_FARES === null) { if (cls === 'advance_std' || cls === 'advance_1st') loadMissingAdvanceFares();
loadAdvanceFares(); updateDisplay();
} else {
updateDisplay();
}
} }
function setEsClass(cls) { function setEsClass(cls) {
@ -120,41 +131,6 @@
updateDisplay(); updateDisplay();
} }
function loadAdvanceFares() {
advanceLoading = true;
if (ADVANCE_FARES === null) ADVANCE_FARES = {};
document.getElementById('advance-loading').style.display = 'inline';
var source = new EventSource(ADVANCE_FARES_STREAM_URL);
source.onmessage = function(event) {
var msg = JSON.parse(event.data);
if (msg.type === 'fares') {
for (var time in msg.fares) {
if (!ADVANCE_FARES[time]) ADVANCE_FARES[time] = {advance_std: null, advance_1st: null};
if (msg.fares[time].advance_std) ADVANCE_FARES[time].advance_std = msg.fares[time].advance_std;
if (msg.fares[time].advance_1st) ADVANCE_FARES[time].advance_1st = msg.fares[time].advance_1st;
}
updateDisplay();
} else if (msg.type === 'done') {
advanceLoading = false;
document.getElementById('advance-loading').style.display = 'none';
source.close();
updateDisplay();
} else if (msg.type === 'error') {
advanceLoading = false;
document.getElementById('advance-loading').textContent = 'Failed to load advance fares.';
source.close();
}
};
source.onerror = function() {
advanceLoading = false;
document.getElementById('advance-loading').style.display = 'none';
source.close();
};
}
function fmtPrice(p) { function fmtPrice(p) {
return '\u00a3' + p.toFixed(2); return '\u00a3' + p.toFixed(2);
} }
@ -165,52 +141,129 @@
+ (fare.seats != null ? ' <span class="text-xs text-muted">' + fare.seats + ' at this price</span>' : ''); + (fare.seats != null ? ' <span class="text-xs text-muted">' + fare.seats + ' at this price</span>' : '');
} }
function mergeAdvanceFares(sectionId, fares) {
if (!ADVANCE_FARES) ADVANCE_FARES = {};
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
for (var time in fares) {
if (!ADVANCE_FARES[sectionId][time]) {
ADVANCE_FARES[sectionId][time] = {advance_std: null, advance_1st: null};
}
if (fares[time].advance_std) ADVANCE_FARES[sectionId][time].advance_std = fares[time].advance_std;
if (fares[time].advance_1st) ADVANCE_FARES[sectionId][time].advance_1st = fares[time].advance_1st;
}
}
function sectionNeedsAdvance(sectionId) {
for (var key in TRIP_FARES) {
var row = TRIP_FARES[key];
if (row.section !== sectionId || !row.advance_key) continue;
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[sectionId];
var advanceFares = sectionFares && sectionFares[row.advance_key];
if (!advanceFares) return true;
if (currentNrClass === 'advance_std' && !advanceFares.advance_std) return true;
if (currentNrClass === 'advance_1st' && !advanceFares.advance_1st) return true;
}
return false;
}
function loadAdvanceFaresForSection(sectionId) {
if (advanceLoadingSections[sectionId] || !ADVANCE_API_URLS[sectionId]) return;
advanceLoadingSections[sectionId] = true;
if (!ADVANCE_FARES) ADVANCE_FARES = {};
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
fetch(ADVANCE_API_URLS[sectionId])
.then(function(response) {
if (!response.ok) throw new Error('advance fare request failed');
return response.json();
})
.then(function(fares) {
mergeAdvanceFares(sectionId, fares);
})
.catch(function() {})
.finally(function() {
advanceLoadingSections[sectionId] = false;
updateDisplay();
});
}
function loadAdvanceFaresForSectionStreaming(sectionId) {
if (advanceLoadingSections[sectionId] || !ADVANCE_STREAM_URLS[sectionId]) return;
advanceLoadingSections[sectionId] = true;
if (!ADVANCE_FARES) ADVANCE_FARES = {};
if (!ADVANCE_FARES[sectionId]) ADVANCE_FARES[sectionId] = {};
var source = new EventSource(ADVANCE_STREAM_URLS[sectionId]);
source.onmessage = function(event) {
var msg = JSON.parse(event.data);
if (msg.type === 'fares') mergeAdvanceFares(sectionId, msg.fares);
if (msg.type === 'done' || msg.type === 'error') {
advanceLoadingSections[sectionId] = false;
source.close();
updateDisplay();
}
};
source.onerror = function() {
advanceLoadingSections[sectionId] = false;
source.close();
updateDisplay();
};
}
function loadMissingAdvanceFares() {
for (var sectionId in ADVANCE_API_URLS) {
if (sectionNeedsAdvance(sectionId)) loadAdvanceFaresForSection(sectionId);
}
}
function currentNrFare(row) {
if (currentNrClass === 'walkon') return row.walkon;
var sectionFares = ADVANCE_FARES && ADVANCE_FARES[row.section];
var advFares = sectionFares && row.advance_key ? sectionFares[row.advance_key] : null;
if (!advFares) return null;
return currentNrClass === 'advance_std' ? advFares.advance_std : advFares.advance_1st;
}
function updateDisplay() { function updateDisplay() {
// First pass: collect totals for min/max emoji badges
var totals = {}; var totals = {};
document.querySelectorAll('tr[data-stp]').forEach(function(tr) { document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
var stp = tr.getAttribute('data-stp'); var key = tr.getAttribute('data-row-key');
var row = TRIP_FARES[stp]; var row = TRIP_FARES[key];
if (!row) return; if (!row) return;
var advFares = ADVANCE_FARES && row.depart_bristol ? ADVANCE_FARES[row.depart_bristol] : null; var nrFare = currentNrFare(row);
var nrFare = currentNrClass === 'walkon' ? row.walkon
: advFares ? (currentNrClass === 'advance_std' ? advFares.advance_std : advFares.advance_1st)
: null;
var esFare = currentEsClass === 'standard' ? row.es_standard : row.es_plus; var esFare = currentEsClass === 'standard' ? row.es_standard : row.es_plus;
if (nrFare && esFare) totals[stp] = nrFare.price + esFare.price + (row.circle_fare || 0); if (nrFare && esFare) totals[key] = nrFare.price + esFare.price + (row.circle_fare || 0);
}); });
var totalValues = Object.values(totals); var totalValues = Object.values(totals);
var minTotal = totalValues.length > 1 ? Math.min.apply(null, totalValues) : null; var minTotal = totalValues.length > 1 ? Math.min.apply(null, totalValues) : null;
var maxTotal = totalValues.length > 1 ? Math.max.apply(null, totalValues) : null; var maxTotal = totalValues.length > 1 ? Math.max.apply(null, totalValues) : null;
var flash = false;
document.querySelectorAll('tr[data-stp]').forEach(function(tr) { document.querySelectorAll('tr[data-row-key]').forEach(function(tr) {
var stp = tr.getAttribute('data-stp'); var key = tr.getAttribute('data-row-key');
var row = TRIP_FARES[stp]; var row = TRIP_FARES[key];
if (!row) return; if (!row) return;
var advFares = ADVANCE_FARES && row.depart_bristol ? ADVANCE_FARES[row.depart_bristol] : null;
// NR fares — walk-on always shown; advance shown when loaded var nrFare = currentNrFare(row);
var walkonEl = tr.querySelector('.nr-walkon'); var walkonEl = tr.querySelector('.nr-walkon');
var advStdEl = tr.querySelector('.nr-advance-std'); var advStdEl = tr.querySelector('.nr-advance-std');
var adv1stEl = tr.querySelector('.nr-advance-1st'); var adv1stEl = tr.querySelector('.nr-advance-1st');
if (walkonEl) { if (walkonEl) {
walkonEl.innerHTML = row.walkon ? fareHtml(row.walkon) : '<span class="text-sm text-muted">\u2013</span>'; walkonEl.innerHTML = row.walkon ? fareHtml(row.walkon) : '<span class="text-sm text-muted">\u2013</span>';
walkonEl.classList.toggle('fare-inactive', currentNrClass !== 'walkon'); walkonEl.classList.toggle('fare-inactive', currentNrClass !== 'walkon');
} }
if (advStdEl) { if (advStdEl || adv1stEl) {
var aStd = advFares && advFares.advance_std; var sectionFares = ADVANCE_FARES && ADVANCE_FARES[row.section];
advStdEl.innerHTML = aStd ? fareHtml(aStd) : (advanceLoading ? '<span class="text-sm text-muted">\u2026</span>' : ''); var advFares = sectionFares && row.advance_key ? sectionFares[row.advance_key] : null;
advStdEl.classList.toggle('fare-inactive', currentNrClass !== 'advance_std'); if (advStdEl) {
} advStdEl.innerHTML = advFares && advFares.advance_std ? fareHtml(advFares.advance_std) : '';
if (adv1stEl) { advStdEl.classList.toggle('fare-inactive', currentNrClass !== 'advance_std');
var a1st = advFares && advFares.advance_1st; }
adv1stEl.innerHTML = a1st ? fareHtml(a1st) : (advanceLoading ? '<span class="text-sm text-muted">\u2026</span>' : ''); if (adv1stEl) {
adv1stEl.classList.toggle('fare-inactive', currentNrClass !== 'advance_1st'); adv1stEl.innerHTML = advFares && advFares.advance_1st ? fareHtml(advFares.advance_1st) : '';
adv1stEl.classList.toggle('fare-inactive', currentNrClass !== 'advance_1st');
}
} }
// ES fares — always show both
var esStdEl = tr.querySelector('.es-standard'); var esStdEl = tr.querySelector('.es-standard');
var esPlusEl = tr.querySelector('.es-plus'); var esPlusEl = tr.querySelector('.es-plus');
if (esStdEl) { if (esStdEl) {
@ -222,44 +275,31 @@
esPlusEl.classList.toggle('fare-inactive', currentEsClass !== 'plus'); esPlusEl.classList.toggle('fare-inactive', currentEsClass !== 'plus');
} }
// Total
var totalSpan = tr.querySelector('.total-price'); var totalSpan = tr.querySelector('.total-price');
if (totalSpan) { if (totalSpan) {
if (stp in totals) { if (key in totals) {
var total = totals[stp]; var total = totals[key];
var html = '<span class="text-sm text-green" style="font-weight:700">' + fmtPrice(total); var html = '<span class="text-sm text-green" style="font-weight:700">' + fmtPrice(total);
if (minTotal !== null && maxTotal !== null) { if (minTotal !== null && maxTotal !== null) {
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">\uD83E\uDE99</span>'; if (total <= minTotal + 10) html += ' <span title="Cheapest journey">low</span>';
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">\uD83D\uDCB8</span>'; else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">high</span>';
} }
html += '</span>'; html += '</span>';
totalSpan.innerHTML = html; totalSpan.innerHTML = html;
flash = true;
} else { } else {
totalSpan.innerHTML = ''; totalSpan.innerHTML = '';
} }
} }
}); });
if (flash) flashTotals();
} }
function flashTotals() { document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.total-price').forEach(function(el) { if (currentNrClass === 'advance_std' || currentNrClass === 'advance_1st') loadMissingAdvanceFares();
el.classList.remove('price-flash'); updateDisplay();
void el.offsetWidth; // force reflow to restart animation
el.classList.add('price-flash');
}); });
}
document.addEventListener('DOMContentLoaded', function() {
updateDisplay();
if ((currentNrClass === 'advance_std' || currentNrClass === 'advance_1st') && ADVANCE_FARES === null) {
loadAdvanceFares();
}
});
</script> </script>
<p class="card-meta"> <p class="card-meta">
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }} {{ gwr_count }} National Rail 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 from_cache %} {% if from_cache %}
@ -278,135 +318,180 @@
{% endif %} {% endif %}
</div> </div>
{% if trips or unreachable_morning_services %} {% if sections %}
<div class="card"> {% for section in sections %}
<table class="results-table"> <div class="card" style="margin-bottom:1.5rem">
<thead> <h2>
<tr> {% if section.direction == 'inbound' %}
<th>National Rail<br><span class="text-xs font-normal text-muted">{{ departure_station_name }} &rarr; Paddington</span></th> Return: {{ destination }} &rarr; {{ departure_station_name }}
<th class="col-transfer">Transfer<br><span class="text-xs font-normal text-muted">Paddington &rarr; St Pancras</span></th>
<th>Eurostar<br><span class="text-xs font-normal text-muted">St Pancras &rarr; {{ destination }}</span></th>
<th class="nowrap">Total</th>
</tr>
</thead>
<tbody>
{% if trips %}
{% set best_mins = trips | map(attribute='total_minutes') | min %}
{% set worst_mins = trips | map(attribute='total_minutes') | max %}
{% endif %}
{% for row in result_rows %}
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
{% set row_class = 'row-fast' %}
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
{% set row_class = 'row-slow' %}
{% elif row.row_type == 'unreachable' %}
{% set row_class = 'row-unreachable' %}
{% elif loop.index is odd %}
{% set row_class = 'row-alt' %}
{% else %}
{% set row_class = '' %}
{% endif %}
<tr class="{{ row_class }}" data-stp="{{ row.depart_st_pancras }}">
{% if row.row_type == 'trip' %}
<td>
<span class="font-bold nowrap">{{ row.depart_bristol }} &rarr; {{ row.arrive_paddington }}</span>
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
{% if row.headcode or row.arrive_platform %}
<br><span class="text-xs text-muted">
{%- if row.headcode %}{{ row.headcode }}{% endif %}
{%- if row.headcode and row.arrive_platform %} &middot; {% endif %}
{%- if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}
</span>
{% endif %}
<span class="fare-line nr-walkon"></span>
<span class="fare-line nr-advance-std"></span>
<span class="fare-line nr-advance-1st"></span>
</td>
<td class="col-transfer" style="color:#4a5568">
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
{% if row.circle_services %}
{% set c = row.circle_services[0] %}
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} → KX {{ c.arrive_kx }} · £{{ "%.2f"|format(c.fare) }}</span>
{% if row.circle_services | length > 1 %}
{% set c2 = row.circle_services[1] %}
<br><span class="text-xs text-muted nowrap" style="opacity:0.7">next {{ c2.depart }} → KX {{ c2.arrive_kx }} · £{{ "%.2f"|format(c2.fare) }}</span>
{% endif %}
{% endif %}
</td>
<td>
<span class="font-bold nowrap">{{ row.depart_st_pancras }} &rarr; {{ row.arrive_destination }} <span class="font-normal text-muted" style="font-size:0.85em">(CET)</span></span>
{% if row.eurostar_duration or row.train_number %}
<br><span class="text-xs text-muted">
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
{%- if row.eurostar_duration and row.train_number %} &middot; {% endif %}
{%- if row.train_number %}{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
</span>
{% endif %}
<span class="fare-line es-standard"></span>
<span class="fare-line es-plus"></span>
</td>
<td class="font-bold nowrap">
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
{% else %}
<span class="text-blue">{{ row.total_duration }}</span>
{% endif %}
<br><span class="total-price"></span>
</td>
{% else %} {% else %}
<td> Outbound: {{ departure_station_name }} &rarr; {{ destination }}
<span class="text-dimmed text-sm" title="Too early to reach from {{ departure_station_name }}">Too early</span>
</td>
<td class="col-transfer text-dimmed">&mdash;</td>
<td>
<span class="font-bold nowrap text-dimmed">{{ row.depart_st_pancras }} &rarr; {{ row.arrive_destination }} <span class="font-normal" style="font-size:0.85em">(CET)</span></span>
{% if row.eurostar_duration or row.train_number %}
<br><span class="text-xs text-dimmed">
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
{%- if row.eurostar_duration and row.train_number %} &middot; {% endif %}
{%- if row.train_number %}{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}{% endif %}
</span>
{% endif %}
<span class="fare-line es-standard"></span>
<span class="fare-line es-plus"></span>
</td>
<td class="text-dimmed">&mdash;</td>
{% endif %} {% endif %}
</tr> </h2>
{% endfor %} <p class="card-meta">{{ section.date_display }}</p>
</tbody> {% if section.rows %}
</table> <table class="results-table">
</div> <thead>
<tr>
{% if section.direction == 'inbound' %}
<th>Eurostar<br><span class="text-xs font-normal text-muted">{{ destination }} &rarr; St Pancras</span></th>
<th class="col-transfer">Transfer<br><span class="text-xs font-normal text-muted">St Pancras &rarr; Paddington</span></th>
<th>National Rail<br><span class="text-xs font-normal text-muted">Paddington &rarr; {{ departure_station_name }}</span></th>
{% else %}
<th>National Rail<br><span class="text-xs font-normal text-muted">{{ departure_station_name }} &rarr; Paddington</span></th>
<th class="col-transfer">Transfer<br><span class="text-xs font-normal text-muted">Paddington &rarr; St Pancras</span></th>
<th>Eurostar<br><span class="text-xs font-normal text-muted">St Pancras &rarr; {{ destination }}</span></th>
{% endif %}
<th class="nowrap">Total</th>
</tr>
</thead>
<tbody>
{% set trip_rows = section.rows | selectattr('row_type', 'equalto', 'trip') | list %}
{% if trip_rows %}
{% set best_mins = trip_rows | map(attribute='total_minutes') | min %}
{% set worst_mins = trip_rows | map(attribute='total_minutes') | max %}
{% endif %}
{% for row in section.rows %}
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
{% set row_class = 'row-fast' %}
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %}
{% set row_class = 'row-slow' %}
{% elif row.row_type == 'unreachable' %}
{% set row_class = 'row-unreachable' %}
{% elif loop.index is odd %}
{% set row_class = 'row-alt' %}
{% else %}
{% set row_class = '' %}
{% endif %}
<tr class="{{ row_class }}" data-row-key="{{ row.row_key }}"
{% if row.ticket_price is not none %}data-walkon="{{ row.ticket_price }}"{% endif %}
{% if row.eurostar_price is not none %}data-es-std="{{ row.eurostar_price }}"{% endif %}>
{% if row.row_type == 'trip' %}
{% if section.direction == 'inbound' %}
<td>
<span class="font-bold nowrap">{{ row.depart_destination }} &rarr; {{ row.arrive_st_pancras }} <span class="font-normal text-muted" style="font-size:0.85em">(UK)</span></span>
<br><span class="text-xs text-muted">check in by {{ row.check_in_by }}</span>
{% if row.eurostar_duration or row.train_number %}
<br><span class="text-xs text-muted">
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
{%- if row.eurostar_duration and row.train_number %} &middot; {% endif %}
{%- if row.train_number %}{{ row.train_number }}{% endif %}
</span>
{% endif %}
<span class="fare-line es-standard">{% if row.eurostar_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
<span class="fare-line es-plus">{% if row.eurostar_plus_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
</td>
<td class="col-transfer" style="color:#4a5568">
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 45 %} <span title="Tight connection">!</span>{% endif %}</span>
{% if row.circle_services %}
{% set c = row.circle_services[0] %}
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} &rarr; PAD {{ c.arrive_pad }} · £{{ "%.2f"|format(c.fare) }}</span>
{% endif %}
</td>
<td>
<span class="font-bold nowrap">{{ row.depart_paddington }} &rarr; {{ row.arrive_uk_station }}</span>
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
{% if row.headcode or row.arrive_platform %}
<br><span class="text-xs text-muted">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} &middot; {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
{% endif %}
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
<span class="fare-line nr-advance-std"></span>
<span class="fare-line nr-advance-1st"></span>
</td>
{% else %}
<td>
<span class="font-bold nowrap">{{ row.depart_bristol }} &rarr; {{ row.arrive_paddington }}</span>
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
{% if row.headcode or row.arrive_platform %}
<br><span class="text-xs text-muted">{{ row.headcode }}{% if row.headcode and row.arrive_platform %} &middot; {% endif %}{% if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %}</span>
{% endif %}
<span class="fare-line nr-walkon">{% if row.ticket_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.ticket_price) }}</span>{% endif %}</span>
<span class="fare-line nr-advance-std"></span>
<span class="fare-line nr-advance-1st"></span>
</td>
<td class="col-transfer" style="color:#4a5568">
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">!</span>{% endif %}</span>
{% if row.circle_services %}
{% set c = row.circle_services[0] %}
<br><span class="text-xs text-muted nowrap">Circle {{ c.depart }} &rarr; KX {{ c.arrive_kx }} · £{{ "%.2f"|format(c.fare) }}</span>
{% endif %}
</td>
<td>
<span class="font-bold nowrap">{{ row.depart_st_pancras }} &rarr; {{ row.arrive_destination }} <span class="font-normal text-muted" style="font-size:0.85em">(CET)</span></span>
{% if row.eurostar_duration or row.train_number %}
<br><span class="text-xs text-muted">
{%- if row.eurostar_duration %}<span class="nowrap">({{ row.eurostar_duration }})</span>{% endif %}
{%- if row.eurostar_duration and row.train_number %} &middot; {% endif %}
{%- if row.train_number %}{{ row.train_number }}{% endif %}
</span>
{% endif %}
<span class="fare-line es-standard">{% if row.eurostar_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>{% endif %}</span>
<span class="fare-line es-plus">{% if row.eurostar_plus_price is not none %}<span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>{% endif %}</span>
</td>
{% endif %}
<td class="font-bold nowrap">
{% if row.total_minutes <= best_mins + 5 and trip_rows | length > 1 %}
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
{% elif row.total_minutes >= worst_mins - 5 and trip_rows | length > 1 %}
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
{% else %}
<span class="text-blue">{{ row.total_duration }}</span>
{% endif %}
<br><span class="total-price"></span>
</td>
{% else %}
<td>
<span class="text-dimmed text-sm">Too early</span>
</td>
<td class="col-transfer text-dimmed">&mdash;</td>
<td>
{% if section.direction == 'inbound' %}
<span class="font-bold nowrap text-dimmed">{{ row.depart_destination }} &rarr; {{ row.arrive_st_pancras }}</span>
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
{% else %}
<span class="font-bold nowrap text-dimmed">{{ row.depart_st_pancras }} &rarr; {{ row.arrive_destination }}</span>
{% if row.train_number %}<br><span class="text-xs text-dimmed">{{ row.train_number }}</span>{% endif %}
{% endif %}
</td>
<td class="text-dimmed nowrap">No connection</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">
<p>No valid journeys found.</p>
<p>
{% if section.gwr_count == 0 and section.eurostar_count == 0 %}
Could not retrieve train data. Check your network connection or try again.
{% elif section.gwr_count == 0 %}
No National Rail trains found for this date.
{% elif section.eurostar_count == 0 %}
No Eurostar services found for {{ destination }} on this date.
{% else %}
No National Rail + Eurostar combination has a {{ section.min_connection }}-{{ section.max_connection }} minute connection.
{% endif %}
</p>
</div>
{% endif %}
</div>
{% endfor %}
<p class="footnote"> <p class="footnote">
Paddington &rarr; St&nbsp;Pancras connection: {{ min_connection }}&ndash;{{ max_connection }}&nbsp;min. Connection windows:
GWR walk-on and advance prices from {% for section in sections %}
<a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>. {% if section.direction == 'inbound' %}return{% else %}outbound{% endif %}
Eurostar Standard and Standard Premier prices are for 1 adult in GBP; always check {{ section.min_connection }}&ndash;{{ section.max_connection }}&nbsp;min{% if not loop.last %}; {% endif %}
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book. {% endfor %}.
&nbsp;&middot;&nbsp; National Rail prices from <a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
<a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} departures on RTT</a> Eurostar prices are for 1 adult in GBP; return searches use Eurostar return-search prices.
&nbsp;&middot;&nbsp; Always check <a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a> &nbsp;&middot;&nbsp;
</p> <a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} on RTT</a>
&nbsp;&middot;&nbsp;
{% else %} <a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington on RTT</a>
<div class="card empty-state">
<p>No valid journeys found.</p>
<p>
{% if gwr_count == 0 and eurostar_count == 0 %}
Could not retrieve train data. Check your network connection or try again.
{% elif gwr_count == 0 %}
No GWR trains found for this date.
{% elif eurostar_count == 0 %}
No Eurostar services found for {{ destination }} on this date.
{% else %}
No GWR&nbsp;+&nbsp;Eurostar combination has at least a {{ min_connection }}-minute connection at Paddington/St&nbsp;Pancras.
{% endif %}
</p> </p>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -65,6 +65,17 @@ def test_search_redirects_to_results_with_selected_params():
) )
def test_search_redirects_return_with_return_date():
client = _client()
resp = client.get('/search?journey_type=return&destination=paris&travel_date=2026-04-10&return_date=2026-04-17&station_crs=BRI')
assert resp.status_code == 302
assert resp.headers['Location'].endswith(
'/results/BRI/paris/2026-04-10?journey_type=return&return_date=2026-04-17'
)
def test_results_shows_same_day_destination_switcher(monkeypatch): def test_results_shows_same_day_destination_switcher(monkeypatch):
_stub_data(monkeypatch) _stub_data(monkeypatch)
client = _client() client = _client()
@ -290,6 +301,97 @@ def test_results_preloads_cached_advance_fares(monkeypatch):
assert 'cachedAdvanceFares' in html assert 'cachedAdvanceFares' in html
def test_results_inbound_uses_reverse_legs(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch_from_paddington',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_paddington': '17:15', 'arrive_destination': '18:55', 'headcode': '1B99'},
],
)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
'fetch',
lambda station_crs, travel_date, direction='to_paddington': {
'17:15': {'ticket': 'Off-Peak Single', 'price': 63.60, 'code': 'SVS'}
},
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'fetch',
lambda destination, travel_date, direction='outbound': [
{'depart_destination': '15:12', 'arrive_st_pancras': '16:30',
'destination': destination, 'train_number': 'ES 9035',
'price': 49, 'seats': 43, 'plus_price': None, 'plus_seats': None},
],
)
client = _client()
resp = client.get('/results/BRI/paris/2026-04-10?journey_type=inbound')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert 'Paris Gare du Nord &rarr; Bristol Temple Meads' in html
assert '15:12 &rarr; 16:30' in html
assert '17:15 &rarr; 18:55' in html
assert 'ES 9035' in html
def test_results_return_renders_outbound_and_inbound_tables(monkeypatch):
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
],
)
monkeypatch.setattr(
app_module.rtt_scraper,
'fetch_from_paddington',
lambda travel_date, user_agent, station_crs='BRI': [
{'depart_paddington': '17:15', 'arrive_destination': '18:55', 'headcode': '1B99'},
],
)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
'fetch',
lambda station_crs, travel_date, direction='to_paddington': {
'07:00': {'ticket': 'Anytime Day Single', 'price': 138.70, 'code': 'SDS'},
'17:15': {'ticket': 'Off-Peak Single', 'price': 63.60, 'code': 'SVS'},
},
)
monkeypatch.setattr(
app_module.eurostar_scraper,
'fetch_return',
lambda destination, outbound_date, return_date: {
'outbound': [
{'depart_st_pancras': '10:01', 'arrive_destination': '13:34',
'destination': destination, 'train_number': 'ES 9014',
'price': 59, 'seats': 42, 'plus_price': None, 'plus_seats': None},
],
'inbound': [
{'depart_destination': '15:12', 'arrive_st_pancras': '16:30',
'destination': destination, 'train_number': 'ES 9035',
'price': 49, 'seats': 43, 'plus_price': None, 'plus_seats': None},
],
},
)
client = _client()
resp = client.get('/results/BRI/paris/2026-04-10?journey_type=return&return_date=2026-04-17')
html = resp.get_data(as_text=True)
assert resp.status_code == 200
assert 'Outbound: Bristol Temple Meads &rarr; Paris Gare du Nord' in html
assert 'Return: Paris Gare du Nord &rarr; Bristol Temple Meads' in html
assert 'ES 9014' in html
assert 'ES 9035' in html
def test_api_advance_fares_returns_json(monkeypatch): def test_api_advance_fares_returns_json(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)

View file

@ -1,5 +1,5 @@
import pytest import pytest
from scraper.eurostar import _parse_graphql, search_url from scraper.eurostar import _parse_graphql, _parse_graphql_leg, search_url
def _gql_response(journeys: list) -> dict: def _gql_response(journeys: list) -> dict:
@ -110,6 +110,24 @@ def test_parse_graphql_empty_journeys():
assert _parse_graphql(data, 'Paris Gare du Nord') == [] assert _parse_graphql(data, 'Paris Gare du Nord') == []
def test_parse_graphql_inbound_leg():
data = {'data': {'journeySearch': {'inbound': {'journeys': [
_journey('17:12', '18:30', price=49, seats=43, service_name='9035')
]}}}}
services = _parse_graphql_leg(data, 'Paris Gare du Nord', 'inbound', 'inbound')
assert services == [{
'depart_destination': '17:12',
'arrive_st_pancras': '18:30',
'destination': 'Paris Gare du Nord',
'train_number': 'ES 9035',
'price': 49.0,
'seats': 43,
'plus_price': None,
'plus_seats': None,
}]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# search_url # search_url
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -120,3 +138,8 @@ def test_search_url():
'https://www.eurostar.com/search/uk-en' 'https://www.eurostar.com/search/uk-en'
'?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10' '?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10'
) )
def test_search_url_return():
url = search_url('Paris Gare du Nord', '2026-04-10', return_date='2026-04-17')
assert url.endswith('&outbound=2026-04-10&inbound=2026-04-17')

View file

@ -0,0 +1,422 @@
import threading
import pytest
from werkzeug.serving import make_server
import app as app_module
playwright_sync = pytest.importorskip("playwright.sync_api")
sync_playwright = playwright_sync.sync_playwright
def _stub_return_data(monkeypatch):
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
app_module.rtt_scraper,
"fetch_from_paddington",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_paddington": "17:15",
"arrive_destination": "18:55",
"headcode": "1B99",
},
],
)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date, direction="to_paddington": {
"07:00": {
"ticket": "Anytime Day Single",
"price": 138.70,
"code": "SDS",
},
"17:15": {
"ticket": "Off-Peak Single",
"price": 63.60,
"code": "SVS",
},
},
)
def fake_advance_streaming(station_crs, travel_date, direction="to_paddington"):
if direction == "from_paddington":
yield {
"17:15": {
"advance_std": {
"ticket": "Advance Single",
"price": 25.0,
"code": "ADV",
},
"advance_1st": {
"ticket": "1st Advance",
"price": 45.0,
"code": "AFA",
},
}
}
else:
yield {
"07:00": {
"advance_std": {
"ticket": "Advance Single",
"price": 50.0,
"code": "ADV",
},
"advance_1st": {
"ticket": "1st Advance",
"price": 80.0,
"code": "AFA",
},
}
}
monkeypatch.setattr(
app_module.gwr_fares_scraper,
"fetch_advance_streaming",
fake_advance_streaming,
)
def fake_advance(station_crs, travel_date, direction="to_paddington"):
pages = list(fake_advance_streaming(station_crs, travel_date, direction))
return pages[0] if pages else {}
monkeypatch.setattr(app_module.gwr_fares_scraper, "fetch_advance", fake_advance)
monkeypatch.setattr(
app_module.eurostar_scraper,
"fetch_return",
lambda destination, outbound_date, return_date: {
"outbound": [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": 89,
"plus_seats": 5,
},
],
"inbound": [
{
"depart_destination": "15:12",
"arrive_st_pancras": "16:30",
"destination": destination,
"train_number": "ES 9035",
"price": 49,
"seats": 43,
"plus_price": 79,
"plus_seats": 6,
},
],
},
)
def _stub_single_data(monkeypatch):
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date: {
"07:00": {
"ticket": "Anytime Day Single",
"price": 138.70,
"code": "SDS",
},
},
)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
"fetch_advance",
lambda station_crs, travel_date: {
"07:00": {
"advance_std": {
"ticket": "Advance Single",
"price": 50.0,
"code": "ADV",
},
"advance_1st": {
"ticket": "1st Advance",
"price": 80.0,
"code": "AFA",
},
},
},
)
monkeypatch.setattr(
app_module.eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": 89,
"plus_seats": 5,
},
],
)
@pytest.fixture
def local_server(monkeypatch):
_stub_return_data(monkeypatch)
app_module.app.config["TESTING"] = True
server = make_server("127.0.0.1", 0, app_module.app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
thread.join(timeout=5)
@pytest.fixture
def single_server(monkeypatch):
_stub_single_data(monkeypatch)
app_module.app.config["TESTING"] = True
server = make_server("127.0.0.1", 0, app_module.app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}"
finally:
server.shutdown()
thread.join(timeout=5)
def _launch_browser(playwright):
try:
return playwright.chromium.launch(headless=True)
except Exception as exc:
pytest.skip(f"Chromium browser unavailable for Playwright: {exc}")
def test_single_advance_standard_totals_after_click(single_server):
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"{single_server}/results/BRI/paris/2026-07-20",
wait_until="domcontentloaded",
)
page.get_by_role("button", name="Advance Std").click()
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£112.10'))",
timeout=10000,
)
assert "nr_class=advance_std" in page.url
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£112.10"]
browser.close()
def test_single_next_date_advance_standard_labels_unreachable_rows(monkeypatch):
monkeypatch.setattr(app_module, "get_cached", lambda key, ttl=None: None)
monkeypatch.setattr(app_module, "set_cached", lambda key, data: None)
monkeypatch.setattr(
app_module.rtt_scraper,
"fetch",
lambda travel_date, user_agent, station_crs="BRI": [
{
"depart_bristol": "07:00",
"arrive_paddington": "08:45",
"headcode": "1A23",
},
],
)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
"fetch",
lambda station_crs, travel_date: {
"07:00": {
"ticket": "Anytime Day Single",
"price": 138.70,
"code": "SDS",
},
},
)
monkeypatch.setattr(
app_module.gwr_fares_scraper,
"fetch_advance",
lambda station_crs, travel_date: {
"07:00": {
"advance_std": {
"ticket": "Advance Single",
"price": 50.0,
"code": "ADV",
},
"advance_1st": None,
},
},
)
monkeypatch.setattr(
app_module.eurostar_scraper,
"fetch",
lambda destination, travel_date: [
{
"depart_st_pancras": "09:30",
"arrive_destination": "12:30",
"destination": destination,
"train_number": "ES 9001",
"price": 59,
"seats": 42,
"plus_price": None,
"plus_seats": None,
},
{
"depart_st_pancras": "10:01",
"arrive_destination": "13:34",
"destination": destination,
"train_number": "ES 9014",
"price": 59,
"seats": 42,
"plus_price": None,
"plus_seats": None,
},
],
)
app_module.app.config["TESTING"] = True
server = make_server("127.0.0.1", 0, app_module.app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"http://127.0.0.1:{server.server_port}"
"/results/BRI/brussels/2026-06-16",
wait_until="domcontentloaded",
)
page.get_by_role("link", name="Next →").click()
page.wait_for_url("**/2026-06-17**", timeout=10000)
page.get_by_role("button", name="Advance Std").click()
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£112.10'))",
timeout=10000,
)
assert page.get_by_text("No connection").count() == 1
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£112.10"]
browser.close()
finally:
server.shutdown()
thread.join(timeout=5)
def test_single_advance_standard_premier_totals_on_initial_url(single_server):
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"{single_server}/results/BRI/paris/2026-07-20"
"?nr_class=advance_std&es_class=plus",
wait_until="domcontentloaded",
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£142.10'))",
timeout=10000,
)
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£142.10"]
browser.close()
def test_return_advance_first_standard_premier_totals(local_server):
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(f"{local_server}/", wait_until="domcontentloaded")
page.locator("#journey-return").check(force=True)
page.locator("#destination-paris").check(force=True)
page.locator("#travel_date").fill("2026-07-20")
page.locator("#return_date").fill("2026-07-27")
page.locator('button[type="submit"]').click()
page.wait_for_url("**/results/**", timeout=10000)
page.get_by_role("button", name="Advance 1st").click()
page.get_by_role("button", name="Standard Premier").click()
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£172.10'))",
timeout=10000,
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£127.10'))",
timeout=10000,
)
assert "journey_type=return" in page.url
assert "return_date=2026-07-27" in page.url
assert "nr_class=advance_1st" in page.url
assert "es_class=plus" in page.url
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£172.10 high", "£127.10 low"]
browser.close()
def test_return_advance_first_standard_premier_totals_on_initial_url(local_server):
with sync_playwright() as p:
browser = _launch_browser(p)
page = browser.new_page()
page.goto(
f"{local_server}/results/BRI/paris/2026-07-20"
"?journey_type=return&return_date=2026-07-27"
"&nr_class=advance_1st&es_class=plus",
wait_until="domcontentloaded",
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£172.10'))",
timeout=10000,
)
page.wait_for_function(
"Array.from(document.querySelectorAll('.total-price'))"
".some(el => el.textContent.includes('£127.10'))",
timeout=10000,
)
totals = [el.inner_text() for el in page.locator(".total-price").all()]
assert totals == ["£172.10 high", "£127.10 low"]
browser.close()

View file

@ -1,5 +1,10 @@
import pytest import pytest
from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration from trip_planner import (
combine_inbound_trips,
combine_trips,
find_unreachable_morning_eurostars,
_fmt_duration,
)
DATE = '2026-03-30' DATE = '2026-03-30'
@ -178,3 +183,28 @@ def test_find_unreachable_eurostars_returns_empty_when_all_connectable():
] ]
assert find_unreachable_morning_eurostars(gwr, eurostar, DATE) == [] assert find_unreachable_morning_eurostars(gwr, eurostar, DATE) == []
def test_combine_inbound_trips_pairs_eurostar_to_paddington_departure():
eurostar = [{
'depart_destination': '15:12',
'arrive_st_pancras': '16:30',
'destination': 'Paris Gare du Nord',
'train_number': 'ES 9035',
}]
gwr = [{
'depart_paddington': '17:15',
'arrive_destination': '18:55',
'headcode': '1B99',
}]
fares = {'17:15': {'ticket': 'Off-Peak Single', 'price': 63.60, 'code': 'SVS'}}
trips = combine_inbound_trips(eurostar, gwr, DATE, min_connection_minutes=30, max_connection_minutes=120, gwr_fares=fares)
assert len(trips) == 1
assert trips[0]['depart_destination'] == '15:12'
assert trips[0]['arrive_st_pancras'] == '16:30'
assert trips[0]['depart_paddington'] == '17:15'
assert trips[0]['arrive_uk_station'] == '18:55'
assert trips[0]['ticket_price'] == 63.60
assert trips[0]['check_in_by'] == '14:42'

View file

@ -9,10 +9,13 @@ from tfl_fare import circle_line_fare
MIN_CONNECTION_MINUTES = 50 MIN_CONNECTION_MINUTES = 50
MAX_CONNECTION_MINUTES = 110 MAX_CONNECTION_MINUTES = 110
INBOUND_MIN_CONNECTION_MINUTES = 30
INBOUND_MAX_CONNECTION_MINUTES = 120
DATE_FMT = "%Y-%m-%d" DATE_FMT = "%Y-%m-%d"
TIME_FMT = "%H:%M" TIME_FMT = "%H:%M"
PAD_WALK_TO_UNDERGROUND_MINUTES = 8 # GWR platform → Paddington (H&C Line) platform PAD_WALK_TO_UNDERGROUND_MINUTES = 8 # GWR platform → Paddington (H&C Line) platform
KX_WALK_TO_UNDERGROUND_MINUTES = 10 # St Pancras arrivals → King's Cross St Pancras Underground
def _parse_dt(date: str, time: str) -> datetime: def _parse_dt(date: str, time: str) -> datetime:
@ -30,7 +33,7 @@ def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
earliest_board = arrive_paddington + timedelta( earliest_board = arrive_paddington + timedelta(
minutes=PAD_WALK_TO_UNDERGROUND_MINUTES minutes=PAD_WALK_TO_UNDERGROUND_MINUTES
) )
services = circle_line.upcoming_services(earliest_board, count=2) services = circle_line.upcoming_services(earliest_board, count=2, direction='pad_to_kx')
return [ return [
{ {
"depart": dep.strftime(TIME_FMT), "depart": dep.strftime(TIME_FMT),
@ -41,6 +44,21 @@ def _circle_line_services(arrive_paddington: datetime) -> list[dict]:
] ]
def _circle_line_services_to_paddington(arrive_st_pancras: datetime) -> list[dict]:
earliest_board = arrive_st_pancras + timedelta(
minutes=KX_WALK_TO_UNDERGROUND_MINUTES
)
services = circle_line.upcoming_services(earliest_board, count=2, direction='kx_to_pad')
return [
{
"depart": dep.strftime(TIME_FMT),
"arrive_pad": arr.strftime(TIME_FMT),
"fare": circle_line_fare(dep),
}
for dep, arr in services
]
def _fmt_duration(minutes: int) -> str: def _fmt_duration(minutes: int) -> str:
h, m = divmod(minutes, 60) h, m = divmod(minutes, 60)
if h and m: if h and m:
@ -80,6 +98,37 @@ def _is_viable_connection(
return dep_bri, arr_pad, dep_stp, arr_dest return dep_bri, arr_pad, dep_stp, arr_dest
def _is_viable_inbound_connection(
eurostar: dict,
gwr: dict,
travel_date: str,
min_connection_minutes: int,
max_connection_minutes: int,
) -> tuple[datetime, datetime, datetime, datetime] | None:
try:
dep_dest = _parse_dt(travel_date, eurostar["depart_destination"])
arr_stp = _parse_dt(travel_date, eurostar["arrive_st_pancras"])
dep_pad = _parse_dt(travel_date, gwr["depart_paddington"])
arr_station = _parse_dt(travel_date, gwr["arrive_destination"])
except (ValueError, KeyError):
return None
if arr_stp < dep_dest:
arr_stp += timedelta(days=1)
if dep_pad < arr_stp:
dep_pad += timedelta(days=1)
if arr_station < dep_pad:
arr_station += timedelta(days=1)
connection_minutes = (dep_pad - arr_stp).total_seconds() / 60
if connection_minutes < min_connection_minutes:
return None
if connection_minutes > max_connection_minutes:
return None
return dep_dest, arr_stp, dep_pad, arr_station
def combine_trips( def combine_trips(
gwr_trains: list[dict], gwr_trains: list[dict],
eurostar_trains: list[dict], eurostar_trains: list[dict],
@ -154,6 +203,68 @@ def combine_trips(
return trips return trips
def combine_inbound_trips(
eurostar_trains: list[dict],
gwr_trains: list[dict],
travel_date: str,
min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES,
gwr_fares: dict | None = None,
) -> list[dict]:
"""Return valid continent→UK combined trips."""
trips = []
for es in eurostar_trains:
for gwr in gwr_trains:
connection = _is_viable_inbound_connection(
es,
gwr,
travel_date,
min_connection_minutes,
max_connection_minutes,
)
if not connection:
continue
dep_dest, arr_stp, dep_pad, arr_station = connection
total_mins = int((arr_station - dep_dest).total_seconds() / 60)
# Destination time is CET/CEST, arrival at London is GMT/BST.
eurostar_mins = int((arr_stp - dep_dest).total_seconds() / 60) + 60
fare = (gwr_fares or {}).get(gwr["depart_paddington"])
circle_svcs = _circle_line_services_to_paddington(arr_stp)
trips.append(
{
"direction": "inbound",
"depart_destination": es["depart_destination"],
"check_in_by": (dep_dest - timedelta(minutes=30)).strftime(TIME_FMT),
"arrive_st_pancras": es["arrive_st_pancras"],
"depart_paddington": gwr["depart_paddington"],
"arrive_uk_station": gwr["arrive_destination"],
"arrive_platform": gwr.get("arrive_platform", ""),
"headcode": gwr.get("headcode", ""),
"gwr_duration": _fmt_duration(
int((arr_station - dep_pad).total_seconds() / 60)
),
"connection_minutes": int((dep_pad - arr_stp).total_seconds() / 60),
"connection_duration": _fmt_duration(
int((dep_pad - arr_stp).total_seconds() / 60)
),
"circle_services": circle_svcs,
"eurostar_duration": _fmt_duration(eurostar_mins),
"train_number": es.get("train_number", ""),
"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
trips.sort(key=lambda t: (t["depart_destination"], t["depart_paddington"]))
return trips
def find_unreachable_morning_eurostars( def find_unreachable_morning_eurostars(
gwr_trains: list[dict], gwr_trains: list[dict],
eurostar_trains: list[dict], eurostar_trains: list[dict],
@ -184,3 +295,35 @@ def find_unreachable_morning_eurostars(
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"])
def find_unreachable_inbound_eurostars(
eurostar_trains: list[dict],
gwr_trains: list[dict],
travel_date: str,
min_connection_minutes: int = INBOUND_MIN_CONNECTION_MINUTES,
max_connection_minutes: int = INBOUND_MAX_CONNECTION_MINUTES,
) -> list[dict]:
unreachable = []
for es in eurostar_trains:
if any(
_is_viable_inbound_connection(
es,
gwr,
travel_date,
min_connection_minutes,
max_connection_minutes,
)
for gwr in gwr_trains
):
continue
dep_dest = _parse_dt(travel_date, es["depart_destination"])
arr_stp = _parse_dt(travel_date, es["arrive_st_pancras"])
if arr_stp < dep_dest:
arr_stp += timedelta(days=1)
eurostar_mins = int((arr_stp - dep_dest).total_seconds() / 60) + 60
unreachable.append({**es, "eurostar_duration": _fmt_duration(eurostar_mins)})
return sorted(unreachable, key=lambda s: s["depart_destination"])