Add tfl_fare.py with circle_line_fare() which returns £3.10 (peak) or £3.00 (off-peak) based on TfL Zone 1 pricing. Peak applies Monday–Friday (excluding England public holidays) 06:30–09:30 and 16:00–19:00. Annotate each circle service with its fare in trip_planner.py, display it alongside the Circle line times in the Transfer column, and include it in the journey Total. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342 lines
11 KiB
Python
342 lines
11 KiB
Python
"""
|
|
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/<station_crs>/<slug>/<travel_date>")
|
|
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")
|
|
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
|
|
|
|
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/<station_crs>/<travel_date>")
|
|
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")
|