""" Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains. """ from flask import Flask, render_template, redirect, url_for, request, abort, jsonify, Response, stream_with_context from datetime import date, timedelta from pathlib import Path import json 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} VALID_NR_CLASSES = {'walkon', 'advance_std', 'advance_1st'} VALID_ES_CLASSES = {'standard', 'plus'} DEFAULT_NR_CLASS = 'walkon' DEFAULT_ES_CLASS = 'standard' 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 ) nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS) if nr_class not in VALID_NR_CLASSES: nr_class = DEFAULT_NR_CLASS es_class = request.args.get("es_class", DEFAULT_ES_CLASS) if es_class not in VALID_ES_CLASSES: es_class = DEFAULT_ES_CLASS 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, nr_class=None if nr_class == DEFAULT_NR_CLASS else nr_class, es_class=None if es_class == DEFAULT_ES_CLASS else es_class, ) ) 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 ) nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS) if nr_class not in VALID_NR_CLASSES: nr_class = DEFAULT_NR_CLASS es_class = request.args.get("es_class", DEFAULT_ES_CLASS) if es_class not in VALID_ES_CLASSES: 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) 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") 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 ] 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 url_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class url_es = None if es_class == DEFAULT_ES_CLASS else es_class # Build per-row fare data for JS consumption trip_fares = {} for row in result_rows: stp = row.get("depart_st_pancras") if not stp: continue circle_svcs = row.get("circle_services") or [] circle_fare = circle_svcs[0]["fare"] if circle_svcs else 0 walkon = ( {"price": row["ticket_price"], "ticket": row.get("ticket_name", "")} if row.get("ticket_price") is not None else None ) es_std = ( {"price": row["eurostar_price"], "seats": row.get("eurostar_seats")} if row.get("eurostar_price") is not None else None ) es_plus = ( {"price": row["eurostar_plus_price"], "seats": row.get("eurostar_plus_seats")} if row.get("eurostar_plus_price") is not None else None ) trip_fares[stp] = { "depart_bristol": row.get("depart_bristol"), "walkon": walkon, "es_standard": es_std, "es_plus": es_plus, "circle_fare": circle_fare, } 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, nr_class=nr_class, es_class=es_class, url_nr_class=url_nr, url_es_class=url_es, trip_fares_json=json.dumps(trip_fares), advance_fares_json=json.dumps(cached_advance_fares), 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), 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 @app.route("/api/advance_fares_stream//") def api_advance_fares_stream(station_crs, travel_date): if station_crs not in STATION_BY_CRS: abort(404) cache_key = f"gwr_advance_{station_crs}_{travel_date}" def generate(): cached = get_cached(cache_key, ttl=24 * 3600) if cached is not None: yield f"data: {json.dumps({'type': 'fares', 'fares': cached})}\n\n" yield f"data: {json.dumps({'type': 'done'})}\n\n" return accumulated: dict = {} try: for page_fares in gwr_fares_scraper.fetch_advance_streaming(station_crs, travel_date): for dep_time, fare_data in page_fares.items(): if dep_time not in accumulated: accumulated[dep_time] = {"advance_std": None, "advance_1st": None} if fare_data.get("advance_std"): accumulated[dep_time]["advance_std"] = fare_data["advance_std"] if fare_data.get("advance_1st"): accumulated[dep_time]["advance_1st"] = fare_data["advance_1st"] yield f"data: {json.dumps({'type': 'fares', 'fares': page_fares})}\n\n" except Exception as e: yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" return set_cached(cache_key, accumulated) yield f"data: {json.dumps({'type': 'done'})}\n\n" return Response( stream_with_context(generate()), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) if __name__ == "__main__": app.run(debug=True, host="0.0.0.0")