bristol-eurostar/app.py
Edward Betts 05eec29b7d Show Eurostar seat availability and no-prices notice
fetch_prices now returns {'price': ..., 'seats': ...} per departure.
Seat count (labelled "N at this price") is shown below the fare — it
reflects price-band depth rather than total remaining seats. A yellow
notice is shown when the API returns journeys but all prices are null
(tickets not yet on sale).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:12:54 +01:00

217 lines
7.4 KiB
Python

"""
Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains.
"""
from flask import Flask, render_template, redirect, url_for, request
from datetime import date, timedelta
import os
from cache import get_cached, set_cached
import scraper.eurostar as eurostar_scraper
import scraper.realtime_trains as rtt_scraper
from trip_planner import combine_trips, find_unreachable_morning_eurostars
from scraper.eurostar import fetch_prices as fetch_eurostar_prices
RTT_PADDINGTON_URL = (
"https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:PAD/from/gb-nr:BRI/{date}/0000-2359"
"?stp=WVS&show=pax-calls&order=wtt"
)
RTT_BRISTOL_URL = (
"https://www.realtimetrains.co.uk/search/detailed/"
"gb-nr:BRI/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']
DESTINATIONS = {
'paris': 'Paris Gare du Nord',
'brussels': 'Brussels Midi',
'lille': 'Lille Europe',
'amsterdam': 'Amsterdam Centraal',
'rotterdam': 'Rotterdam Centraal',
}
@app.route('/')
def index():
today = date.today().isoformat()
return render_template(
'index.html',
destinations=DESTINATIONS,
today=today,
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', '')
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', 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):
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_{travel_date}"
es_cache_key = f"eurostar_{travel_date}_{destination}"
prices_cache_key = f"eurostar_prices_{travel_date}_{destination}"
cached_rtt = get_cached(rtt_cache_key)
cached_es = get_cached(es_cache_key)
cached_prices = get_cached(prices_cache_key, ttl=24 * 3600)
from_cache = bool(cached_rtt and cached_es and cached_prices)
error = None
if cached_rtt:
gwr_trains = cached_rtt
else:
try:
gwr_trains = rtt_scraper.fetch(travel_date, user_agent)
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_trains = cached_es
else:
try:
eurostar_trains = eurostar_scraper.fetch(destination, travel_date, user_agent)
set_cached(es_cache_key, eurostar_trains)
except Exception as e:
eurostar_trains = []
msg = f"Could not fetch Eurostar times: {e}"
error = f"{error}; {msg}" if error else msg
if cached_prices:
eurostar_prices = cached_prices
else:
try:
eurostar_prices = fetch_eurostar_prices(destination, travel_date)
set_cached(prices_cache_key, eurostar_prices)
except Exception as e:
eurostar_prices = {}
msg = f"Could not fetch Eurostar prices: {e}"
error = f"{error}; {msg}" if error else msg
trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection)
# 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
# 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')
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(date=travel_date)
rtt_bristol_url = RTT_BRISTOL_URL.format(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,
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_bristol_url=rtt_bristol_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)