Add multi-station support, GWR fares API, and Circle line improvements
- Support any station with direct trains to Paddington; station CRS code is now part of the URL (/results/<crs>/<slug>/<date>) - Load station list from data/direct_to_paddington.tsv; show dropdown on index page; 404 for unknown station codes - Fetch live GWR walk-on fares via api.gwr.com for all stations (SSS/SVS/SDS with restrictions already applied per train); cache 30 days - Scrape Paddington arrival platform numbers from RTT - Show unreachable morning Eurostars (before first reachable service only) - Circle line: show actual KX St Pancras arrival times (not check-in estimate) and add a second backup service in the transfer column - Widen page max-width to 1100px for longer station names Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
71be0dd8cf
commit
3c787b33d3
12 changed files with 810 additions and 262 deletions
183
app.py
183
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/<slug>/<travel_date>')
|
||||
def results(slug, travel_date):
|
||||
@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'))
|
||||
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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue