Fetches prices via the site-api.eurostar.com GraphQL gateway (NewBookingSearch operation, discovered with Playwright). Adds fetch_prices() to scraper/eurostar.py using requests, caches results, annotates each trip with eurostar_price and total_price, and shows an ES Std column plus total cost (duration + price) in the results table. The Transfer column is hidden on small screens for mobile usability. Closes #4 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
6.2 KiB
Python
186 lines
6.2 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
|
|
|
|
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"
|
|
)
|
|
|
|
app = Flask(__name__)
|
|
|
|
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 = {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)
|
|
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 and total cost
|
|
for trip in trips:
|
|
es_price = eurostar_prices.get(trip['depart_st_pancras'])
|
|
trip['eurostar_price'] = es_price
|
|
if es_price is not None:
|
|
trip['total_price'] = trip['ticket_price'] + es_price
|
|
else:
|
|
trip['total_price'] = None
|
|
|
|
unreachable_morning_services = find_unreachable_morning_eurostars(
|
|
gwr_trains,
|
|
eurostar_trains,
|
|
travel_date,
|
|
min_connection,
|
|
max_connection,
|
|
)
|
|
for svc in unreachable_morning_services:
|
|
svc['eurostar_price'] = eurostar_prices.get(svc['depart_st_pancras'])
|
|
|
|
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.timetable_url(destination) + f"?date={travel_date}"
|
|
rtt_url = RTT_PADDINGTON_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,
|
|
eurostar_url=eurostar_url,
|
|
rtt_url=rtt_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)
|