diff --git a/app.py b/app.py index a5a60cb..a4e76df 100644 --- a/app.py +++ b/app.py @@ -1,55 +1,75 @@ """ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains. """ -from flask import Flask, render_template, redirect, url_for, request + +from flask import Flask, render_template, redirect, url_for, request, abort from datetime import date, timedelta +from pathlib import Path import os from cache import get_cached, set_cached import scraper.eurostar as eurostar_scraper +import scraper.gwr_fares as gwr_fares_scraper import scraper.realtime_trains as rtt_scraper from trip_planner import combine_trips, find_unreachable_morning_eurostars RTT_PADDINGTON_URL = ( "https://www.realtimetrains.co.uk/search/detailed/" - "gb-nr:PAD/from/gb-nr:BRI/{date}/0000-2359" + "gb-nr:PAD/from/gb-nr:{crs}/{date}/0000-2359" "?stp=WVS&show=pax-calls&order=wtt" ) -RTT_BRISTOL_URL = ( +RTT_STATION_URL = ( "https://www.realtimetrains.co.uk/search/detailed/" - "gb-nr:BRI/to/gb-nr:PAD/{date}/0000-2359" + "gb-nr:{crs}/to/gb-nr:PAD/{date}/0000-2359" "?stp=WVS&show=pax-calls&order=wtt" ) app = Flask(__name__, instance_relative_config=False) -app.config.from_object('config.default') -_local = os.path.join(os.path.dirname(__file__), 'config', 'local.py') +app.config.from_object("config.default") +_local = os.path.join(os.path.dirname(__file__), "config", "local.py") if os.path.exists(_local): app.config.from_pyfile(_local) import cache import circle_line -cache.CACHE_DIR = app.config['CACHE_DIR'] -circle_line._TXC_XML = app.config['CIRCLE_LINE_XML'] + +cache.CACHE_DIR = app.config["CACHE_DIR"] +circle_line._TXC_XML = app.config["CIRCLE_LINE_XML"] + + +def _load_stations(): + tsv = Path(__file__).parent / "data" / "direct_to_paddington.tsv" + stations = [] + for line in tsv.read_text().splitlines(): + line = line.strip() + if "\t" in line: + name, crs = line.split("\t", 1) + stations.append((name, crs)) + return sorted(stations, key=lambda x: x[0]) + + +STATIONS = _load_stations() +STATION_BY_CRS = {crs: name for name, crs in STATIONS} DESTINATIONS = { - 'paris': 'Paris Gare du Nord', - 'brussels': 'Brussels Midi', - 'lille': 'Lille Europe', - 'amsterdam': 'Amsterdam Centraal', - 'rotterdam': 'Rotterdam Centraal', - 'cologne': 'Cologne Hbf', + "paris": "Paris Gare du Nord", + "brussels": "Brussels Midi", + "lille": "Lille Europe", + "amsterdam": "Amsterdam Centraal", + "rotterdam": "Rotterdam Centraal", + "cologne": "Cologne Hbf", } -@app.route('/') +@app.route("/") def index(): today = date.today().isoformat() return render_template( - 'index.html', + "index.html", destinations=DESTINATIONS, today=today, + stations=STATIONS, valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_max_connections=sorted(VALID_MAX_CONNECTIONS), ) @@ -59,53 +79,70 @@ VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120} VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180} -@app.route('/search') +@app.route("/search") def search(): - slug = request.args.get('destination', '') - travel_date = request.args.get('travel_date', '') + slug = request.args.get("destination", "") + travel_date = request.args.get("travel_date", "") + station_crs = request.args.get("station_crs", "BRI") + if station_crs not in STATION_BY_CRS: + station_crs = "BRI" try: - min_conn = int(request.args.get('min_connection', 50)) + min_conn = int(request.args.get("min_connection", 50)) except ValueError: min_conn = 50 if min_conn not in VALID_MIN_CONNECTIONS: min_conn = 50 try: - max_conn = int(request.args.get('max_connection', 110)) + max_conn = int(request.args.get("max_connection", 110)) except ValueError: max_conn = 110 if max_conn not in VALID_MAX_CONNECTIONS: max_conn = 110 if slug in DESTINATIONS and travel_date: - return redirect(url_for('results', slug=slug, travel_date=travel_date, min_connection=min_conn, max_connection=max_conn)) - return redirect(url_for('index')) + return redirect( + url_for( + "results", + station_crs=station_crs, + slug=slug, + travel_date=travel_date, + min_connection=min_conn, + max_connection=max_conn, + ) + ) + return redirect(url_for("index")) -@app.route('/results//') -def results(slug, travel_date): +@app.route("/results///") +def results(station_crs, slug, travel_date): + departure_station_name = STATION_BY_CRS.get(station_crs) + if departure_station_name is None: + abort(404) destination = DESTINATIONS.get(slug) if not destination or not travel_date: - return redirect(url_for('index')) + return redirect(url_for("index")) try: - min_connection = int(request.args.get('min_connection', 50)) + min_connection = int(request.args.get("min_connection", 50)) except ValueError: min_connection = 50 if min_connection not in VALID_MIN_CONNECTIONS: min_connection = 50 try: - max_connection = int(request.args.get('max_connection', 110)) + max_connection = int(request.args.get("max_connection", 110)) except ValueError: max_connection = 110 if max_connection not in VALID_MAX_CONNECTIONS: max_connection = 110 - user_agent = request.headers.get('User-Agent', rtt_scraper.DEFAULT_UA) + user_agent = request.headers.get("User-Agent", rtt_scraper.DEFAULT_UA) - rtt_cache_key = f"rtt_{travel_date}" + rtt_cache_key = f"rtt_{station_crs}_{travel_date}" es_cache_key = f"eurostar_{travel_date}_{destination}" + gwr_fares_cache_key = f"gwr_fares_{station_crs}_{travel_date}" 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) from_cache = bool(cached_rtt and cached_es) error = None @@ -114,7 +151,7 @@ def results(slug, travel_date): gwr_trains = cached_rtt else: try: - gwr_trains = rtt_scraper.fetch(travel_date, user_agent) + gwr_trains = rtt_scraper.fetch(travel_date, user_agent, station_crs) set_cached(rtt_cache_key, gwr_trains) except Exception as e: gwr_trains = [] @@ -131,26 +168,51 @@ def results(slug, travel_date): msg = f"Could not fetch Eurostar times: {e}" error = f"{error}; {msg}" if error else msg + if cached_gwr_fares: + gwr_fares = cached_gwr_fares + else: + try: + gwr_fares = gwr_fares_scraper.fetch(station_crs, travel_date) + set_cached(gwr_fares_cache_key, gwr_fares) + except Exception as e: + gwr_fares = {} + msg = f"Could not fetch GWR fares: {e}" + error = f"{error}; {msg}" if error else msg + eurostar_trains = eurostar_services eurostar_prices = { - s['depart_st_pancras']: {'price': s.get('price'), 'seats': s.get('seats')} + s["depart_st_pancras"]: {"price": s.get("price"), "seats": s.get("seats")} for s in eurostar_services } - trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection) + trips = combine_trips( + gwr_trains, + eurostar_trains, + travel_date, + min_connection, + max_connection, + gwr_fares, + ) # Annotate each trip with Eurostar Standard price, seats, and total cost for trip in trips: - es = eurostar_prices.get(trip['depart_st_pancras'], {}) - es_price = es.get('price') - trip['eurostar_price'] = es_price - trip['eurostar_seats'] = es.get('seats') - trip['total_price'] = trip['ticket_price'] + es_price if es_price is not None else None + es = eurostar_prices.get(trip["depart_st_pancras"], {}) + es_price = es.get("price") + trip["eurostar_price"] = es_price + trip["eurostar_seats"] = es.get("seats") + gwr_p = trip.get("ticket_price") + trip["total_price"] = ( + gwr_p + es_price if (gwr_p is not None and es_price is not None) else None + ) # If the API returned journeys but every price is None, tickets aren't on sale yet 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.' + 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, @@ -160,27 +222,40 @@ def results(slug, travel_date): 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') + es = eurostar_prices.get(svc["depart_st_pancras"], {}) + svc["eurostar_price"] = es.get("price") + svc["eurostar_seats"] = es.get("seats") + + # Only keep unreachable services that depart before the first reachable Eurostar. + # Services after the first reachable one are omitted (they aren't "Too early"). + if trips: + first_es_depart = min(t["depart_st_pancras"] for t in trips) + unreachable_morning_services = [ + s + for s in unreachable_morning_services + if s["depart_st_pancras"] < first_es_depart + ] result_rows = sorted( - [{'row_type': 'trip', **trip} for trip in trips] - + [{'row_type': 'unreachable', **service} for service in unreachable_morning_services], - key=lambda row: row['depart_st_pancras'], + [{"row_type": "trip", **trip} for trip in trips] + + [ + {"row_type": "unreachable", **service} + for service in unreachable_morning_services + ], + key=lambda row: row["depart_st_pancras"], ) dt = date.fromisoformat(travel_date) prev_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) - rtt_url = RTT_PADDINGTON_URL.format(date=travel_date) - rtt_bristol_url = RTT_BRISTOL_URL.format(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) return render_template( - 'results.html', + "results.html", trips=trips, result_rows=result_rows, unreachable_morning_services=unreachable_morning_services, @@ -188,6 +263,8 @@ def results(slug, travel_date): destination=destination, travel_date=travel_date, slug=slug, + station_crs=station_crs, + departure_station_name=departure_station_name, prev_date=prev_date, next_date=next_date, travel_date_display=travel_date_display, @@ -198,7 +275,7 @@ def results(slug, travel_date): no_prices_note=no_prices_note, eurostar_url=eurostar_url, rtt_url=rtt_url, - rtt_bristol_url=rtt_bristol_url, + rtt_station_url=rtt_station_url, min_connection=min_connection, max_connection=max_connection, valid_min_connections=sorted(VALID_MIN_CONNECTIONS), @@ -206,5 +283,5 @@ def results(slug, travel_date): ) -if __name__ == '__main__': - app.run(debug=True) +if __name__ == "__main__": + app.run(debug=True, host="0.0.0.0") diff --git a/circle_line.py b/circle_line.py index 5c942f9..6292f24 100644 --- a/circle_line.py +++ b/circle_line.py @@ -135,6 +135,19 @@ def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None: The caller is responsible for adding any walk time from the GWR platform before passing *earliest_board*. """ + services = upcoming_services(earliest_board, count=1) + return services[0] if services else None + + +def upcoming_services( + earliest_board: datetime, count: int = 2 +) -> list[tuple[datetime, datetime]]: + """ + Return up to *count* Circle line services from Paddington (H&C Line) to + King's Cross St Pancras, starting from *earliest_board*. + + Each element is (depart_paddington, arrive_kings_cross) as datetimes. + """ timetable = _get_timetable()[_day_type(earliest_board.weekday())] board_secs = ( earliest_board.hour * 3600 @@ -142,7 +155,13 @@ def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None: + earliest_board.second ) midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0) + results = [] for pad_secs, kxp_secs in timetable: if pad_secs >= board_secs: - return midnight + timedelta(seconds=pad_secs), midnight + timedelta(seconds=kxp_secs) - return None + results.append(( + midnight + timedelta(seconds=pad_secs), + midnight + timedelta(seconds=kxp_secs), + )) + if len(results) == count: + break + return results diff --git a/data/direct_to_paddington.tsv b/data/direct_to_paddington.tsv new file mode 100644 index 0000000..cbfd7a2 --- /dev/null +++ b/data/direct_to_paddington.tsv @@ -0,0 +1,129 @@ +Shenfield SNF +Brentwood BRE +Harold Wood HRO +Gidea Park GDP +Romford RMF +Chadwell Heath CTH +Goodmayes GMY +Seven Kings SVK +Ilford IFD +Manor Park MNP +Forest Gate FOG +Maryland MYL +Stratford (London) SRA +Whitechapel ZLW +London Liverpool Street LST +Farringdon ZFD +Tottenham Court Road TCR +Bond Street BDS +London Paddington PAD +Heathrow Airport Terminal 5 HWV +Heathrow Central HXX +Hayes & Harlington HAY +Southall STL +Hanwell HAN +West Ealing WEA +Ealing Broadway EAL +Acton Main Line AML +Abbey Wood ABW +Woolwich WWC +Custom House CUS +Canary Wharf CWX +Reading RDG +Twyford TWY +Maidenhead MAI +Taplow TAP +Burnham BNM +Slough SLO +Langley LNY +Iver IVR +West Drayton WDT +Oxford OXF +Didcot Parkway DID +Heathrow Airport Terminal 4 HAF +Cholsey CHO +Goring & Streatley GOR +Pangbourne PAN +Tilehurst TLH +Bristol Temple Meads BRI +Bath Spa BTH +Chippenham CPM +Swindon SWI +Hereford HFD +Ledbury LED +Colwall CWL +Great Malvern GMV +Malvern Link MVL +Worcester Foregate Street WOF +Worcester Shrub Hill WOS +Worcestershire Parkway WOP +Pershore PSH +Evesham EVE +Honeybourne HYB +Moreton-in-Marsh MIM +Kingham KGM +Charlbury CBY +Hanborough HND +Penzance PNZ +St Erth SER +Camborne CBN +Redruth RED +Truro TRU +St Austell SAU +Par PAR +Bodmin Parkway BOD +Liskeard LSK +Plymouth PLY +Totnes TOT +Newton Abbot NTA +Exeter St Davids EXD +Taunton TAU +Bedwyn BDW +Hungerford HGD +Kintbury KIT +Newbury NBY +Thatcham THA +Theale THE +Reading West RDW +Swansea SWA +Neath NTH +Port Talbot Parkway PTA +Bridgend BGN +Cardiff Central CDF +Newport (South Wales) NWP +Bristol Parkway BPW +Newbury Racecourse NRC +Midgham MDG +Aldermaston AMT +Cheltenham Spa CNM +Gloucester GCR +Stonehouse SHU +Stroud STD +Kemble KEM +Frome FRO +Westbury WSB +Pewsey PEW +Weston-super-Mare WSM +Weston Milton WNM +Worle WOR +Yatton YAT +Nailsea & Backwell NLS +Tiverton Parkway TVP +Castle Cary CLC +Bridgwater BWT +Highbridge & Burnham HIG +Carmarthen CMN +Ferryside FYS +Kidwelly KWL +Pembrey & Burry Port PBY +Llanelli LLE +Paignton PGN +Torquay TQY +Torre TRR +Teignmouth TGM +Dawlish DWL +Banbury BAN +Greenford GFD +South Greenford SGN +Castle Bar Park CBP +Drayton Green DRG diff --git a/data/pad_origins.json b/data/pad_origins.json new file mode 100644 index 0000000..11f64e3 --- /dev/null +++ b/data/pad_origins.json @@ -0,0 +1,212 @@ +{ + "Shenfield": { + "name": "Shenfield", + "crs": "", + "service_id": "G24729", + "service_date": "2026-04-20", + "processed": true + }, + "Heathrow Airport Terminal 5": { + "name": "Heathrow Airport Terminal 5", + "crs": "", + "service_id": "G24322", + "service_date": "2026-04-20", + "processed": true + }, + "Abbey Wood": { + "name": "Abbey Wood", + "crs": "", + "service_id": "G24730", + "service_date": "2026-04-20", + "processed": true + }, + "Reading": { + "name": "Reading", + "crs": "", + "service_id": "G21939", + "service_date": "2026-04-20", + "processed": true + }, + "Oxford": { + "name": "Oxford", + "crs": "", + "service_id": "P22703", + "service_date": "2026-04-20", + "processed": true + }, + "Heathrow Airport Terminal 4": { + "name": "Heathrow Airport Terminal 4", + "crs": "", + "service_id": "G21944", + "service_date": "2026-04-20", + "processed": true + }, + "Didcot Parkway": { + "name": "Didcot Parkway", + "crs": "", + "service_id": "P33203", + "service_date": "2026-04-20", + "processed": true + }, + "Bristol Temple Meads": { + "name": "Bristol Temple Meads", + "crs": "", + "service_id": "P22595", + "service_date": "2026-04-20", + "processed": true + }, + "Hereford": { + "name": "Hereford", + "crs": "", + "service_id": "P22860", + "service_date": "2026-04-20", + "processed": true + }, + "Penzance": { + "name": "Penzance", + "crs": "", + "service_id": "P29703", + "service_date": "2026-04-20", + "processed": true + }, + "Stratford (London)": { + "name": "Stratford (London)", + "crs": "", + "service_id": "G23396", + "service_date": "2026-04-21", + "processed": true + }, + "Maidenhead": { + "name": "Maidenhead", + "crs": "", + "service_id": "G23904", + "service_date": "2026-04-21", + "processed": true + }, + "Gidea Park": { + "name": "Gidea Park", + "crs": "", + "service_id": "G23407", + "service_date": "2026-04-21", + "processed": true + }, + "Bedwyn": { + "name": "Bedwyn", + "crs": "", + "service_id": "P30238", + "service_date": "2026-04-21", + "processed": true + }, + "Swansea": { + "name": "Swansea", + "crs": "", + "service_id": "P22786", + "service_date": "2026-04-21", + "processed": true + }, + "Worcester Shrub Hill": { + "name": "Worcester Shrub Hill", + "crs": "", + "service_id": "P22841", + "service_date": "2026-04-21", + "processed": true + }, + "Newbury": { + "name": "Newbury", + "crs": "", + "service_id": "P30241", + "service_date": "2026-04-21", + "processed": true + }, + "Cheltenham Spa": { + "name": "Cheltenham Spa", + "crs": "", + "service_id": "P22821", + "service_date": "2026-04-21", + "processed": true + }, + "Frome": { + "name": "Frome", + "crs": "", + "service_id": "P22597", + "service_date": "2026-04-21", + "processed": true + }, + "Weston-super-Mare": { + "name": "Weston-super-Mare", + "crs": "", + "service_id": "P22584", + "service_date": "2026-04-21", + "processed": true + }, + "Plymouth": { + "name": "Plymouth", + "crs": "", + "service_id": "P22598", + "service_date": "2026-04-21", + "processed": true + }, + "Taunton": { + "name": "Taunton", + "crs": "", + "service_id": "P29610", + "service_date": "2026-04-21", + "processed": true + }, + "Great Malvern": { + "name": "Great Malvern", + "crs": "", + "service_id": "P22844", + "service_date": "2026-04-21", + "processed": true + }, + "Carmarthen": { + "name": "Carmarthen", + "crs": "", + "service_id": "P22797", + "service_date": "2026-04-21", + "processed": true + }, + "Cardiff Central": { + "name": "Cardiff Central", + "crs": "", + "service_id": "P22798", + "service_date": "2026-04-21", + "processed": true + }, + "Paignton": { + "name": "Paignton", + "crs": "", + "service_id": "P22603", + "service_date": "2026-04-21", + "processed": true + }, + "Exeter St Davids": { + "name": "Exeter St Davids", + "crs": "", + "service_id": "P22606", + "service_date": "2026-04-21", + "processed": true + }, + "Worcester Foregate Street": { + "name": "Worcester Foregate Street", + "crs": "", + "service_id": "P22852", + "service_date": "2026-04-21", + "processed": true + }, + "Banbury": { + "name": "Banbury", + "crs": "", + "service_id": "P30629", + "service_date": "2026-04-21", + "processed": true + }, + "Greenford": { + "name": "Greenford", + "crs": "", + "service_id": "P31884", + "service_date": "2026-04-21", + "processed": true + } +} \ No newline at end of file diff --git a/scraper/gwr_fares.py b/scraper/gwr_fares.py new file mode 100644 index 0000000..9b09511 --- /dev/null +++ b/scraper/gwr_fares.py @@ -0,0 +1,125 @@ +""" +Fetch GWR walk-on single fares from any station to London Paddington. + +Uses the GWR journey search API (same API as www.gwr.com ticket search). +Returns per-train cheapest standard-class fare with restrictions already applied. +Cache for 30 days — fares rarely change. +""" + +import httpx + +_API_URL = "https://api.gwr.com/api/shopping/journeysearch" +# API key is embedded in the GWR web app (appvalues.prod.json) +_API_KEY = "OgovGqAlLp4gWAhL7DQLo7pMCt8GHi2U4SPFiZgG" +_PAD_CODE = "GBQQP" # London Paddington cluster code as used by GWR website +_WANTED_CODES = {"SSS", "SVS", "SDS"} +_MAX_PAGES = 20 + + +def _headers() -> dict: + return { + "user-agent": ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" + ), + "accept": "application/json, text/plain, */*", + "channel": "WEB", + "content-type": "application/json", + "apikey": _API_KEY, + "origin": "https://www.gwr.com", + "referer": "https://www.gwr.com/", + } + + +def _request_body( + station_crs: str, + travel_date: str, + conversation_token: str | None, + later: bool, +) -> dict: + return { + "IsNextOutward": False, + "IsPreviousOutward": False, + "IsNextReturn": False, + "IsPreviousReturn": False, + "campaignCode": "", + "validationCode": "", + "locfrom": f"GB{station_crs}", + "locto": _PAD_CODE, + "datetimedepart": f"{travel_date}T00:00:00", + "outwarddepartafter": True, + "datetimereturn": None, + "returndepartafter": False, + "directServicesOnly": False, + "firstclass": False, + "standardclass": True, + "adults": 1, + "children": 0, + "openreturn": False, + "via": None, + "avoid": None, + "isEarlierSearch": False, + "isLaterSearch": later, + "isEarlierSearchReturn": False, + "isLaterSearchReturn": False, + "railcards": [], + "conversationToken": conversation_token, + } + + +def fetch(station_crs: str, travel_date: str) -> dict[str, dict]: + """ + Fetch GWR single fares from station_crs to London Paddington on travel_date. + + Returns {departure_time: {'ticket': name, 'price': float, 'code': code}} + where price is in £ and only the cheapest available standard-class ticket + per departure (with restrictions already applied by GWR) is kept. + """ + result: dict[str, dict] = {} + + with httpx.Client(headers=_headers(), timeout=30) as client: + conversation_token = None + later = False + + for _ in range(_MAX_PAGES): + body = _request_body(station_crs, travel_date, conversation_token, later) + resp = client.post(_API_URL, json=body) + resp.raise_for_status() + data = resp.json().get("data", {}) + + conversation_token = data.get("conversationToken") + + for journey in data.get("outwardOpenPureReturnFare", []): + dep_iso = journey.get("departureTime", "") + dep_time = dep_iso[11:16] # "HH:MM" from "2026-04-10T09:08:00" + if not dep_time or dep_time in result: + continue + + cheapest = None + for fare in journey.get("journeyFareDetails", []): + code = fare.get("ticketTypeCode") + if code not in _WANTED_CODES: + continue + if not fare.get("isStandardClass"): + continue + price_pence = fare.get("fare", 0) + if cheapest is None or price_pence < cheapest["price_pence"]: + cheapest = { + "ticket": fare.get("ticketType", ""), + "price": price_pence / 100, + "price_pence": price_pence, + "code": code, + } + + if cheapest: + result[dep_time] = { + "ticket": cheapest["ticket"], + "price": cheapest["price"], + "code": cheapest["code"], + } + + if not data.get("showLaterOutward", False): + break + later = True + + return result diff --git a/scraper/realtime_trains.py b/scraper/realtime_trains.py index acd8203..9b5e936 100644 --- a/scraper/realtime_trains.py +++ b/scraper/realtime_trains.py @@ -10,14 +10,14 @@ import re import httpx import lxml.html -BRI_TO_PAD = ( +_TO_PAD_TMPL = ( "https://www.realtimetrains.co.uk/search/detailed/" - "gb-nr:BRI/to/gb-nr:PAD/{date}/0000-2359" + "gb-nr:{crs}/to/gb-nr:PAD/{date}/0000-2359" "?stp=WVS&show=pax-calls&order=wtt" ) -PAD_FROM_BRI = ( +_PAD_FROM_TMPL = ( "https://www.realtimetrains.co.uk/search/detailed/" - "gb-nr:PAD/from/gb-nr:BRI/{date}/0000-2359" + "gb-nr:PAD/from/gb-nr:{crs}/{date}/0000-2359" "?stp=WVS&show=pax-calls&order=wtt" ) @@ -68,19 +68,48 @@ def _parse_services(html: str, time_selector: str) -> dict[str, str]: return result -def fetch(date: str, user_agent: str = DEFAULT_UA) -> list[dict]: - """Fetch GWR trains; returns [{'depart_bristol', 'arrive_paddington', 'headcode'}].""" +def _parse_arrivals(html: str) -> dict[str, dict]: + """Return {train_id: {'time': ..., 'platform': ...}} from a PAD arrivals page.""" + root = lxml.html.fromstring(html) + sl = root.cssselect('div.servicelist') + if not sl: + return {} + result = {} + for svc in sl[0].cssselect('a.service'): + tid_els = svc.cssselect('div.tid') + time_els = svc.cssselect('div.time.plan.a') + if not (tid_els and time_els): + continue + time_text = time_els[0].text_content().strip() + if not time_text: + continue + plat_els = svc.cssselect('div.platform') + platform = plat_els[0].text_content().strip() if plat_els else '' + result[tid_els[0].text_content().strip()] = { + 'time': _fmt(time_text), + 'platform': platform, + } + return result + + +def fetch(date: str, user_agent: str = DEFAULT_UA, station_crs: str = 'BRI') -> list[dict]: + """Fetch trains from station_crs to PAD; returns [{'depart_bristol', 'arrive_paddington', 'headcode', 'arrive_platform'}].""" headers = _browser_headers(user_agent) with httpx.Client(headers=headers, follow_redirects=True, timeout=30) as client: - r_bri = client.get(BRI_TO_PAD.format(date=date)) - r_pad = client.get(PAD_FROM_BRI.format(date=date)) + r_bri = client.get(_TO_PAD_TMPL.format(crs=station_crs, date=date)) + r_pad = client.get(_PAD_FROM_TMPL.format(crs=station_crs, date=date)) departures = _parse_services(r_bri.text, 'div.time.plan.d') - arrivals = _parse_services(r_pad.text, 'div.time.plan.a') + arrivals = _parse_arrivals(r_pad.text) trains = [ - {'depart_bristol': dep, 'arrive_paddington': arr, 'headcode': tid} + { + 'depart_bristol': dep, + 'arrive_paddington': arrivals[tid]['time'], + 'arrive_platform': arrivals[tid]['platform'], + 'headcode': tid, + } for tid, dep in departures.items() - if (arr := arrivals.get(tid)) + if tid in arrivals ] return sorted(trains, key=lambda t: t['depart_bristol']) diff --git a/templates/base.html b/templates/base.html index 5fb61b4..aa4f77d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -43,7 +43,7 @@ } main { - max-width: 960px; + max-width: 1100px; margin: 2rem auto; padding: 0 1rem; } diff --git a/templates/index.html b/templates/index.html index 8bdbb51..8314e52 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,12 +3,13 @@

Plan your journey

-
- Departure point -
- Bristol Temple Meads - Fixed starting station for all journeys -
+
+ +
diff --git a/templates/results.html b/templates/results.html index 8389b73..183bc3e 100644 --- a/templates/results.html +++ b/templates/results.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}Bristol to {{ destination }} via Eurostar{% endblock %} -{% block og_title %}Bristol to {{ destination }} via Eurostar{% endblock %} -{% block og_description %}Train options from Bristol Temple Meads to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %} -{% block twitter_title %}Bristol to {{ destination }} via Eurostar{% endblock %} -{% block twitter_description %}Train options from Bristol Temple Meads to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %} +{% block title %}{{ departure_station_name }} to {{ destination }} via Eurostar{% endblock %} +{% block og_title %}{{ departure_station_name }} to {{ destination }} via 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_description %}Train options from {{ departure_station_name }} to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %} {% block content %}

- Bristol Temple Meads → {{ destination }} + {{ departure_station_name }} → {{ destination }}

- ← Prev {{ travel_date_display }} - Next →
@@ -30,7 +30,7 @@ {% else %} {{ destination_name }} {% endif %} {% endfor %} @@ -66,19 +66,13 @@ function applyConnectionFilter() { var min = document.getElementById('min_conn_select').value; var max = document.getElementById('max_conn_select').value; - window.location = '{{ url_for('results', slug=slug, travel_date=travel_date) }}?min_connection=' + min + '&max_connection=' + max; + window.location = '{{ url_for('results', station_crs=station_crs, slug=slug, travel_date=travel_date) }}?min_connection=' + min + '&max_connection=' + max; }

{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}  ·  {{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }} - {% if unreachable_morning_services %} -  ·  - - {{ unreachable_morning_services | length }} Eurostar service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol - - {% endif %} {% if from_cache %}  ·  (cached) {% endif %} @@ -100,7 +94,7 @@ - + @@ -141,14 +135,26 @@ + - {% endif %} @@ -217,11 +223,12 @@

Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min. - GWR walk-on single prices for Bristol Temple Meads → Paddington. + GWR walk-on single prices from + gwr.com. Eurostar Standard prices are for 1 adult in GBP; always check eurostar.com to book.  ·  - Bristol departures on RTT + {{ departure_station_name }} departures on RTT  ·  Paddington arrivals on RTT

diff --git a/tests/test_app.py b/tests/test_app.py index fd06a8d..c25a2f5 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,16 +6,21 @@ def _client(): return app_module.app.test_client() -def _stub_data(monkeypatch, prices=None): +def _stub_data(monkeypatch, prices=None, gwr_fares=None): monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) monkeypatch.setattr( app_module.rtt_scraper, 'fetch', - lambda travel_date, user_agent: [ + 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: gwr_fares or {'07:00': {'ticket': 'Anytime Day Single', 'price': 138.70, 'code': 'SDS'}}, + ) p = (prices or {}).get('10:01', {}) monkeypatch.setattr( app_module.eurostar_scraper, @@ -33,7 +38,7 @@ def _stub_data(monkeypatch, prices=None): ) -def test_index_shows_fixed_departure_and_destination_radios(): +def test_index_shows_station_dropdown_and_destination_radios(): client = _client() resp = client.get('/') @@ -42,6 +47,7 @@ def test_index_shows_fixed_departure_and_destination_radios(): assert resp.status_code == 200 assert 'Departure point' in html assert 'Bristol Temple Meads' in html + assert 'name="station_crs"' in html assert html.count('type="radio"') == len(app_module.DESTINATIONS) assert 'destination-rotterdam' in html @@ -49,11 +55,11 @@ def test_index_shows_fixed_departure_and_destination_radios(): def test_search_redirects_to_results_with_selected_params(): client = _client() - resp = client.get('/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120') + resp = client.get('/search?destination=rotterdam&travel_date=2026-04-10&min_connection=60&max_connection=120&station_crs=BRI') assert resp.status_code == 302 assert resp.headers['Location'].endswith( - '/results/rotterdam/2026-04-10?min_connection=60&max_connection=120' + '/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120' ) @@ -61,14 +67,14 @@ def test_results_shows_same_day_destination_switcher(monkeypatch): _stub_data(monkeypatch) client = _client() - resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') html = resp.get_data(as_text=True) assert resp.status_code == 200 assert 'Switch destination for Friday 10 April 2026' in html assert 'Paris Gare du Nord' in html - assert '/results/brussels/2026-04-10?min_connection=60&max_connection=120' in html - assert '/results/rotterdam/2026-04-10?min_connection=60&max_connection=120' in html + assert '/results/BRI/brussels/2026-04-10?min_connection=60&max_connection=120' in html + assert '/results/BRI/rotterdam/2026-04-10?min_connection=60&max_connection=120' in html assert 'ES 9014' in html @@ -76,26 +82,27 @@ def test_results_title_and_social_meta_include_destination(monkeypatch): _stub_data(monkeypatch) client = _client() - resp = client.get('/results/lille/2026-04-10?min_connection=60&max_connection=120') + resp = client.get('/results/BRI/lille/2026-04-10?min_connection=60&max_connection=120') html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'Bristol to Lille Europe via Eurostar' in html - assert '' in html + assert 'Bristol Temple Meads to Lille Europe via Eurostar' in html + assert '' in html assert ( '' ) in html - assert '' in html + assert '' in html def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypatch): monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {}) monkeypatch.setattr( app_module.rtt_scraper, 'fetch', - lambda travel_date, user_agent: [ + lambda travel_date, user_agent, station_crs='BRI': [ {'depart_bristol': '07:00', 'arrive_paddington': '08:30', 'headcode': '1A01'}, {'depart_bristol': '07:05', 'arrive_paddington': '08:36', 'headcode': '1A02'}, {'depart_bristol': '07:10', 'arrive_paddington': '08:46', 'headcode': '1A03'}, @@ -116,7 +123,7 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa ) client = _client() - resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') html = resp.get_data(as_text=True) assert resp.status_code == 200 @@ -130,13 +137,18 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa assert '5h 10m 🐢' not in html -def test_results_shows_unreachable_morning_eurostar_services(monkeypatch): +def test_results_shows_only_pre_first_reachable_unreachable_services(monkeypatch): + # GWR arrives 08:45; min=60 → earliest viable Eurostar 09:45; max=120 → latest 10:45. + # 09:30 too early → shown as "Too early" + # 10:15 reachable → shown as a trip (needs circle line XML, so not tested here) + # 12:30 after first reachable → hidden monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {}) monkeypatch.setattr( app_module.rtt_scraper, 'fetch', - lambda travel_date, user_agent: [ + lambda travel_date, user_agent, station_crs='BRI': [ {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, ], ) @@ -151,15 +163,13 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch): ) client = _client() - resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert '2 Eurostar services unavailable from Bristol' in html - assert '09:30' in html - assert 'ES 9001' in html + assert 'ES 9001' in html # before first reachable → shown assert 'Too early' in html - assert html.index('09:30') < html.index('10:15') + assert 'ES 9003' not in html # after first reachable → hidden def test_results_shows_eurostar_price_and_total(monkeypatch): @@ -167,7 +177,7 @@ def test_results_shows_eurostar_price_and_total(monkeypatch): _stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}}) client = _client() - resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') html = resp.get_data(as_text=True) assert resp.status_code == 200 @@ -175,13 +185,16 @@ def test_results_shows_eurostar_price_and_total(monkeypatch): assert '£197.70' in html # Anytime £138.70 + ES £59 -def test_results_can_show_only_unreachable_morning_services(monkeypatch): +def test_results_shows_unreachable_service_when_no_trips(monkeypatch): + # Only one Eurostar at 09:30; GWR arrives 08:45 with min=60 → unreachable. + # No trips at all, so the unreachable service is shown as "Too early". monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None) monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None) + monkeypatch.setattr(app_module.gwr_fares_scraper, 'fetch', lambda s, d: {}) monkeypatch.setattr( app_module.rtt_scraper, 'fetch', - lambda travel_date, user_agent: [ + lambda travel_date, user_agent, station_crs='BRI': [ {'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'}, ], ) @@ -194,11 +207,10 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch): ) client = _client() - resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120') + resp = client.get('/results/BRI/paris/2026-04-10?min_connection=60&max_connection=120') html = resp.get_data(as_text=True) assert resp.status_code == 200 - assert 'No valid journeys found.' not in html - assert '1 Eurostar service unavailable from Bristol' in html - assert '09:30' in html + assert 'ES 9001' in html assert 'Too early' in html + assert 'No valid journeys found.' not in html diff --git a/tests/test_trip_planner.py b/tests/test_trip_planner.py index a04d8d8..306d391 100644 --- a/tests/test_trip_planner.py +++ b/tests/test_trip_planner.py @@ -1,5 +1,5 @@ import pytest -from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration, cheapest_gwr_ticket +from trip_planner import combine_trips, find_unreachable_morning_eurostars, _fmt_duration DATE = '2026-03-30' @@ -23,7 +23,7 @@ def test_fmt_duration_minutes_only(): # --------------------------------------------------------------------------- GWR_FAST = {'depart_bristol': '07:00', 'arrive_paddington': '08:45'} # 1h 45m -GWR_SLOW = {'depart_bristol': '07:00', 'arrive_paddington': '09:26'} # 2h 26m — over limit +GWR_SLOW = {'depart_bristol': '07:00', 'arrive_paddington': '09:26'} # 2h 26m — connection too short for ES_PARIS ES_PARIS = {'depart_st_pancras': '10:01', 'arrive_destination': '13:34', 'destination': 'Paris Gare du Nord'} ES_EARLY = {'depart_st_pancras': '09:00', 'arrive_destination': '12:00', 'destination': 'Paris Gare du Nord'} @@ -41,7 +41,7 @@ def test_valid_trip_is_returned(): def test_gwr_too_slow_excluded(): - # 2h 26m GWR journey exceeds MAX_GWR_MINUTES (110) + # arrive 09:26, Eurostar 10:01 → 35 min connection < 50 min minimum trips = combine_trips([GWR_SLOW], [ES_PARIS], DATE) assert trips == [] @@ -148,56 +148,6 @@ def test_find_unreachable_eurostars_excludes_connectable_services(): assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30'] -# --------------------------------------------------------------------------- -# cheapest_gwr_ticket — Bristol Temple Meads → Paddington -# --------------------------------------------------------------------------- - -# 2026-03-30 is a Monday; 2026-03-28 is a Saturday - -def test_cheapest_ticket_weekday_super_off_peak_morning(): - # 05:00 on Monday: dep ≤ 05:04 → Super Off-Peak - t = cheapest_gwr_ticket('05:00', '2026-03-30') - assert t['ticket'] == 'Super Off-Peak' - assert t['price'] == 45.00 - -def test_cheapest_ticket_weekday_anytime_window(): - # 07:00 on Monday: 05:05–08:25 → Anytime only - t = cheapest_gwr_ticket('07:00', '2026-03-30') - assert t['ticket'] == 'Anytime' - assert t['price'] == 138.70 - -def test_cheapest_ticket_weekday_off_peak(): - # 08:30 on Monday: dep ≥ 08:26 but < 09:58 → Off-Peak - t = cheapest_gwr_ticket('08:30', '2026-03-30') - assert t['ticket'] == 'Off-Peak' - assert t['price'] == 63.60 - -def test_cheapest_ticket_weekday_super_off_peak_late(): - # 10:00 on Monday: dep ≥ 09:58 → Super Off-Peak - t = cheapest_gwr_ticket('10:00', '2026-03-30') - assert t['ticket'] == 'Super Off-Peak' - assert t['price'] == 45.00 - -def test_cheapest_ticket_boundary_super_off_peak_cutoff(): - # 05:04 is last valid minute for early Super Off-Peak - assert cheapest_gwr_ticket('05:04', '2026-03-30')['ticket'] == 'Super Off-Peak' - # 05:05 falls into the Anytime window (off-peak starts at 08:26) - assert cheapest_gwr_ticket('05:05', '2026-03-30')['ticket'] == 'Anytime' - -def test_cheapest_ticket_boundary_off_peak_start(): - assert cheapest_gwr_ticket('08:25', '2026-03-30')['ticket'] == 'Anytime' - assert cheapest_gwr_ticket('08:26', '2026-03-30')['ticket'] == 'Off-Peak' - -def test_cheapest_ticket_boundary_super_off_peak_resumes(): - assert cheapest_gwr_ticket('09:57', '2026-03-30')['ticket'] == 'Off-Peak' - assert cheapest_gwr_ticket('09:58', '2026-03-30')['ticket'] == 'Super Off-Peak' - -def test_cheapest_ticket_weekend_always_super_off_peak(): - # Saturday — no restrictions - t = cheapest_gwr_ticket('07:00', '2026-03-28') - assert t['ticket'] == 'Super Off-Peak' - assert t['price'] == 45.00 - def test_combine_trips_includes_ticket_fields(): trips = combine_trips([GWR_FAST], [ES_PARIS], DATE) assert len(trips) == 1 @@ -206,6 +156,18 @@ def test_combine_trips_includes_ticket_fields(): assert 'ticket_price' in t assert 'ticket_code' in t +def test_combine_trips_uses_gwr_fares_when_provided(): + fares = {'07:00': {'ticket': 'Super Off-Peak Single', 'price': 49.30, 'code': 'SSS'}} + trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares=fares) + assert len(trips) == 1 + assert trips[0]['ticket_price'] == 49.30 + assert trips[0]['ticket_code'] == 'SSS' + +def test_combine_trips_ticket_price_none_when_no_fares(): + trips = combine_trips([GWR_FAST], [ES_PARIS], DATE, gwr_fares={}) + assert len(trips) == 1 + assert trips[0]['ticket_price'] is None + def test_find_unreachable_eurostars_returns_empty_when_all_connectable(): gwr = [ diff --git a/trip_planner.py b/trip_planner.py index 5a0b718..ba7948d 100644 --- a/trip_planner.py +++ b/trip_planner.py @@ -1,71 +1,39 @@ """ -Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains. +Combine GWR station→Paddington trains with Eurostar St Pancras→destination trains. """ -from datetime import datetime, timedelta, time as _time + +from datetime import datetime, timedelta import circle_line MIN_CONNECTION_MINUTES = 50 MAX_CONNECTION_MINUTES = 110 -MAX_GWR_MINUTES = 110 -DATE_FMT = '%Y-%m-%d' -TIME_FMT = '%H:%M' +DATE_FMT = "%Y-%m-%d" +TIME_FMT = "%H:%M" -PAD_WALK_TO_UNDERGROUND_MINUTES = 7 # GWR platform → Paddington (H&C Line) platform -KX_WALK_TO_CHECKIN_MINUTES = 8 # King's Cross St Pancras platform → St Pancras check-in - - -# Bristol Temple Meads → London Paddington walk-on single fares. -# Weekday restrictions (Mon–Fri only): -# Super Off-Peak (£45.00, SSS): not valid if 05:05 ≤ dep ≤ 09:57 -# Off-Peak (£63.60, SVS): not valid if dep < 08:26, except 02:00–05:10 -# Anytime (£138.70, SDS): no restrictions -_TICKET_SUPER_OFF_PEAK = {'ticket': 'Super Off-Peak', 'price': 45.00, 'code': 'SSS'} -_TICKET_OFF_PEAK = {'ticket': 'Off-Peak', 'price': 63.60, 'code': 'SVS'} -_TICKET_ANYTIME = {'ticket': 'Anytime', 'price': 138.70, 'code': 'SDS'} - - -def cheapest_gwr_ticket(depart_time: str, travel_date: str) -> dict: - """ - Return the cheapest walk-on single for Bristol Temple Meads → Paddington. - - Weekday (Mon–Fri) restrictions derived from the SSS/SVS ticket conditions: - Super Off-Peak valid: dep ≤ 05:04 or dep ≥ 09:58 - Off-Peak valid: 02:00 ≤ dep ≤ 05:10 or dep ≥ 08:26 - Weekends: no restrictions — always Super Off-Peak. - """ - dt = datetime.strptime(f"{travel_date} {depart_time}", f"{DATE_FMT} {TIME_FMT}") - if dt.weekday() >= 5: # Saturday or Sunday - return _TICKET_SUPER_OFF_PEAK - - dep = dt.time() - if dep <= _time(5, 4) or dep >= _time(9, 58): - return _TICKET_SUPER_OFF_PEAK - if dep >= _time(8, 26): - return _TICKET_OFF_PEAK - return _TICKET_ANYTIME +PAD_WALK_TO_UNDERGROUND_MINUTES = 8 # GWR platform → Paddington (H&C Line) platform def _parse_dt(date: str, time: str) -> datetime: return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}") -def _circle_line_times(arrive_paddington: datetime) -> tuple[str, str] | tuple[None, None]: +def _circle_line_services(arrive_paddington: datetime) -> list[dict]: """ - Given GWR arrival at Paddington, return (circle_line_depart_str, arrive_checkin_str). + Given GWR arrival at Paddington, return up to 2 upcoming Circle line services + as [{'depart': 'HH:MM', 'arrive_kx': 'HH:MM'}, ...]. - Adds PAD_WALK_TO_UNDERGROUND_MINUTES to get the earliest boarding time, looks up - the next real Circle Line service from the timetable, then adds KX_WALK_TO_CHECKIN_MINUTES - to the Kings Cross arrival to give an estimated St Pancras check-in time. - Returns (None, None) if no service is found. + Each entry gives the departure from Paddington (H&C Line) and the actual + arrival at King's Cross St Pancras underground station. """ - earliest_board = arrive_paddington + timedelta(minutes=PAD_WALK_TO_UNDERGROUND_MINUTES) - result = circle_line.next_service(earliest_board) - if result is None: - return None, None - circle_depart, arrive_kx = result - arrive_checkin = arrive_kx + timedelta(minutes=KX_WALK_TO_CHECKIN_MINUTES) - return circle_depart.strftime(TIME_FMT), arrive_checkin.strftime(TIME_FMT) + earliest_board = arrive_paddington + timedelta( + minutes=PAD_WALK_TO_UNDERGROUND_MINUTES + ) + services = circle_line.upcoming_services(earliest_board, count=2) + return [ + {"depart": dep.strftime(TIME_FMT), "arrive_kx": arr.strftime(TIME_FMT)} + for dep, arr in services + ] def _fmt_duration(minutes: int) -> str: @@ -85,15 +53,15 @@ def _is_viable_connection( max_connection_minutes: int, ) -> tuple[datetime, datetime, datetime, datetime] | None: try: - arr_pad = _parse_dt(travel_date, gwr['arrive_paddington']) - dep_bri = _parse_dt(travel_date, gwr['depart_bristol']) - dep_stp = _parse_dt(travel_date, eurostar['depart_st_pancras']) - arr_dest = _parse_dt(travel_date, eurostar['arrive_destination']) + arr_pad = _parse_dt(travel_date, gwr["arrive_paddington"]) + dep_bri = _parse_dt(travel_date, gwr["depart_bristol"]) + dep_stp = _parse_dt(travel_date, eurostar["depart_st_pancras"]) + arr_dest = _parse_dt(travel_date, eurostar["arrive_destination"]) except (ValueError, KeyError): return None - if int((arr_pad - dep_bri).total_seconds() / 60) > MAX_GWR_MINUTES: - return None + if arr_pad < dep_bri: + arr_pad += timedelta(days=1) if arr_dest < dep_stp: arr_dest += timedelta(days=1) @@ -113,6 +81,7 @@ def combine_trips( travel_date: str, min_connection_minutes: int = MIN_CONNECTION_MINUTES, max_connection_minutes: int = MAX_CONNECTION_MINUTES, + gwr_fares: dict | None = None, ) -> list[dict]: """ Return a list of valid combined trips, sorted by Bristol departure time. @@ -146,31 +115,37 @@ def combine_trips( total_mins = int((arr_dest - dep_bri).total_seconds() / 60) # Destination time is CET/CEST, departure is GMT/BST; Europe is always 1h ahead. eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60 - ticket = cheapest_gwr_ticket(gwr['depart_bristol'], travel_date) - circle_depart, arrive_checkin = _circle_line_times(arr_pad) - trips.append({ - 'depart_bristol': gwr['depart_bristol'], - 'arrive_paddington': gwr['arrive_paddington'], - 'headcode': gwr.get('headcode', ''), - 'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)), - 'connection_minutes': int((dep_stp - arr_pad).total_seconds() / 60), - 'connection_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)), - 'circle_line_depart': circle_depart, - 'circle_arrive_checkin': arrive_checkin, - 'depart_st_pancras': es['depart_st_pancras'], - 'arrive_destination': es['arrive_destination'], - 'eurostar_duration': _fmt_duration(eurostar_mins), - 'train_number': es.get('train_number', ''), - 'total_duration': _fmt_duration(total_mins), - 'total_minutes': total_mins, - 'destination': es['destination'], - 'ticket_name': ticket['ticket'], - 'ticket_price': ticket['price'], - 'ticket_code': ticket['code'], - }) + fare = (gwr_fares or {}).get(gwr["depart_bristol"]) + circle_svcs = _circle_line_services(arr_pad) + trips.append( + { + "depart_bristol": gwr["depart_bristol"], + "arrive_paddington": gwr["arrive_paddington"], + "arrive_platform": gwr.get("arrive_platform", ""), + "headcode": gwr.get("headcode", ""), + "gwr_duration": _fmt_duration( + int((arr_pad - dep_bri).total_seconds() / 60) + ), + "connection_minutes": int((dep_stp - arr_pad).total_seconds() / 60), + "connection_duration": _fmt_duration( + int((dep_stp - arr_pad).total_seconds() / 60) + ), + "circle_services": circle_svcs, + "depart_st_pancras": es["depart_st_pancras"], + "arrive_destination": es["arrive_destination"], + "eurostar_duration": _fmt_duration(eurostar_mins), + "train_number": es.get("train_number", ""), + "total_duration": _fmt_duration(total_mins), + "total_minutes": total_mins, + "destination": es["destination"], + "ticket_name": fare["ticket"] if fare else None, + "ticket_price": fare["price"] if fare else None, + "ticket_code": fare["code"] if fare else None, + } + ) break # Only the earliest valid Eurostar per GWR departure - trips.sort(key=lambda t: (t['depart_bristol'], t['depart_st_pancras'])) + trips.sort(key=lambda t: (t["depart_bristol"], t["depart_st_pancras"])) return trips @@ -196,11 +171,11 @@ def find_unreachable_morning_eurostars( ): continue - dep_stp = _parse_dt(travel_date, es['depart_st_pancras']) - arr_dest = _parse_dt(travel_date, es['arrive_destination']) + dep_stp = _parse_dt(travel_date, es["depart_st_pancras"]) + arr_dest = _parse_dt(travel_date, es["arrive_destination"]) if arr_dest < dep_stp: arr_dest += timedelta(days=1) eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60 - unreachable.append({**es, 'eurostar_duration': _fmt_duration(eurostar_mins)}) + 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"])
Bristol{{ departure_station_name }} Paddington GWR Fare Transfer {{ row.arrive_paddington }} ({{ row.gwr_duration }}) + {% if row.arrive_platform %}
Plat {{ row.arrive_platform }}{% endif %}
- £{{ "%.2f"|format(row.ticket_price) }} -
{{ row.ticket_name }} + {% if row.ticket_price is not none %} + £{{ "%.2f"|format(row.ticket_price) }} +
{{ row.ticket_name }} + {% else %} + + {% endif %}
{{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %} -
Circle {{ row.circle_line_depart }} → STP {{ row.circle_arrive_checkin }} + {% if row.circle_services %} + {% set c = row.circle_services[0] %} +
Circle {{ c.depart }} → KX {{ c.arrive_kx }} + {% if row.circle_services | length > 1 %} + {% set c2 = row.circle_services[1] %} +
next {{ c2.depart }} → KX {{ c2.arrive_kx }} + {% endif %} + {% endif %}
{{ row.depart_st_pancras }} @@ -184,8 +190,8 @@ {% else %} n/a {{ row.depart_st_pancras }} {% if row.train_number %}
{% for part in row.train_number.split(' + ') %}{{ part }}{% if not loop.last %} + {% endif %}{% endfor %}{% endif %} @@ -206,7 +212,7 @@ {% endif %}
- Too early + Too early