""" Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains. """ from flask import Flask, render_template, redirect, url_for, request, abort, jsonify 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() default_min, default_max = _get_defaults() return render_template( "index.html", destinations=DESTINATIONS, today=today, stations=STATIONS, default_min_connection=default_min, default_max_connection=default_max, 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} def _get_defaults(): return ( app.config["DEFAULT_MIN_CONNECTION"], app.config["DEFAULT_MAX_CONNECTION"], ) def _parse_connection(raw, default, valid_set): try: val = int(raw) except (TypeError, ValueError): return default return val if val in valid_set else default @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" default_min, default_max = _get_defaults() min_conn = _parse_connection( request.args.get("min_connection"), default_min, VALID_MIN_CONNECTIONS ) max_conn = _parse_connection( request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS ) if slug in DESTINATIONS and travel_date: return redirect( url_for( "results", station_crs=station_crs, slug=slug, travel_date=travel_date, min_connection=None if min_conn == default_min else min_conn, max_connection=None if max_conn == default_max else 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")) default_min, default_max = _get_defaults() min_connection = _parse_connection( request.args.get("min_connection"), default_min, VALID_MIN_CONNECTIONS ) max_connection = _parse_connection( request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS ) # Redirect to clean URL when both params are at their defaults if ( "min_connection" in request.args or "max_connection" in request.args ) and min_connection == default_min and max_connection == default_max: 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) 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}" gwr_advance_cache_key = f"gwr_advance_{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) 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: 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"), "plus_price": s.get("plus_price"), "plus_seats": s.get("plus_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 prices and total cost (walk-on + standard) 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["eurostar_plus_price"] = es.get("plus_price") trip["eurostar_plus_seats"] = es.get("plus_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") 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 ] 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) url_min = None if min_connection == default_min else min_connection url_max = None if max_connection == default_max else max_connection 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, default_min_connection=default_min, default_max_connection=default_max, url_min_connection=url_min, url_max_connection=url_max, cached_advance_fares=cached_advance_fares, valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_max_connections=sorted(VALID_MAX_CONNECTIONS), ) @app.route("/api/advance_fares//") def api_advance_fares(station_crs, travel_date): if station_crs not in STATION_BY_CRS: abort(404) cache_key = f"gwr_advance_{station_crs}_{travel_date}" cached = get_cached(cache_key, ttl=24 * 3600) if cached is not None: return jsonify(cached) try: fares = gwr_fares_scraper.fetch_advance(station_crs, travel_date) set_cached(cache_key, fares) return jsonify(fares) except Exception as e: return jsonify({"error": str(e)}), 500 if __name__ == "__main__": app.run(debug=True, host="0.0.0.0")