""" Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains. """ 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:{crs}/{date}/0000-2359" "?stp=WVS&show=pax-calls&order=wtt" ) RTT_STATION_URL = ( "https://www.realtimetrains.co.uk/search/detailed/" "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") 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"] 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", } @app.route("/") def index(): today = date.today().isoformat() return render_template( "index.html", destinations=DESTINATIONS, today=today, stations=STATIONS, valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_max_connections=sorted(VALID_MAX_CONNECTIONS), ) 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") def search(): 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)) 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)) 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", 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(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")) try: 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)) 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) 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 if cached_rtt: gwr_trains = cached_rtt else: try: gwr_trains = rtt_scraper.fetch(travel_date, user_agent, station_crs) set_cached(rtt_cache_key, gwr_trains) except Exception as e: gwr_trains = [] error = f"Could not fetch GWR trains: {e}" if cached_es: eurostar_services = cached_es else: try: eurostar_services = eurostar_scraper.fetch(destination, travel_date) set_cached(es_cache_key, eurostar_services) except Exception as e: eurostar_services = [] 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")} for s in eurostar_services } 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") 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." ) 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") # 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"], ) 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") eurostar_url = eurostar_scraper.search_url(destination, 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", trips=trips, result_rows=result_rows, unreachable_morning_services=unreachable_morning_services, destinations=DESTINATIONS, 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, gwr_count=len(gwr_trains), eurostar_count=len(eurostar_trains), from_cache=from_cache, error=error, no_prices_note=no_prices_note, eurostar_url=eurostar_url, rtt_url=rtt_url, rtt_station_url=rtt_station_url, min_connection=min_connection, max_connection=max_connection, valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_max_connections=sorted(VALID_MAX_CONNECTIONS), ) if __name__ == "__main__": app.run(debug=True, host="0.0.0.0")