Compare commits
No commits in common. "71be0dd8cf867312e6cd4a21e93588309c5c684d" and "cdee44ea3f34919e2a2fb7c730276e8f69f1f5ac" have entirely different histories.
71be0dd8cf
...
cdee44ea3f
14 changed files with 418 additions and 626 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,7 +12,6 @@ venv/
|
|||
|
||||
# App
|
||||
cache/
|
||||
config/local.py
|
||||
|
||||
# Pytest
|
||||
.pytest_cache/
|
||||
|
|
|
|||
46
README.md
46
README.md
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
Source: https://git.4angle.com/edward/bristol-eurostar
|
||||
|
||||
Plan a trip from Bristol Temple Meads to Europe via Eurostar.
|
||||
Plan a trip from Bristol Temple Meads to Europe on Eurostar.
|
||||
|
||||
Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras → destination) and shows all valid same-day connections, including Circle Line times for the Paddington → St Pancras transfer. Displays GWR walk-on fares, Eurostar Standard prices, seat availability, and total journey cost.
|
||||
Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras → destination) and shows all valid same-day connections, filtering by journey time and minimum/maximum transfer window at Paddington/St Pancras.
|
||||
|
||||
## Destinations
|
||||
|
||||
|
|
@ -12,42 +12,23 @@ Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras
|
|||
- Brussels Midi
|
||||
- Lille Europe
|
||||
- Amsterdam Centraal
|
||||
- Rotterdam Centraal
|
||||
- Cologne Hbf
|
||||
|
||||
## How it works
|
||||
|
||||
Train times and prices are fetched from two sources:
|
||||
Train times are fetched from two sources simultaneously:
|
||||
|
||||
- **GWR** — scraped from [Realtime Trains](https://www.realtimetrains.co.uk/) using httpx
|
||||
- **Eurostar** — fetched from the Eurostar GraphQL API (single call returns timetable, Standard fares, and seat availability)
|
||||
|
||||
The Paddington → St Pancras transfer uses a real Circle Line timetable parsed from a TfL TransXChange XML file, accounting for walk time to the platform at Paddington and walk time from the platform to the St Pancras check-in.
|
||||
- **Eurostar** — scraped from the Eurostar timetable pages via the embedded `__NEXT_DATA__` JSON (no browser required)
|
||||
|
||||
Results are cached to disk by date and destination.
|
||||
|
||||
## Connection constraints
|
||||
|
||||
Configurable via the search form. Defaults:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Minimum Paddington → St Pancras | 50 min |
|
||||
| Maximum Paddington → St Pancras | 110 min |
|
||||
|
||||
Valid range: 45–120 min (min), 60–180 min (max).
|
||||
|
||||
## GWR fares
|
||||
|
||||
Walk-on single fares for Bristol Temple Meads → Paddington, selected automatically by departure time:
|
||||
|
||||
| Ticket | Price | Restriction (weekdays) |
|
||||
|---|---|---|
|
||||
| Super Off-Peak | £45.00 | Not valid 05:05–09:57 |
|
||||
| Off-Peak | £63.60 | Not valid before 08:26 |
|
||||
| Anytime | £138.70 | No restriction |
|
||||
|
||||
Weekends always use Super Off-Peak.
|
||||
| Minimum Paddington → St Pancras | 75 min |
|
||||
| Maximum Paddington → St Pancras | 2h 20m |
|
||||
| Maximum Bristol → Paddington | 1h 50m |
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -55,19 +36,6 @@ Weekends always use Super Off-Peak.
|
|||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Copy or create `config/local.py` (gitignored) to override defaults:
|
||||
|
||||
```python
|
||||
CACHE_DIR = '/var/cache/bristol-eurostar'
|
||||
CIRCLE_LINE_XML = '/path/to/output_txc_01CIR_.xml'
|
||||
```
|
||||
|
||||
Defaults (in `config/default.py`) use `~/lib/data/tfl/`.
|
||||
|
||||
The Circle Line XML is a TfL TransXChange timetable file. The Paddington (H&C Line) stop is `9400ZZLUPAH1`; the King's Cross St Pancras stop is `9400ZZLUKSX3`.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
|
|
|
|||
64
app.py
64
app.py
|
|
@ -3,12 +3,12 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination t
|
|||
"""
|
||||
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/"
|
||||
|
|
@ -22,16 +22,7 @@ RTT_BRISTOL_URL = (
|
|||
"?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']
|
||||
app = Flask(__name__)
|
||||
|
||||
DESTINATIONS = {
|
||||
'paris': 'Paris Gare du Nord',
|
||||
|
|
@ -39,7 +30,6 @@ DESTINATIONS = {
|
|||
'lille': 'Lille Europe',
|
||||
'amsterdam': 'Amsterdam Centraal',
|
||||
'rotterdam': 'Rotterdam Centraal',
|
||||
'cologne': 'Cologne Hbf',
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -103,10 +93,12 @@ def results(slug, travel_date):
|
|||
|
||||
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, ttl=24 * 3600)
|
||||
from_cache = bool(cached_rtt and cached_es)
|
||||
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
|
||||
|
||||
|
|
@ -121,36 +113,37 @@ def results(slug, travel_date):
|
|||
error = f"Could not fetch GWR trains: {e}"
|
||||
|
||||
if cached_es:
|
||||
eurostar_services = cached_es
|
||||
eurostar_trains = cached_es
|
||||
else:
|
||||
try:
|
||||
eurostar_services = eurostar_scraper.fetch(destination, travel_date)
|
||||
set_cached(es_cache_key, eurostar_services)
|
||||
eurostar_trains = eurostar_scraper.fetch(destination, travel_date, user_agent)
|
||||
set_cached(es_cache_key, eurostar_trains)
|
||||
except Exception as e:
|
||||
eurostar_services = []
|
||||
eurostar_trains = []
|
||||
msg = f"Could not fetch Eurostar times: {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')}
|
||||
for s in eurostar_services
|
||||
}
|
||||
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
|
||||
# Annotate each trip with Eurostar Standard price and total cost
|
||||
for trip in trips:
|
||||
es = eurostar_prices.get(trip['depart_st_pancras'], {})
|
||||
es_price = es.get('price')
|
||||
es_price = eurostar_prices.get(trip['depart_st_pancras'])
|
||||
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.'
|
||||
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,
|
||||
|
|
@ -160,9 +153,7 @@ 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')
|
||||
svc['eurostar_price'] = eurostar_prices.get(svc['depart_st_pancras'])
|
||||
|
||||
result_rows = sorted(
|
||||
[{'row_type': 'trip', **trip} for trip in trips]
|
||||
|
|
@ -195,7 +186,6 @@ def results(slug, travel_date):
|
|||
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,
|
||||
|
|
|
|||
2
cache.py
2
cache.py
|
|
@ -2,7 +2,7 @@ import json
|
|||
import os
|
||||
import time
|
||||
|
||||
from config.default import CACHE_DIR # overridden by app config after import
|
||||
CACHE_DIR = os.path.join(os.path.dirname(__file__), 'cache')
|
||||
|
||||
|
||||
def _cache_path(key: str) -> str:
|
||||
|
|
|
|||
148
circle_line.py
148
circle_line.py
|
|
@ -1,148 +0,0 @@
|
|||
"""
|
||||
Circle Line timetable: Paddington (H&C Line) → King's Cross St Pancras.
|
||||
|
||||
Parses the TransXChange XML file on first use and caches the result in memory.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
_PAD_STOP = '9400ZZLUPAH1' # Paddington (H&C Line)
|
||||
_KXP_STOP = '9400ZZLUKSX3' # King's Cross St Pancras
|
||||
|
||||
from config.default import CIRCLE_LINE_XML as _TXC_XML # overridden by app config after import
|
||||
_NS = {'t': 'http://www.transxchange.org.uk/'}
|
||||
|
||||
# Populated on first call to next_service(); maps day-type -> sorted list of
|
||||
# (pad_depart_seconds, kxp_arrive_seconds) measured from midnight.
|
||||
_timetable: dict[str, list[tuple[int, int]]] | None = None
|
||||
|
||||
|
||||
def _parse_duration(s: str | None) -> int:
|
||||
if not s:
|
||||
return 0
|
||||
m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', s)
|
||||
return int(m.group(1) or 0) * 3600 + int(m.group(2) or 0) * 60 + int(m.group(3) or 0)
|
||||
|
||||
|
||||
def _load_timetable() -> dict[str, list[tuple[int, int]]]:
|
||||
tree = ET.parse(_TXC_XML)
|
||||
root = tree.getroot()
|
||||
|
||||
# Build JPS id -> [(from_stop, to_stop, runtime_secs, wait_secs)]
|
||||
jps_map: dict[str, list[tuple]] = {}
|
||||
for jps_el in root.find('t:JourneyPatternSections', _NS):
|
||||
links = []
|
||||
for link in jps_el.findall('t:JourneyPatternTimingLink', _NS):
|
||||
fr = link.find('t:From/t:StopPointRef', _NS)
|
||||
to = link.find('t:To/t:StopPointRef', _NS)
|
||||
rt = link.find('t:RunTime', _NS)
|
||||
wait = link.find('t:From/t:WaitTime', _NS)
|
||||
links.append((
|
||||
fr.text if fr is not None else None,
|
||||
to.text if to is not None else None,
|
||||
_parse_duration(rt.text if rt is not None else None),
|
||||
_parse_duration(wait.text if wait is not None else None),
|
||||
))
|
||||
jps_map[jps_el.get('id')] = links
|
||||
|
||||
def _seconds_to_depart(links, stop):
|
||||
"""Seconds from journey start until departure from *stop*."""
|
||||
elapsed = 0
|
||||
for fr, to, rt, wait in links:
|
||||
elapsed += wait
|
||||
if fr == stop:
|
||||
return elapsed
|
||||
elapsed += rt
|
||||
return None
|
||||
|
||||
def _seconds_to_arrive(links, stop):
|
||||
"""Seconds from journey start until arrival at *stop*."""
|
||||
elapsed = 0
|
||||
for fr, to, rt, wait in links:
|
||||
elapsed += wait + rt
|
||||
if to == stop:
|
||||
return elapsed
|
||||
return None
|
||||
|
||||
# Map JP id -> (pad_offset_secs, kxp_arrive_offset_secs)
|
||||
jp_offsets: dict[str, tuple[int, int]] = {}
|
||||
for svc in root.find('t:Services', _NS):
|
||||
for jp in svc.findall('.//t:JourneyPattern', _NS):
|
||||
jps_ref = jp.find('t:JourneyPatternSectionRefs', _NS)
|
||||
if jps_ref is None:
|
||||
continue
|
||||
links = jps_map.get(jps_ref.text, [])
|
||||
stops = [l[0] for l in links] + ([links[-1][1]] if links else [])
|
||||
if (
|
||||
_PAD_STOP in stops
|
||||
and _KXP_STOP in stops
|
||||
and stops.index(_PAD_STOP) < stops.index(_KXP_STOP)
|
||||
):
|
||||
pad_off = _seconds_to_depart(links, _PAD_STOP)
|
||||
kxp_off = _seconds_to_arrive(links, _KXP_STOP)
|
||||
if pad_off is not None and kxp_off is not None:
|
||||
jp_offsets[jp.get('id')] = (pad_off, kxp_off)
|
||||
|
||||
result: dict[str, list[tuple[int, int]]] = {
|
||||
'MondayToFriday': [],
|
||||
'Saturday': [],
|
||||
'Sunday': [],
|
||||
}
|
||||
|
||||
for vj in root.find('t:VehicleJourneys', _NS):
|
||||
jp_ref = vj.find('t:JourneyPatternRef', _NS)
|
||||
dep_time = vj.find('t:DepartureTime', _NS)
|
||||
op = vj.find('t:OperatingProfile', _NS)
|
||||
if jp_ref is None or dep_time is None or jp_ref.text not in jp_offsets:
|
||||
continue
|
||||
pad_off, kxp_off = jp_offsets[jp_ref.text]
|
||||
h, m, s = map(int, dep_time.text.split(':'))
|
||||
dep_secs = h * 3600 + m * 60 + s
|
||||
rdt = op.find('.//t:DaysOfWeek', _NS) if op is not None else None
|
||||
if rdt is None:
|
||||
continue
|
||||
for day_el in rdt:
|
||||
day_type = day_el.tag.split('}')[-1]
|
||||
if day_type in result:
|
||||
result[day_type].append((dep_secs + pad_off, dep_secs + kxp_off))
|
||||
|
||||
for key in result:
|
||||
result[key].sort()
|
||||
return result
|
||||
|
||||
|
||||
def _get_timetable() -> dict[str, list[tuple[int, int]]]:
|
||||
global _timetable
|
||||
if _timetable is None:
|
||||
_timetable = _load_timetable()
|
||||
return _timetable
|
||||
|
||||
|
||||
def _day_type(weekday: int) -> str:
|
||||
if weekday < 5:
|
||||
return 'MondayToFriday'
|
||||
return 'Saturday' if weekday == 5 else 'Sunday'
|
||||
|
||||
|
||||
def next_service(earliest_board: datetime) -> tuple[datetime, datetime] | None:
|
||||
"""
|
||||
Given the earliest time a passenger can board at Paddington (H&C Line),
|
||||
return (circle_line_depart, arrive_kings_cross) as datetimes, or None if
|
||||
no service is found before midnight.
|
||||
|
||||
The caller is responsible for adding any walk time from the GWR platform
|
||||
before passing *earliest_board*.
|
||||
"""
|
||||
timetable = _get_timetable()[_day_type(earliest_board.weekday())]
|
||||
board_secs = (
|
||||
earliest_board.hour * 3600
|
||||
+ earliest_board.minute * 60
|
||||
+ earliest_board.second
|
||||
)
|
||||
midnight = earliest_board.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
for pad_secs, kxp_secs in timetable:
|
||||
if pad_secs >= board_secs:
|
||||
return midnight + timedelta(seconds=pad_secs), midnight + timedelta(seconds=kxp_secs)
|
||||
return None
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
import os
|
||||
|
||||
# Directory containing TfL reference data (TransXChange XML files etc.)
|
||||
TFL_DATA_DIR = os.path.expanduser('~/lib/data/tfl')
|
||||
|
||||
# Directory for caching scraped train times
|
||||
CACHE_DIR = os.path.expanduser('~/lib/data/tfl/cache')
|
||||
|
||||
# TransXChange timetable file for the Circle Line
|
||||
CIRCLE_LINE_XML = os.path.join(TFL_DATA_DIR, 'output_txc_01CIR_.xml')
|
||||
|
|
@ -1,14 +1,29 @@
|
|||
"""
|
||||
Fetch Eurostar timetable, prices, and seat availability via the GraphQL API.
|
||||
Scrape Eurostar timetable via httpx and fetch prices via the GraphQL API.
|
||||
|
||||
A single POST to https://site-api.eurostar.com/gateway (operationName
|
||||
NewBookingSearch) returns departure time, arrival time, train number,
|
||||
Eurostar Standard fare price, and seats remaining at that price for every
|
||||
service on the requested date.
|
||||
Timetable: route-specific pages are Next.js SSR — all departure data is
|
||||
embedded in <script id="__NEXT_DATA__"> as JSON, so no browser / JS needed.
|
||||
|
||||
URL pattern:
|
||||
https://www.eurostar.com/uk-en/travel-info/timetable/
|
||||
{origin_id}/{dest_id}/{origin_slug}/{dest_slug}?date=YYYY-MM-DD
|
||||
|
||||
Data path: props.pageProps.pageData.liveDepartures[]
|
||||
.origin.model.scheduledDepartureDateTime → London departure
|
||||
.destination.model.scheduledArrivalDateTime → destination arrival
|
||||
(already filtered to the requested stop, not the final stop)
|
||||
|
||||
Prices: POST https://site-api.eurostar.com/gateway (GraphQL, operationName
|
||||
NewBookingSearch). The `journeys[].fares[]` array contains one entry per
|
||||
class of service; we extract the Eurostar Standard (classOfService.code ==
|
||||
"STANDARD") displayPrice for 1 adult, in GBP.
|
||||
"""
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
import string
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
|
||||
DEFAULT_UA = (
|
||||
|
|
@ -17,6 +32,8 @@ DEFAULT_UA = (
|
|||
)
|
||||
|
||||
ORIGIN_STATION_ID = '7015400'
|
||||
ORIGIN_STATION_SLUG = 'london-st-pancras-intl'
|
||||
TIMETABLE_BASE_URL = 'https://www.eurostar.com/uk-en/travel-info/timetable'
|
||||
|
||||
DESTINATION_STATION_IDS = {
|
||||
'Paris Gare du Nord': '8727100',
|
||||
|
|
@ -24,15 +41,84 @@ DESTINATION_STATION_IDS = {
|
|||
'Lille Europe': '8722326',
|
||||
'Amsterdam Centraal': '8400058',
|
||||
'Rotterdam Centraal': '8400530',
|
||||
'Cologne Hbf': '8015458',
|
||||
}
|
||||
|
||||
|
||||
def _slugify_station_name(name: str) -> str:
|
||||
return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
|
||||
|
||||
|
||||
def search_url(destination: str, travel_date: str) -> str:
|
||||
dest_id = DESTINATION_STATION_IDS[destination]
|
||||
return (
|
||||
f'https://www.eurostar.com/search/uk-en'
|
||||
f'?adult=1&origin={ORIGIN_STATION_ID}&destination={dest_id}&outbound={travel_date}'
|
||||
)
|
||||
|
||||
|
||||
def timetable_url(destination: str) -> str:
|
||||
dest_id = DESTINATION_STATION_IDS[destination]
|
||||
dest_slug = _slugify_station_name(destination)
|
||||
return (
|
||||
f'{TIMETABLE_BASE_URL}/{ORIGIN_STATION_ID}/{dest_id}/'
|
||||
f'{ORIGIN_STATION_SLUG}/{dest_slug}'
|
||||
)
|
||||
|
||||
|
||||
def _hhmm(dt_str: str | None) -> str | None:
|
||||
"""'2026-03-30 09:34:00' → '09:34'"""
|
||||
if not dt_str:
|
||||
return None
|
||||
m = re.search(r'(\d{2}):(\d{2}):\d{2}$', dt_str)
|
||||
return f"{m.group(1)}:{m.group(2)}" if m else None
|
||||
|
||||
|
||||
def _parse(html: str, destination: str) -> list[dict]:
|
||||
m = re.search(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', html, re.DOTALL)
|
||||
if not m:
|
||||
return []
|
||||
data = json.loads(m.group(1))
|
||||
departures = data['props']['pageProps']['pageData']['liveDepartures']
|
||||
services = []
|
||||
for dep in departures:
|
||||
dep_time = _hhmm(dep['origin']['model']['scheduledDepartureDateTime'])
|
||||
arr_time = _hhmm(dep['destination']['model']['scheduledArrivalDateTime'])
|
||||
if dep_time and arr_time:
|
||||
carrier = dep.get('model', {}).get('carrier', 'ES')
|
||||
number = dep.get('model', {}).get('trainNumber', '')
|
||||
services.append({
|
||||
'depart_st_pancras': dep_time,
|
||||
'arrive_destination': arr_time,
|
||||
'destination': destination,
|
||||
'train_number': f"{carrier} {number}" if number else '',
|
||||
})
|
||||
return sorted(services, key=lambda s: s['depart_st_pancras'])
|
||||
|
||||
|
||||
def fetch(destination: str, travel_date: str,
|
||||
user_agent: str = DEFAULT_UA) -> list[dict]:
|
||||
url = timetable_url(destination)
|
||||
headers = {
|
||||
'User-Agent': user_agent,
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-GB,en;q=0.9',
|
||||
}
|
||||
with httpx.Client(headers=headers, follow_redirects=True, timeout=20) as client:
|
||||
r = client.get(url, params={'date': travel_date})
|
||||
r.raise_for_status()
|
||||
return _parse(r.text, destination)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Price fetching via site-api.eurostar.com GraphQL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GATEWAY_URL = 'https://site-api.eurostar.com/gateway'
|
||||
|
||||
# Query requesting timing, train identity, and Standard fare price + seats.
|
||||
# Variable names and argument names match the site's own query so the
|
||||
# Minimal query requesting only timing + Eurostar Standard fare price.
|
||||
# Variable names and inline argument names match what the site sends so the
|
||||
# server-side query planner sees a familiar shape.
|
||||
_GQL_QUERY = (
|
||||
_GQL_PRICES = (
|
||||
"query NewBookingSearch("
|
||||
"$origin:String!,$destination:String!,$outbound:String!,"
|
||||
"$currency:Currency!,$adult:Int,"
|
||||
|
|
@ -55,85 +141,40 @@ _GQL_QUERY = (
|
|||
" hideExternalCarrierTrains:true"
|
||||
" hideDirectExternalCarrierTrains:true"
|
||||
"){"
|
||||
"timing{departureTime:departs arrivalTime:arrives}"
|
||||
"timing{departureTime:departs __typename}"
|
||||
"fares(filteredClassesOfService:$filteredClassesOfService){"
|
||||
"classOfService{code}"
|
||||
"prices{displayPrice}"
|
||||
"seats "
|
||||
"legs{serviceName serviceType{code}}"
|
||||
"classOfService{code __typename}"
|
||||
"prices{displayPrice __typename}"
|
||||
"seats __typename"
|
||||
"}"
|
||||
"__typename"
|
||||
"}"
|
||||
"__typename"
|
||||
"}"
|
||||
"__typename"
|
||||
"}"
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
def search_url(destination: str, travel_date: str) -> str:
|
||||
dest_id = DESTINATION_STATION_IDS[destination]
|
||||
return (
|
||||
f'https://www.eurostar.com/search/uk-en'
|
||||
f'?adult=1&origin={ORIGIN_STATION_ID}&destination={dest_id}&outbound={travel_date}'
|
||||
)
|
||||
|
||||
|
||||
def _generate_cid() -> str:
|
||||
chars = string.ascii_letters + string.digits
|
||||
return 'SRCH-' + ''.join(random.choices(chars, k=22))
|
||||
|
||||
|
||||
def _parse_graphql(data: dict, destination: str) -> list[dict]:
|
||||
def fetch_prices(destination: str, travel_date: str) -> dict[str, int | None]:
|
||||
"""
|
||||
Parse a NewBookingSearch GraphQL response into a list of service dicts.
|
||||
Return Eurostar Standard prices for every departure on travel_date.
|
||||
|
||||
Each dict contains: depart_st_pancras, arrive_destination, destination,
|
||||
train_number, price (float or None), seats (int or None).
|
||||
|
||||
The same St Pancras departure can appear multiple times (different
|
||||
connecting trains); we keep the entry with the earliest arrival.
|
||||
Multi-leg train numbers are joined with ' + ' (e.g. 'ES 9116 + ER 9329').
|
||||
"""
|
||||
best: dict[str, dict] = {}
|
||||
journeys = data['data']['journeySearch']['outbound']['journeys']
|
||||
for journey in journeys:
|
||||
dep = journey['timing']['departureTime']
|
||||
arr = journey['timing']['arrivalTime']
|
||||
for fare in journey['fares']:
|
||||
if fare['classOfService']['code'] == 'STANDARD':
|
||||
p = fare.get('prices')
|
||||
price = float(p['displayPrice']) if p and p.get('displayPrice') else None
|
||||
seats = fare.get('seats')
|
||||
legs = fare.get('legs') or []
|
||||
train_number = ' + '.join(
|
||||
f"{(leg.get('serviceType') or {}).get('code', 'ES')} {leg['serviceName']}"
|
||||
for leg in legs if leg.get('serviceName')
|
||||
)
|
||||
if dep not in best or arr < best[dep]['arrive_destination']:
|
||||
best[dep] = {
|
||||
'depart_st_pancras': dep,
|
||||
'arrive_destination': arr,
|
||||
'destination': destination,
|
||||
'train_number': train_number,
|
||||
'price': price,
|
||||
'seats': seats,
|
||||
}
|
||||
break
|
||||
return sorted(best.values(), key=lambda s: s['depart_st_pancras'])
|
||||
|
||||
|
||||
def fetch(destination: str, travel_date: str) -> list[dict]:
|
||||
"""
|
||||
Return all Eurostar services for destination on travel_date.
|
||||
|
||||
Each dict contains timetable info (depart_st_pancras, arrive_destination,
|
||||
train_number) plus pricing (price, seats) from a single GraphQL call.
|
||||
Result: {depart_st_pancras: price_gbp_int_or_None}
|
||||
None means the class is sold out or unavailable for that departure.
|
||||
"""
|
||||
dest_id = DESTINATION_STATION_IDS[destination]
|
||||
headers = {
|
||||
'User-Agent': DEFAULT_UA,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language':'en-GB',
|
||||
'Accept-Language': 'en-GB',
|
||||
'Referer': 'https://www.eurostar.com/',
|
||||
'x-platform': 'web',
|
||||
'x-market-code': 'uk',
|
||||
|
|
@ -150,8 +191,21 @@ def fetch(destination: str, travel_date: str) -> list[dict]:
|
|||
'adult': 1,
|
||||
'filteredClassesOfService': ['STANDARD'],
|
||||
},
|
||||
'query': _GQL_QUERY,
|
||||
'query': _GQL_PRICES,
|
||||
}
|
||||
resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20)
|
||||
resp.raise_for_status()
|
||||
return _parse_graphql(resp.json(), destination)
|
||||
data = resp.json()
|
||||
prices: dict[str, int | None] = {}
|
||||
journeys = data['data']['journeySearch']['outbound']['journeys']
|
||||
for journey in journeys:
|
||||
dep = journey['timing']['departureTime']
|
||||
price = None
|
||||
for fare in journey['fares']:
|
||||
if fare['classOfService']['code'] == 'STANDARD':
|
||||
p = fare.get('prices')
|
||||
if p and p.get('displayPrice'):
|
||||
price = int(p['displayPrice'])
|
||||
break
|
||||
prices[dep] = price
|
||||
return prices
|
||||
|
|
|
|||
|
|
@ -185,103 +185,9 @@
|
|||
.card {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
.col-transfer { display: none; }
|
||||
}
|
||||
|
||||
a { color: #00539f; }
|
||||
|
||||
/* Card helpers */
|
||||
.card > h2:first-child { margin-top: 0; }
|
||||
.card-scroll { overflow-x: auto; }
|
||||
|
||||
/* Form groups */
|
||||
.form-group { margin-bottom: 1.2rem; }
|
||||
.form-group-lg { margin-bottom: 1.5rem; }
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: #00539f;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-nav {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: #00539f;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Inline select */
|
||||
.select-inline {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #cbd5e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Alert boxes */
|
||||
.alert {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.alert-error {
|
||||
background: #fff5f5;
|
||||
border: 1px solid #fc8181;
|
||||
color: #c53030;
|
||||
}
|
||||
.alert-warning {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #f6e05e;
|
||||
color: #744210;
|
||||
}
|
||||
|
||||
/* Results page layout */
|
||||
.back-link { margin-bottom: 1rem; }
|
||||
.date-nav { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||
.switcher-section { margin: 0.9rem 0 1rem; }
|
||||
.section-label { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.45rem; }
|
||||
.filter-row { margin-top: 0.75rem; display: flex; gap: 1.5rem; align-items: center; }
|
||||
.filter-label { font-size: 0.9rem; font-weight: 600; margin-right: 0.5rem; }
|
||||
.card-meta { color: #4a5568; margin: 0; }
|
||||
.footnote { margin-top: 1rem; font-size: 0.82rem; color: #718096; }
|
||||
|
||||
/* Results table */
|
||||
.results-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
|
||||
.results-table th,
|
||||
.results-table td { padding: 0.6rem 0.8rem; }
|
||||
.results-table thead tr { border-bottom: 2px solid #e2e8f0; text-align: left; }
|
||||
.results-table tbody tr { border-bottom: 1px solid #e2e8f0; }
|
||||
.row-fast { background: #f0fff4; }
|
||||
.row-slow { background: #fff5f5; }
|
||||
.row-alt { background: #f7fafc; }
|
||||
.row-unreachable { background: #f7fafc; color: #a0aec0; }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state { color: #4a5568; text-align: center; padding: 3rem 2rem; }
|
||||
.empty-state p { margin: 0; }
|
||||
.empty-state p:first-child { font-size: 1.1rem; margin-bottom: 0.5rem; }
|
||||
.empty-state p:last-child { font-size: 0.9rem; }
|
||||
|
||||
/* Utilities */
|
||||
.text-muted { color: #718096; }
|
||||
.text-dimmed { color: #a0aec0; }
|
||||
.text-green { color: #276749; }
|
||||
.text-red { color: #c53030; }
|
||||
.text-blue { color: #00539f; }
|
||||
.text-sm { font-size: 0.85rem; }
|
||||
.text-xs { font-size: 0.75rem; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.font-bold { font-weight: 600; }
|
||||
.font-normal { font-weight: 400; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<h2>Plan your journey</h2>
|
||||
<h2 style="margin-top:0">Plan your journey</h2>
|
||||
<form method="get" action="{{ url_for('search') }}">
|
||||
<div class="form-group">
|
||||
<div style="margin-bottom:1.2rem">
|
||||
<span class="field-label">Departure point</span>
|
||||
<div class="fixed-station" aria-label="Departure point">
|
||||
<strong>Bristol Temple Meads</strong>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div style="margin-bottom:1.2rem">
|
||||
<span class="field-label">Eurostar destination</span>
|
||||
<div class="destination-grid" role="radiogroup" aria-label="Eurostar destination">
|
||||
{% for slug, name in destinations.items() %}
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group-lg">
|
||||
<div style="margin-bottom:1.5rem">
|
||||
<label for="travel_date" class="field-label">
|
||||
Travel date
|
||||
</label>
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div style="margin-bottom:1.2rem">
|
||||
<label for="min_connection" class="field-label">
|
||||
Minimum connection time (Paddington → St Pancras)
|
||||
</label>
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group-lg">
|
||||
<div style="margin-bottom:1.5rem">
|
||||
<label for="max_connection" class="field-label">
|
||||
Maximum connection time (Paddington → St Pancras)
|
||||
</label>
|
||||
|
|
@ -64,7 +64,9 @@
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">
|
||||
<button type="submit"
|
||||
style="background:#00539f;color:#fff;border:none;padding:0.75rem 2rem;
|
||||
font-size:1rem;font-weight:600;border-radius:4px;cursor:pointer">
|
||||
Search journeys
|
||||
</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -5,24 +5,31 @@
|
|||
{% block twitter_title %}Bristol to {{ destination }} via Eurostar{% endblock %}
|
||||
{% block twitter_description %}Train options from Bristol Temple Meads to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
@media (max-width: 640px) {
|
||||
.col-transfer { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<p class="back-link">
|
||||
<p style="margin-bottom:1rem">
|
||||
<a href="{{ url_for('index') }}">← New search</a>
|
||||
</p>
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2>
|
||||
<h2 style="margin-top:0">
|
||||
Bristol Temple Meads → {{ destination }}
|
||||
</h2>
|
||||
<div class="date-nav">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem">
|
||||
<a href="{{ url_for('results', slug=slug, travel_date=prev_date, min_connection=min_connection, max_connection=max_connection) }}"
|
||||
class="btn-nav">← Prev</a>
|
||||
style="padding:0.3rem 0.75rem;border:1px solid #cbd5e0;border-radius:4px;
|
||||
text-decoration:none;color:#00539f;font-size:0.9rem">← Prev</a>
|
||||
<strong>{{ travel_date_display }}</strong>
|
||||
<a href="{{ url_for('results', slug=slug, travel_date=next_date, min_connection=min_connection, max_connection=max_connection) }}"
|
||||
class="btn-nav">Next →</a>
|
||||
style="padding:0.3rem 0.75rem;border:1px solid #cbd5e0;border-radius:4px;
|
||||
text-decoration:none;color:#00539f;font-size:0.9rem">Next →</a>
|
||||
</div>
|
||||
<div class="switcher-section">
|
||||
<div class="section-label">Switch destination for {{ travel_date_display }}</div>
|
||||
<div style="margin:0.9rem 0 1rem">
|
||||
<div style="font-size:0.9rem;font-weight:600;margin-bottom:0.45rem">Switch destination for {{ travel_date_display }}</div>
|
||||
<div class="chip-row">
|
||||
{% for destination_slug, destination_name in destinations.items() %}
|
||||
{% if destination_slug == slug %}
|
||||
|
|
@ -36,26 +43,26 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div style="margin-top:0.75rem;display:flex;gap:1.5rem;align-items:center">
|
||||
<div>
|
||||
<label for="min_conn_select" class="filter-label">
|
||||
<label for="min_conn_select" style="font-size:0.9rem;font-weight:600;margin-right:0.5rem">
|
||||
Min connection:
|
||||
</label>
|
||||
<select id="min_conn_select"
|
||||
onchange="applyConnectionFilter()"
|
||||
class="select-inline">
|
||||
style="padding:0.3rem 0.6rem;font-size:0.9rem;border:1px solid #cbd5e0;border-radius:4px">
|
||||
{% for mins in valid_min_connections %}
|
||||
<option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="max_conn_select" class="filter-label">
|
||||
<label for="max_conn_select" style="font-size:0.9rem;font-weight:600;margin-right:0.5rem">
|
||||
Max connection:
|
||||
</label>
|
||||
<select id="max_conn_select"
|
||||
onchange="applyConnectionFilter()"
|
||||
class="select-inline">
|
||||
style="padding:0.3rem 0.6rem;font-size:0.9rem;border:1px solid #cbd5e0;border-radius:4px">
|
||||
{% for mins in valid_max_connections %}
|
||||
<option value="{{ mins }}" {% if mins == max_connection %}selected{% endif %}>{{ mins }} min</option>
|
||||
{% endfor %}
|
||||
|
|
@ -69,45 +76,40 @@
|
|||
window.location = '{{ url_for('results', slug=slug, travel_date=travel_date) }}?min_connection=' + min + '&max_connection=' + max;
|
||||
}
|
||||
</script>
|
||||
<p class="card-meta">
|
||||
<p style="color:#4a5568;margin:0">
|
||||
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
|
||||
·
|
||||
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
|
||||
{% if unreachable_morning_services %}
|
||||
·
|
||||
<span class="text-muted">
|
||||
<span style="color:#718096">
|
||||
{{ unreachable_morning_services | length }} Eurostar service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if from_cache %}
|
||||
· <span class="text-muted text-sm">(cached)</span>
|
||||
· <span style="color:#718096;font-size:0.85rem">(cached)</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">
|
||||
<div style="margin-top:1rem;padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fc8181;border-radius:4px;color:#c53030">
|
||||
<strong>Warning:</strong> {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if no_prices_note %}
|
||||
<div class="alert alert-warning">
|
||||
{{ no_prices_note }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if trips or unreachable_morning_services %}
|
||||
<div class="card card-scroll">
|
||||
<table class="results-table">
|
||||
<div class="card" style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:0.95rem">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="nowrap">Bristol</th>
|
||||
<th class="nowrap">Paddington</th>
|
||||
<th class="nowrap">GWR Fare</th>
|
||||
<th class="col-transfer nowrap">Transfer</th>
|
||||
<th class="nowrap">Depart STP</th>
|
||||
<th>{{ destination }}</th>
|
||||
<th class="nowrap">ES Std</th>
|
||||
<th class="nowrap">Total</th>
|
||||
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
|
||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Bristol</th>
|
||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Paddington</th>
|
||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">GWR Fare</th>
|
||||
<th class="col-transfer" style="padding:0.6rem 0.8rem;white-space:nowrap">Transfer</th>
|
||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Depart St Pancras</th>
|
||||
<th style="padding:0.6rem 0.8rem">{{ destination }}</th>
|
||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">ES Std</th>
|
||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -122,91 +124,82 @@
|
|||
{% endif %}
|
||||
{% for row in result_rows %}
|
||||
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
||||
{% set row_class = 'row-fast' %}
|
||||
{% set row_bg = 'background:#f0fff4' %}
|
||||
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
||||
{% set row_class = 'row-slow' %}
|
||||
{% set row_bg = 'background:#fff5f5' %}
|
||||
{% elif row.row_type == 'unreachable' %}
|
||||
{% set row_class = 'row-unreachable' %}
|
||||
{% set row_bg = 'background:#f7fafc;color:#a0aec0' %}
|
||||
{% elif loop.index is odd %}
|
||||
{% set row_class = 'row-alt' %}
|
||||
{% set row_bg = 'background:#f7fafc' %}
|
||||
{% else %}
|
||||
{% set row_class = '' %}
|
||||
{% set row_bg = '' %}
|
||||
{% endif %}
|
||||
<tr class="{{ row_class }}">
|
||||
<tr style="border-bottom:1px solid #e2e8f0;{{ row_bg }}">
|
||||
{% if row.row_type == 'trip' %}
|
||||
<td class="font-bold">
|
||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
||||
{{ row.depart_bristol }}
|
||||
{% if row.headcode %}<br><span class="text-xs font-normal text-muted">{{ row.headcode }}</span>{% endif %}
|
||||
{% if row.headcode %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ row.headcode }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<td style="padding:0.6rem 0.8rem">
|
||||
{{ row.arrive_paddington }}
|
||||
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
|
||||
<span style="font-size:0.8rem;color:#718096;white-space:nowrap">({{ row.gwr_duration }})</span>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
|
||||
£{{ "%.2f"|format(row.ticket_price) }}
|
||||
<br><span class="text-xs text-muted">{{ row.ticket_name }}</span>
|
||||
<br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span>
|
||||
</td>
|
||||
<td class="col-transfer nowrap" style="color:#4a5568">
|
||||
<td class="col-transfer" style="padding:0.6rem 0.8rem;color:#4a5568;white-space:nowrap">
|
||||
{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}
|
||||
<br><span class="text-xs text-muted">Circle {{ row.circle_line_depart }} → STP {{ row.circle_arrive_checkin }}</span>
|
||||
</td>
|
||||
<td class="font-bold">
|
||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
||||
{{ row.depart_st_pancras }}
|
||||
{% if row.train_number %}<br><span class="text-xs font-normal text-muted">{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}</span>{% endif %}
|
||||
{% if row.train_number %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ row.train_number }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<td style="padding:0.6rem 0.8rem">
|
||||
{{ row.arrive_destination }}
|
||||
<span class="font-normal text-muted" style="font-size:0.85em">(CET)</span>
|
||||
{% if row.eurostar_duration %}<br><span class="text-sm text-muted nowrap">({{ row.eurostar_duration }})</span>{% endif %}
|
||||
<span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
|
||||
{% if row.eurostar_price is not none %}
|
||||
£{{ "%.2f"|format(row.eurostar_price) }}
|
||||
{% if row.eurostar_seats is not none %}
|
||||
<br><span class="text-xs text-muted">{{ row.eurostar_seats }} at this price</span>
|
||||
{% endif %}
|
||||
£{{ row.eurostar_price }}
|
||||
{% else %}
|
||||
<span class="text-muted">–</span>
|
||||
<span style="color:#718096">–</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="font-bold nowrap">
|
||||
<td style="padding:0.6rem 0.8rem;font-weight:600;white-space:nowrap">
|
||||
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
||||
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
|
||||
<span style="color:#276749" title="Fastest journey">{{ row.total_duration }} ⚡</span>
|
||||
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
||||
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
|
||||
<span style="color:#c53030" title="Slowest journey">{{ row.total_duration }} 🐢</span>
|
||||
{% else %}
|
||||
<span class="text-blue">{{ row.total_duration }}</span>
|
||||
<span style="color:#00539f">{{ row.total_duration }}</span>
|
||||
{% endif %}
|
||||
{% if row.total_price is not none %}
|
||||
<br><span class="text-sm text-green" style="font-weight:700">£{{ "%.2f"|format(row.total_price) }}{% if min_price is defined and max_price is defined %}{% if row.total_price <= min_price + 10 %} <span title="Cheapest journey">🪙</span>{% elif row.total_price >= max_price - 10 %} <span title="Most expensive journey">💸</span>{% endif %}{% endif %}</span>
|
||||
<br><span style="font-size:0.8rem;font-weight:700;color:#276749">£{{ "%.2f"|format(row.total_price) }}{% if min_price is defined and max_price is defined %}{% if row.total_price <= min_price + 10 %} <span title="Cheapest journey">💰</span>{% elif row.total_price >= max_price - 10 %} <span title="Most expensive journey">💸</span>{% endif %}{% endif %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="font-bold">—</td>
|
||||
<td>—</td>
|
||||
<td class="col-transfer">—</td>
|
||||
<td>n/a</td>
|
||||
<td class="font-bold">
|
||||
<td style="padding:0.6rem 0.8rem;font-weight:600">—</td>
|
||||
<td style="padding:0.6rem 0.8rem">—</td>
|
||||
<td class="col-transfer" style="padding:0.6rem 0.8rem">—</td>
|
||||
<td style="padding:0.6rem 0.8rem">n/a</td>
|
||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
||||
{{ row.depart_st_pancras }}
|
||||
{% if row.train_number %}<br><span class="text-xs font-normal text-dimmed">{% for part in row.train_number.split(' + ') %}<span class="nowrap">{{ part }}</span>{% if not loop.last %} + {% endif %}{% endfor %}</span>{% endif %}
|
||||
{% if row.train_number %}<br><span style="font-size:0.75rem;font-weight:400;color:#a0aec0">{{ row.train_number }}</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<td style="padding:0.6rem 0.8rem">
|
||||
{{ row.arrive_destination }}
|
||||
<span class="font-normal text-dimmed" style="font-size:0.85em">(CET)</span>
|
||||
{% if row.eurostar_duration %}<br><span class="text-sm text-dimmed nowrap">({{ row.eurostar_duration }})</span>{% endif %}
|
||||
<span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
|
||||
{% if row.eurostar_price is not none %}
|
||||
<span class="text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span>
|
||||
{% if row.eurostar_seats is not none %}
|
||||
<br><span class="text-xs text-dimmed">{{ row.eurostar_seats }} at this price</span>
|
||||
{% endif %}
|
||||
<span style="color:#a0aec0">£{{ row.eurostar_price }}</span>
|
||||
{% else %}
|
||||
<span class="text-dimmed">–</span>
|
||||
<span style="color:#a0aec0">–</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="font-bold">
|
||||
<span title="No same-day Bristol connection" class="text-dimmed nowrap">Too early</span>
|
||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
||||
<span title="No same-day Bristol connection" style="color:#a0aec0">Too early</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
|
@ -215,7 +208,7 @@
|
|||
</table>
|
||||
</div>
|
||||
|
||||
<p class="footnote">
|
||||
<p style="margin-top:1rem;font-size:0.82rem;color:#718096">
|
||||
Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
||||
GWR walk-on single prices for Bristol Temple Meads → Paddington.
|
||||
Eurostar Standard prices are for 1 adult in GBP; always check
|
||||
|
|
@ -227,9 +220,9 @@
|
|||
</p>
|
||||
|
||||
{% else %}
|
||||
<div class="card empty-state">
|
||||
<p>No valid journeys found.</p>
|
||||
<p>
|
||||
<div class="card" style="color:#4a5568;text-align:center;padding:3rem 2rem">
|
||||
<p style="font-size:1.1rem;margin:0 0 0.5rem">No valid journeys found.</p>
|
||||
<p style="font-size:0.9rem;margin:0">
|
||||
{% if gwr_count == 0 and eurostar_count == 0 %}
|
||||
Could not retrieve train data. Check your network connection or try again.
|
||||
{% elif gwr_count == 0 %}
|
||||
|
|
|
|||
|
|
@ -16,21 +16,25 @@ def _stub_data(monkeypatch, prices=None):
|
|||
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
|
||||
],
|
||||
)
|
||||
p = (prices or {}).get('10:01', {})
|
||||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
'fetch',
|
||||
lambda destination, travel_date: [
|
||||
lambda destination, travel_date, user_agent: [
|
||||
{
|
||||
'depart_st_pancras': '10:01',
|
||||
'arrive_destination': '13:34',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 9014',
|
||||
'price': p.get('price') if isinstance(p, dict) else None,
|
||||
'seats': p.get('seats') if isinstance(p, dict) else None,
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
'timetable_url',
|
||||
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
|
||||
)
|
||||
_prices = prices if prices is not None else {}
|
||||
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: _prices)
|
||||
|
||||
|
||||
def test_index_shows_fixed_departure_and_destination_radios():
|
||||
|
|
@ -92,6 +96,7 @@ def test_results_title_and_social_meta_include_destination(monkeypatch):
|
|||
def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypatch):
|
||||
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
|
||||
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
||||
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
|
||||
monkeypatch.setattr(
|
||||
app_module.rtt_scraper,
|
||||
'fetch',
|
||||
|
|
@ -106,14 +111,44 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
|
|||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
'fetch',
|
||||
lambda destination, travel_date: [
|
||||
{'depart_st_pancras': '09:30', 'arrive_destination': '11:50', 'destination': destination, 'train_number': 'ES 1001', 'price': None, 'seats': None},
|
||||
{'depart_st_pancras': '09:40', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 1002', 'price': None, 'seats': None},
|
||||
{'depart_st_pancras': '09:50', 'arrive_destination': '12:20', 'destination': destination, 'train_number': 'ES 1003', 'price': None, 'seats': None},
|
||||
{'depart_st_pancras': '10:00', 'arrive_destination': '12:35', 'destination': destination, 'train_number': 'ES 1004', 'price': None, 'seats': None},
|
||||
{'depart_st_pancras': '10:10', 'arrive_destination': '12:45', 'destination': destination, 'train_number': 'ES 1005', 'price': None, 'seats': None},
|
||||
lambda destination, travel_date, user_agent: [
|
||||
{
|
||||
'depart_st_pancras': '09:30',
|
||||
'arrive_destination': '11:50',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 1001',
|
||||
},
|
||||
{
|
||||
'depart_st_pancras': '09:40',
|
||||
'arrive_destination': '12:00',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 1002',
|
||||
},
|
||||
{
|
||||
'depart_st_pancras': '09:50',
|
||||
'arrive_destination': '12:20',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 1003',
|
||||
},
|
||||
{
|
||||
'depart_st_pancras': '10:00',
|
||||
'arrive_destination': '12:35',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 1004',
|
||||
},
|
||||
{
|
||||
'depart_st_pancras': '10:10',
|
||||
'arrive_destination': '12:45',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 1005',
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
'timetable_url',
|
||||
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
|
||||
)
|
||||
client = _client()
|
||||
|
||||
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
||||
|
|
@ -133,6 +168,7 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
|
|||
def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
||||
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
|
||||
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
||||
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
|
||||
monkeypatch.setattr(
|
||||
app_module.rtt_scraper,
|
||||
'fetch',
|
||||
|
|
@ -143,12 +179,32 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
|||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
'fetch',
|
||||
lambda destination, travel_date: [
|
||||
{'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 9001', 'price': None, 'seats': None},
|
||||
{'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': destination, 'train_number': 'ES 9002', 'price': None, 'seats': None},
|
||||
{'depart_st_pancras': '12:30', 'arrive_destination': '15:55', 'destination': destination, 'train_number': 'ES 9003', 'price': None, 'seats': None},
|
||||
lambda destination, travel_date, user_agent: [
|
||||
{
|
||||
'depart_st_pancras': '09:30',
|
||||
'arrive_destination': '12:00',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 9001',
|
||||
},
|
||||
{
|
||||
'depart_st_pancras': '10:15',
|
||||
'arrive_destination': '13:40',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 9002',
|
||||
},
|
||||
{
|
||||
'depart_st_pancras': '12:30',
|
||||
'arrive_destination': '15:55',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 9003',
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
'timetable_url',
|
||||
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
|
||||
)
|
||||
client = _client()
|
||||
|
||||
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
||||
|
|
@ -158,13 +214,13 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
|||
assert '2 Eurostar services unavailable from Bristol' in html
|
||||
assert '09:30' in html
|
||||
assert 'ES 9001' in html
|
||||
assert 'Too early' in html
|
||||
assert 'Unavailable from Bristol' in html
|
||||
assert html.index('09:30') < html.index('10:15')
|
||||
|
||||
|
||||
def test_results_shows_eurostar_price_and_total(monkeypatch):
|
||||
# 07:00 on Friday 2026-04-10 → Anytime £138.70 (weekday, 05:05–08:25 window)
|
||||
_stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}})
|
||||
_stub_data(monkeypatch, prices={'10:01': 59})
|
||||
client = _client()
|
||||
|
||||
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
||||
|
|
@ -178,6 +234,7 @@ def test_results_shows_eurostar_price_and_total(monkeypatch):
|
|||
def test_results_can_show_only_unreachable_morning_services(monkeypatch):
|
||||
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
|
||||
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
||||
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
|
||||
monkeypatch.setattr(
|
||||
app_module.rtt_scraper,
|
||||
'fetch',
|
||||
|
|
@ -188,10 +245,20 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
|
|||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
'fetch',
|
||||
lambda destination, travel_date: [
|
||||
{'depart_st_pancras': '09:30', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 9001', 'price': None, 'seats': None},
|
||||
lambda destination, travel_date, user_agent: [
|
||||
{
|
||||
'depart_st_pancras': '09:30',
|
||||
'arrive_destination': '12:00',
|
||||
'destination': destination,
|
||||
'train_number': 'ES 9001',
|
||||
},
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.eurostar_scraper,
|
||||
'timetable_url',
|
||||
lambda destination: f'https://example.test/{destination.lower().replace(" ", "-")}',
|
||||
)
|
||||
client = _client()
|
||||
|
||||
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
||||
|
|
@ -201,4 +268,4 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
|
|||
assert 'No valid journeys found.' not in html
|
||||
assert '1 Eurostar service unavailable from Bristol' in html
|
||||
assert '09:30' in html
|
||||
assert 'Too early' in html
|
||||
assert 'Unavailable from Bristol' in html
|
||||
|
|
|
|||
|
|
@ -1,92 +1,97 @@
|
|||
import json
|
||||
import pytest
|
||||
from scraper.eurostar import _parse_graphql, search_url
|
||||
from scraper.eurostar import _hhmm, _parse, timetable_url
|
||||
|
||||
|
||||
def _gql_response(journeys: list) -> dict:
|
||||
return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}}
|
||||
# ---------------------------------------------------------------------------
|
||||
# _hhmm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_hhmm_parses_datetime_string():
|
||||
assert _hhmm('2026-03-30 09:34:00') == '09:34'
|
||||
|
||||
def test_hhmm_none_input():
|
||||
assert _hhmm(None) is None
|
||||
|
||||
def test_hhmm_empty_string():
|
||||
assert _hhmm('') is None
|
||||
|
||||
|
||||
def _journey(departs: str, arrives: str, price=None, seats=None, service_name='', carrier='ES') -> dict:
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_next_data(departures: list) -> str:
|
||||
data = {
|
||||
'props': {
|
||||
'pageProps': {
|
||||
'pageData': {
|
||||
'liveDepartures': departures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return f'<script id="__NEXT_DATA__" type="application/json">{json.dumps(data)}</script>'
|
||||
|
||||
|
||||
def _departure(dep_dt: str, arr_dt: str) -> dict:
|
||||
return {
|
||||
'timing': {'departureTime': departs, 'arrivalTime': arrives},
|
||||
'fares': [{
|
||||
'classOfService': {'code': 'STANDARD'},
|
||||
'prices': {'displayPrice': price},
|
||||
'seats': seats,
|
||||
'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}]
|
||||
if service_name else [],
|
||||
}],
|
||||
'origin': {'model': {'scheduledDepartureDateTime': dep_dt}},
|
||||
'destination': {'model': {'scheduledArrivalDateTime': arr_dt}},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _parse_graphql
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_graphql_single_journey():
|
||||
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='9014')])
|
||||
services = _parse_graphql(data, 'Paris Gare du Nord')
|
||||
def test_parse_single_departure():
|
||||
html = _make_next_data([_departure('2026-03-30 06:01:00', '2026-03-30 09:34:00')])
|
||||
services = _parse(html, 'Paris Gare du Nord')
|
||||
assert len(services) == 1
|
||||
s = services[0]
|
||||
assert s['depart_st_pancras'] == '09:31'
|
||||
assert s['arrive_destination'] == '12:55'
|
||||
assert s['destination'] == 'Paris Gare du Nord'
|
||||
assert s['train_number'] == 'ES 9014'
|
||||
assert s['price'] == 156.0
|
||||
assert s['seats'] == 37
|
||||
assert services[0] == {
|
||||
'depart_st_pancras': '06:01',
|
||||
'arrive_destination': '09:34',
|
||||
'destination': 'Paris Gare du Nord',
|
||||
'train_number': '',
|
||||
}
|
||||
|
||||
|
||||
def test_parse_graphql_half_pound_price():
|
||||
data = _gql_response([_journey('09:01', '14:20', price=192.5, seats=25, service_name='9116')])
|
||||
services = _parse_graphql(data, 'Amsterdam Centraal')
|
||||
assert services[0]['price'] == 192.5
|
||||
|
||||
|
||||
def test_parse_graphql_null_price():
|
||||
data = _gql_response([_journey('06:16', '11:09', price=None, seats=0)])
|
||||
services = _parse_graphql(data, 'Amsterdam Centraal')
|
||||
assert services[0]['price'] is None
|
||||
assert services[0]['seats'] == 0
|
||||
|
||||
|
||||
def test_parse_graphql_sorted_by_departure():
|
||||
data = _gql_response([
|
||||
_journey('10:31', '13:55'),
|
||||
_journey('07:31', '10:59'),
|
||||
def test_parse_results_sorted_by_departure():
|
||||
html = _make_next_data([
|
||||
_departure('2026-03-30 10:00:00', '2026-03-30 13:00:00'),
|
||||
_departure('2026-03-30 07:00:00', '2026-03-30 10:00:00'),
|
||||
])
|
||||
services = _parse_graphql(data, 'Paris Gare du Nord')
|
||||
assert services[0]['depart_st_pancras'] == '07:31'
|
||||
assert services[1]['depart_st_pancras'] == '10:31'
|
||||
services = _parse(html, 'Paris Gare du Nord')
|
||||
assert services[0]['depart_st_pancras'] == '07:00'
|
||||
assert services[1]['depart_st_pancras'] == '10:00'
|
||||
|
||||
|
||||
def test_parse_graphql_deduplicates_same_departure_time():
|
||||
data = _gql_response([
|
||||
_journey('06:16', '11:09', price=None, seats=0),
|
||||
_journey('06:16', '11:09', price=None, seats=0),
|
||||
_journey('06:16', '11:09', price=None, seats=0),
|
||||
def test_parse_skips_entries_with_missing_times():
|
||||
html = _make_next_data([
|
||||
_departure(None, '2026-03-30 09:34:00'),
|
||||
_departure('2026-03-30 08:00:00', None),
|
||||
_departure('2026-03-30 09:00:00', '2026-03-30 12:00:00'),
|
||||
])
|
||||
services = _parse_graphql(data, 'Amsterdam Centraal')
|
||||
services = _parse(html, 'Paris Gare du Nord')
|
||||
assert len(services) == 1
|
||||
assert services[0]['depart_st_pancras'] == '09:00'
|
||||
|
||||
|
||||
def test_parse_graphql_no_legs_gives_empty_train_number():
|
||||
data = _gql_response([_journey('09:31', '12:55', price=156, seats=37, service_name='')])
|
||||
services = _parse_graphql(data, 'Paris Gare du Nord')
|
||||
assert services[0]['train_number'] == ''
|
||||
def test_parse_no_next_data_returns_empty():
|
||||
assert _parse('<html><body>nothing here</body></html>', 'Paris Gare du Nord') == []
|
||||
|
||||
|
||||
def test_parse_graphql_empty_journeys():
|
||||
data = _gql_response([])
|
||||
assert _parse_graphql(data, 'Paris Gare du Nord') == []
|
||||
def test_parse_empty_departures():
|
||||
html = _make_next_data([])
|
||||
assert _parse(html, 'Paris Gare du Nord') == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# search_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_search_url():
|
||||
url = search_url('Paris Gare du Nord', '2026-04-10')
|
||||
assert url == (
|
||||
'https://www.eurostar.com/search/uk-en'
|
||||
'?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10'
|
||||
def test_timetable_url_uses_station_id_table():
|
||||
assert timetable_url('Paris Gare du Nord') == (
|
||||
'https://www.eurostar.com/uk-en/travel-info/timetable/'
|
||||
'7015400/8727100/london-st-pancras-intl/paris-gare-du-nord'
|
||||
)
|
||||
|
||||
|
||||
def test_timetable_url_slugifies_destination_name():
|
||||
assert timetable_url('Rotterdam Centraal') == (
|
||||
'https://www.eurostar.com/uk-en/travel-info/timetable/'
|
||||
'7015400/8400530/london-st-pancras-intl/rotterdam-centraal'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,17 +3,12 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination t
|
|||
"""
|
||||
from datetime import datetime, timedelta, time as _time
|
||||
|
||||
import circle_line
|
||||
|
||||
MIN_CONNECTION_MINUTES = 50
|
||||
MAX_CONNECTION_MINUTES = 110
|
||||
MAX_GWR_MINUTES = 110
|
||||
DATE_FMT = '%Y-%m-%d'
|
||||
TIME_FMT = '%H:%M'
|
||||
|
||||
PAD_WALK_TO_UNDERGROUND_MINUTES = 7 # GWR platform → Paddington (H&C Line) platform
|
||||
KX_WALK_TO_CHECKIN_MINUTES = 8 # King's Cross St Pancras platform → St Pancras check-in
|
||||
|
||||
|
||||
# Bristol Temple Meads → London Paddington walk-on single fares.
|
||||
# Weekday restrictions (Mon–Fri only):
|
||||
|
|
@ -50,24 +45,6 @@ def _parse_dt(date: str, time: str) -> datetime:
|
|||
return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}")
|
||||
|
||||
|
||||
def _circle_line_times(arrive_paddington: datetime) -> tuple[str, str] | tuple[None, None]:
|
||||
"""
|
||||
Given GWR arrival at Paddington, return (circle_line_depart_str, arrive_checkin_str).
|
||||
|
||||
Adds PAD_WALK_TO_UNDERGROUND_MINUTES to get the earliest boarding time, looks up
|
||||
the next real Circle Line service from the timetable, then adds KX_WALK_TO_CHECKIN_MINUTES
|
||||
to the Kings Cross arrival to give an estimated St Pancras check-in time.
|
||||
Returns (None, None) if no service is found.
|
||||
"""
|
||||
earliest_board = arrive_paddington + timedelta(minutes=PAD_WALK_TO_UNDERGROUND_MINUTES)
|
||||
result = circle_line.next_service(earliest_board)
|
||||
if result is None:
|
||||
return None, None
|
||||
circle_depart, arrive_kx = result
|
||||
arrive_checkin = arrive_kx + timedelta(minutes=KX_WALK_TO_CHECKIN_MINUTES)
|
||||
return circle_depart.strftime(TIME_FMT), arrive_checkin.strftime(TIME_FMT)
|
||||
|
||||
|
||||
def _fmt_duration(minutes: int) -> str:
|
||||
h, m = divmod(minutes, 60)
|
||||
if h and m:
|
||||
|
|
@ -144,10 +121,7 @@ def combine_trips(
|
|||
dep_bri, arr_pad, dep_stp, arr_dest = connection
|
||||
|
||||
total_mins = int((arr_dest - dep_bri).total_seconds() / 60)
|
||||
# Destination time is CET/CEST, departure is GMT/BST; Europe is always 1h ahead.
|
||||
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
|
||||
ticket = cheapest_gwr_ticket(gwr['depart_bristol'], travel_date)
|
||||
circle_depart, arrive_checkin = _circle_line_times(arr_pad)
|
||||
trips.append({
|
||||
'depart_bristol': gwr['depart_bristol'],
|
||||
'arrive_paddington': gwr['arrive_paddington'],
|
||||
|
|
@ -155,11 +129,8 @@ def combine_trips(
|
|||
'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)),
|
||||
'connection_minutes': int((dep_stp - arr_pad).total_seconds() / 60),
|
||||
'connection_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)),
|
||||
'circle_line_depart': circle_depart,
|
||||
'circle_arrive_checkin': arrive_checkin,
|
||||
'depart_st_pancras': es['depart_st_pancras'],
|
||||
'arrive_destination': es['arrive_destination'],
|
||||
'eurostar_duration': _fmt_duration(eurostar_mins),
|
||||
'train_number': es.get('train_number', ''),
|
||||
'total_duration': _fmt_duration(total_mins),
|
||||
'total_minutes': total_mins,
|
||||
|
|
@ -196,11 +167,6 @@ def find_unreachable_morning_eurostars(
|
|||
):
|
||||
continue
|
||||
|
||||
dep_stp = _parse_dt(travel_date, es['depart_st_pancras'])
|
||||
arr_dest = _parse_dt(travel_date, es['arrive_destination'])
|
||||
if arr_dest < dep_stp:
|
||||
arr_dest += timedelta(days=1)
|
||||
eurostar_mins = int((arr_dest - dep_stp).total_seconds() / 60) - 60
|
||||
unreachable.append({**es, 'eurostar_duration': _fmt_duration(eurostar_mins)})
|
||||
unreachable.append(es)
|
||||
|
||||
return sorted(unreachable, key=lambda s: s['depart_st_pancras'])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue