Compare commits
7 commits
cdee44ea3f
...
71be0dd8cf
| Author | SHA1 | Date | |
|---|---|---|---|
| 71be0dd8cf | |||
| e6f310f517 | |||
| c22a3ea0fc | |||
| 05eec29b7d | |||
| cd37f0619b | |||
| c215456620 | |||
| 60674fe663 |
14 changed files with 626 additions and 418 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -12,6 +12,7 @@ venv/
|
||||||
|
|
||||||
# App
|
# App
|
||||||
cache/
|
cache/
|
||||||
|
config/local.py
|
||||||
|
|
||||||
# Pytest
|
# Pytest
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
|
||||||
46
README.md
46
README.md
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
Source: https://git.4angle.com/edward/bristol-eurostar
|
Source: https://git.4angle.com/edward/bristol-eurostar
|
||||||
|
|
||||||
Plan a trip from Bristol Temple Meads to Europe on Eurostar.
|
Plan a trip from Bristol Temple Meads to Europe via Eurostar.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Destinations
|
## Destinations
|
||||||
|
|
||||||
|
|
@ -12,23 +12,42 @@ Combines GWR trains (Bristol → Paddington) with Eurostar services (St Pancras
|
||||||
- Brussels Midi
|
- Brussels Midi
|
||||||
- Lille Europe
|
- Lille Europe
|
||||||
- Amsterdam Centraal
|
- Amsterdam Centraal
|
||||||
|
- Rotterdam Centraal
|
||||||
|
- Cologne Hbf
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
Train times are fetched from two sources simultaneously:
|
Train times and prices are fetched from two sources:
|
||||||
|
|
||||||
- **GWR** — scraped from [Realtime Trains](https://www.realtimetrains.co.uk/) using httpx
|
- **GWR** — scraped from [Realtime Trains](https://www.realtimetrains.co.uk/) using httpx
|
||||||
- **Eurostar** — scraped from the Eurostar timetable pages via the embedded `__NEXT_DATA__` JSON (no browser required)
|
- **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.
|
||||||
|
|
||||||
Results are cached to disk by date and destination.
|
Results are cached to disk by date and destination.
|
||||||
|
|
||||||
## Connection constraints
|
## Connection constraints
|
||||||
|
|
||||||
|
Configurable via the search form. Defaults:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Minimum Paddington → St Pancras | 75 min |
|
| Minimum Paddington → St Pancras | 50 min |
|
||||||
| Maximum Paddington → St Pancras | 2h 20m |
|
| Maximum Paddington → St Pancras | 110 min |
|
||||||
| Maximum Bristol → Paddington | 1h 50m |
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|
@ -36,6 +55,19 @@ Results are cached to disk by date and destination.
|
||||||
pip install -e ".[dev]"
|
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
|
## Running
|
||||||
|
|
||||||
```bash
|
```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 flask import Flask, render_template, redirect, url_for, request
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
import os
|
||||||
|
|
||||||
from cache import get_cached, set_cached
|
from cache import get_cached, set_cached
|
||||||
import scraper.eurostar as eurostar_scraper
|
import scraper.eurostar as eurostar_scraper
|
||||||
import scraper.realtime_trains as rtt_scraper
|
import scraper.realtime_trains as rtt_scraper
|
||||||
from trip_planner import combine_trips, find_unreachable_morning_eurostars
|
from trip_planner import combine_trips, find_unreachable_morning_eurostars
|
||||||
from scraper.eurostar import fetch_prices as fetch_eurostar_prices
|
|
||||||
|
|
||||||
RTT_PADDINGTON_URL = (
|
RTT_PADDINGTON_URL = (
|
||||||
"https://www.realtimetrains.co.uk/search/detailed/"
|
"https://www.realtimetrains.co.uk/search/detailed/"
|
||||||
|
|
@ -22,7 +22,16 @@ RTT_BRISTOL_URL = (
|
||||||
"?stp=WVS&show=pax-calls&order=wtt"
|
"?stp=WVS&show=pax-calls&order=wtt"
|
||||||
)
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
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 = {
|
DESTINATIONS = {
|
||||||
'paris': 'Paris Gare du Nord',
|
'paris': 'Paris Gare du Nord',
|
||||||
|
|
@ -30,6 +39,7 @@ DESTINATIONS = {
|
||||||
'lille': 'Lille Europe',
|
'lille': 'Lille Europe',
|
||||||
'amsterdam': 'Amsterdam Centraal',
|
'amsterdam': 'Amsterdam Centraal',
|
||||||
'rotterdam': 'Rotterdam Centraal',
|
'rotterdam': 'Rotterdam Centraal',
|
||||||
|
'cologne': 'Cologne Hbf',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -93,12 +103,10 @@ def results(slug, travel_date):
|
||||||
|
|
||||||
rtt_cache_key = f"rtt_{travel_date}"
|
rtt_cache_key = f"rtt_{travel_date}"
|
||||||
es_cache_key = f"eurostar_{travel_date}_{destination}"
|
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_rtt = get_cached(rtt_cache_key)
|
||||||
cached_es = get_cached(es_cache_key)
|
cached_es = get_cached(es_cache_key, ttl=24 * 3600)
|
||||||
cached_prices = get_cached(prices_cache_key, ttl=24 * 3600)
|
from_cache = bool(cached_rtt and cached_es)
|
||||||
from_cache = bool(cached_rtt and cached_es and cached_prices)
|
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
|
|
||||||
|
|
@ -113,37 +121,36 @@ def results(slug, travel_date):
|
||||||
error = f"Could not fetch GWR trains: {e}"
|
error = f"Could not fetch GWR trains: {e}"
|
||||||
|
|
||||||
if cached_es:
|
if cached_es:
|
||||||
eurostar_trains = cached_es
|
eurostar_services = cached_es
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
eurostar_trains = eurostar_scraper.fetch(destination, travel_date, user_agent)
|
eurostar_services = eurostar_scraper.fetch(destination, travel_date)
|
||||||
set_cached(es_cache_key, eurostar_trains)
|
set_cached(es_cache_key, eurostar_services)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
eurostar_trains = []
|
eurostar_services = []
|
||||||
msg = f"Could not fetch Eurostar times: {e}"
|
msg = f"Could not fetch Eurostar times: {e}"
|
||||||
error = f"{error}; {msg}" if error else msg
|
error = f"{error}; {msg}" if error else msg
|
||||||
|
|
||||||
if cached_prices:
|
eurostar_trains = eurostar_services
|
||||||
eurostar_prices = cached_prices
|
eurostar_prices = {
|
||||||
else:
|
s['depart_st_pancras']: {'price': s.get('price'), 'seats': s.get('seats')}
|
||||||
try:
|
for s in eurostar_services
|
||||||
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)
|
trips = combine_trips(gwr_trains, eurostar_trains, travel_date, min_connection, max_connection)
|
||||||
|
|
||||||
# Annotate each trip with Eurostar Standard price and total cost
|
# Annotate each trip with Eurostar Standard price, seats, and total cost
|
||||||
for trip in trips:
|
for trip in trips:
|
||||||
es_price = eurostar_prices.get(trip['depart_st_pancras'])
|
es = eurostar_prices.get(trip['depart_st_pancras'], {})
|
||||||
|
es_price = es.get('price')
|
||||||
trip['eurostar_price'] = es_price
|
trip['eurostar_price'] = es_price
|
||||||
if es_price is not None:
|
trip['eurostar_seats'] = es.get('seats')
|
||||||
trip['total_price'] = trip['ticket_price'] + es_price
|
trip['total_price'] = trip['ticket_price'] + es_price if es_price is not None else None
|
||||||
else:
|
|
||||||
trip['total_price'] = 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(
|
unreachable_morning_services = find_unreachable_morning_eurostars(
|
||||||
gwr_trains,
|
gwr_trains,
|
||||||
|
|
@ -153,7 +160,9 @@ def results(slug, travel_date):
|
||||||
max_connection,
|
max_connection,
|
||||||
)
|
)
|
||||||
for svc in unreachable_morning_services:
|
for svc in unreachable_morning_services:
|
||||||
svc['eurostar_price'] = eurostar_prices.get(svc['depart_st_pancras'])
|
es = eurostar_prices.get(svc['depart_st_pancras'], {})
|
||||||
|
svc['eurostar_price'] = es.get('price')
|
||||||
|
svc['eurostar_seats'] = es.get('seats')
|
||||||
|
|
||||||
result_rows = sorted(
|
result_rows = sorted(
|
||||||
[{'row_type': 'trip', **trip} for trip in trips]
|
[{'row_type': 'trip', **trip} for trip in trips]
|
||||||
|
|
@ -186,6 +195,7 @@ def results(slug, travel_date):
|
||||||
eurostar_count=len(eurostar_trains),
|
eurostar_count=len(eurostar_trains),
|
||||||
from_cache=from_cache,
|
from_cache=from_cache,
|
||||||
error=error,
|
error=error,
|
||||||
|
no_prices_note=no_prices_note,
|
||||||
eurostar_url=eurostar_url,
|
eurostar_url=eurostar_url,
|
||||||
rtt_url=rtt_url,
|
rtt_url=rtt_url,
|
||||||
rtt_bristol_url=rtt_bristol_url,
|
rtt_bristol_url=rtt_bristol_url,
|
||||||
|
|
|
||||||
2
cache.py
2
cache.py
|
|
@ -2,7 +2,7 @@ import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
CACHE_DIR = os.path.join(os.path.dirname(__file__), 'cache')
|
from config.default import CACHE_DIR # overridden by app config after import
|
||||||
|
|
||||||
|
|
||||||
def _cache_path(key: str) -> str:
|
def _cache_path(key: str) -> str:
|
||||||
|
|
|
||||||
148
circle_line.py
Normal file
148
circle_line.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""
|
||||||
|
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
|
||||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
10
config/default.py
Normal file
10
config/default.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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,29 +1,14 @@
|
||||||
"""
|
"""
|
||||||
Scrape Eurostar timetable via httpx and fetch prices via the GraphQL API.
|
Fetch Eurostar timetable, prices, and seat availability via the GraphQL API.
|
||||||
|
|
||||||
Timetable: route-specific pages are Next.js SSR — all departure data is
|
A single POST to https://site-api.eurostar.com/gateway (operationName
|
||||||
embedded in <script id="__NEXT_DATA__"> as JSON, so no browser / JS needed.
|
NewBookingSearch) returns departure time, arrival time, train number,
|
||||||
|
Eurostar Standard fare price, and seats remaining at that price for every
|
||||||
URL pattern:
|
service on the requested date.
|
||||||
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 random
|
||||||
import re
|
|
||||||
import string
|
import string
|
||||||
|
|
||||||
import httpx
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
DEFAULT_UA = (
|
DEFAULT_UA = (
|
||||||
|
|
@ -32,8 +17,6 @@ DEFAULT_UA = (
|
||||||
)
|
)
|
||||||
|
|
||||||
ORIGIN_STATION_ID = '7015400'
|
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 = {
|
DESTINATION_STATION_IDS = {
|
||||||
'Paris Gare du Nord': '8727100',
|
'Paris Gare du Nord': '8727100',
|
||||||
|
|
@ -41,84 +24,15 @@ DESTINATION_STATION_IDS = {
|
||||||
'Lille Europe': '8722326',
|
'Lille Europe': '8722326',
|
||||||
'Amsterdam Centraal': '8400058',
|
'Amsterdam Centraal': '8400058',
|
||||||
'Rotterdam Centraal': '8400530',
|
'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'
|
_GATEWAY_URL = 'https://site-api.eurostar.com/gateway'
|
||||||
|
|
||||||
# Minimal query requesting only timing + Eurostar Standard fare price.
|
# Query requesting timing, train identity, and Standard fare price + seats.
|
||||||
# Variable names and inline argument names match what the site sends so the
|
# Variable names and argument names match the site's own query so the
|
||||||
# server-side query planner sees a familiar shape.
|
# server-side query planner sees a familiar shape.
|
||||||
_GQL_PRICES = (
|
_GQL_QUERY = (
|
||||||
"query NewBookingSearch("
|
"query NewBookingSearch("
|
||||||
"$origin:String!,$destination:String!,$outbound:String!,"
|
"$origin:String!,$destination:String!,$outbound:String!,"
|
||||||
"$currency:Currency!,$adult:Int,"
|
"$currency:Currency!,$adult:Int,"
|
||||||
|
|
@ -141,40 +55,85 @@ _GQL_PRICES = (
|
||||||
" hideExternalCarrierTrains:true"
|
" hideExternalCarrierTrains:true"
|
||||||
" hideDirectExternalCarrierTrains:true"
|
" hideDirectExternalCarrierTrains:true"
|
||||||
"){"
|
"){"
|
||||||
"timing{departureTime:departs __typename}"
|
"timing{departureTime:departs arrivalTime:arrives}"
|
||||||
"fares(filteredClassesOfService:$filteredClassesOfService){"
|
"fares(filteredClassesOfService:$filteredClassesOfService){"
|
||||||
"classOfService{code __typename}"
|
"classOfService{code}"
|
||||||
"prices{displayPrice __typename}"
|
"prices{displayPrice}"
|
||||||
"seats __typename"
|
"seats "
|
||||||
|
"legs{serviceName serviceType{code}}"
|
||||||
"}"
|
"}"
|
||||||
"__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:
|
def _generate_cid() -> str:
|
||||||
chars = string.ascii_letters + string.digits
|
chars = string.ascii_letters + string.digits
|
||||||
return 'SRCH-' + ''.join(random.choices(chars, k=22))
|
return 'SRCH-' + ''.join(random.choices(chars, k=22))
|
||||||
|
|
||||||
|
|
||||||
def fetch_prices(destination: str, travel_date: str) -> dict[str, int | None]:
|
def _parse_graphql(data: dict, destination: str) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Return Eurostar Standard prices for every departure on travel_date.
|
Parse a NewBookingSearch GraphQL response into a list of service dicts.
|
||||||
|
|
||||||
Result: {depart_st_pancras: price_gbp_int_or_None}
|
Each dict contains: depart_st_pancras, arrive_destination, destination,
|
||||||
None means the class is sold out or unavailable for that departure.
|
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.
|
||||||
"""
|
"""
|
||||||
dest_id = DESTINATION_STATION_IDS[destination]
|
dest_id = DESTINATION_STATION_IDS[destination]
|
||||||
headers = {
|
headers = {
|
||||||
'User-Agent': DEFAULT_UA,
|
'User-Agent': DEFAULT_UA,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': '*/*',
|
'Accept': '*/*',
|
||||||
'Accept-Language': 'en-GB',
|
'Accept-Language':'en-GB',
|
||||||
'Referer': 'https://www.eurostar.com/',
|
'Referer': 'https://www.eurostar.com/',
|
||||||
'x-platform': 'web',
|
'x-platform': 'web',
|
||||||
'x-market-code': 'uk',
|
'x-market-code': 'uk',
|
||||||
|
|
@ -191,21 +150,8 @@ def fetch_prices(destination: str, travel_date: str) -> dict[str, int | None]:
|
||||||
'adult': 1,
|
'adult': 1,
|
||||||
'filteredClassesOfService': ['STANDARD'],
|
'filteredClassesOfService': ['STANDARD'],
|
||||||
},
|
},
|
||||||
'query': _GQL_PRICES,
|
'query': _GQL_QUERY,
|
||||||
}
|
}
|
||||||
resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20)
|
resp = requests.post(_GATEWAY_URL, json=payload, headers=headers, timeout=20)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
return _parse_graphql(resp.json(), destination)
|
||||||
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,9 +185,103 @@
|
||||||
.card {
|
.card {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
.col-transfer { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: #00539f; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-top:0">Plan your journey</h2>
|
<h2>Plan your journey</h2>
|
||||||
<form method="get" action="{{ url_for('search') }}">
|
<form method="get" action="{{ url_for('search') }}">
|
||||||
<div style="margin-bottom:1.2rem">
|
<div class="form-group">
|
||||||
<span class="field-label">Departure point</span>
|
<span class="field-label">Departure point</span>
|
||||||
<div class="fixed-station" aria-label="Departure point">
|
<div class="fixed-station" aria-label="Departure point">
|
||||||
<strong>Bristol Temple Meads</strong>
|
<strong>Bristol Temple Meads</strong>
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:1.2rem">
|
<div class="form-group">
|
||||||
<span class="field-label">Eurostar destination</span>
|
<span class="field-label">Eurostar destination</span>
|
||||||
<div class="destination-grid" role="radiogroup" aria-label="Eurostar destination">
|
<div class="destination-grid" role="radiogroup" aria-label="Eurostar destination">
|
||||||
{% for slug, name in destinations.items() %}
|
{% for slug, name in destinations.items() %}
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:1.5rem">
|
<div class="form-group-lg">
|
||||||
<label for="travel_date" class="field-label">
|
<label for="travel_date" class="field-label">
|
||||||
Travel date
|
Travel date
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -42,7 +42,7 @@
|
||||||
class="form-control">
|
class="form-control">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:1.2rem">
|
<div class="form-group">
|
||||||
<label for="min_connection" class="field-label">
|
<label for="min_connection" class="field-label">
|
||||||
Minimum connection time (Paddington → St Pancras)
|
Minimum connection time (Paddington → St Pancras)
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:1.5rem">
|
<div class="form-group-lg">
|
||||||
<label for="max_connection" class="field-label">
|
<label for="max_connection" class="field-label">
|
||||||
Maximum connection time (Paddington → St Pancras)
|
Maximum connection time (Paddington → St Pancras)
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -64,9 +64,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit"
|
<button type="submit" class="btn-primary">
|
||||||
style="background:#00539f;color:#fff;border:none;padding:0.75rem 2rem;
|
|
||||||
font-size:1rem;font-weight:600;border-radius:4px;cursor:pointer">
|
|
||||||
Search journeys
|
Search journeys
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -5,31 +5,24 @@
|
||||||
{% block twitter_title %}Bristol to {{ destination }} via Eurostar{% endblock %}
|
{% 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 twitter_description %}Train options from Bristol Temple Meads to {{ destination }} on {{ travel_date_display }} via Paddington, St Pancras, and Eurostar.{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.col-transfer { display: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<p style="margin-bottom:1rem">
|
<p class="back-link">
|
||||||
<a href="{{ url_for('index') }}">← New search</a>
|
<a href="{{ url_for('index') }}">← New search</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card" style="margin-bottom:1.5rem">
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
<h2 style="margin-top:0">
|
<h2>
|
||||||
Bristol Temple Meads → {{ destination }}
|
Bristol Temple Meads → {{ destination }}
|
||||||
</h2>
|
</h2>
|
||||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:0.5rem">
|
<div class="date-nav">
|
||||||
<a href="{{ url_for('results', slug=slug, travel_date=prev_date, min_connection=min_connection, max_connection=max_connection) }}"
|
<a href="{{ url_for('results', slug=slug, travel_date=prev_date, min_connection=min_connection, max_connection=max_connection) }}"
|
||||||
style="padding:0.3rem 0.75rem;border:1px solid #cbd5e0;border-radius:4px;
|
class="btn-nav">← Prev</a>
|
||||||
text-decoration:none;color:#00539f;font-size:0.9rem">← Prev</a>
|
|
||||||
<strong>{{ travel_date_display }}</strong>
|
<strong>{{ travel_date_display }}</strong>
|
||||||
<a href="{{ url_for('results', slug=slug, travel_date=next_date, min_connection=min_connection, max_connection=max_connection) }}"
|
<a href="{{ url_for('results', slug=slug, travel_date=next_date, min_connection=min_connection, max_connection=max_connection) }}"
|
||||||
style="padding:0.3rem 0.75rem;border:1px solid #cbd5e0;border-radius:4px;
|
class="btn-nav">Next →</a>
|
||||||
text-decoration:none;color:#00539f;font-size:0.9rem">Next →</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="margin:0.9rem 0 1rem">
|
<div class="switcher-section">
|
||||||
<div style="font-size:0.9rem;font-weight:600;margin-bottom:0.45rem">Switch destination for {{ travel_date_display }}</div>
|
<div class="section-label">Switch destination for {{ travel_date_display }}</div>
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for destination_slug, destination_name in destinations.items() %}
|
{% for destination_slug, destination_name in destinations.items() %}
|
||||||
{% if destination_slug == slug %}
|
{% if destination_slug == slug %}
|
||||||
|
|
@ -43,26 +36,26 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:0.75rem;display:flex;gap:1.5rem;align-items:center">
|
<div class="filter-row">
|
||||||
<div>
|
<div>
|
||||||
<label for="min_conn_select" style="font-size:0.9rem;font-weight:600;margin-right:0.5rem">
|
<label for="min_conn_select" class="filter-label">
|
||||||
Min connection:
|
Min connection:
|
||||||
</label>
|
</label>
|
||||||
<select id="min_conn_select"
|
<select id="min_conn_select"
|
||||||
onchange="applyConnectionFilter()"
|
onchange="applyConnectionFilter()"
|
||||||
style="padding:0.3rem 0.6rem;font-size:0.9rem;border:1px solid #cbd5e0;border-radius:4px">
|
class="select-inline">
|
||||||
{% for mins in valid_min_connections %}
|
{% for mins in valid_min_connections %}
|
||||||
<option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option>
|
<option value="{{ mins }}" {% if mins == min_connection %}selected{% endif %}>{{ mins }} min</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="max_conn_select" style="font-size:0.9rem;font-weight:600;margin-right:0.5rem">
|
<label for="max_conn_select" class="filter-label">
|
||||||
Max connection:
|
Max connection:
|
||||||
</label>
|
</label>
|
||||||
<select id="max_conn_select"
|
<select id="max_conn_select"
|
||||||
onchange="applyConnectionFilter()"
|
onchange="applyConnectionFilter()"
|
||||||
style="padding:0.3rem 0.6rem;font-size:0.9rem;border:1px solid #cbd5e0;border-radius:4px">
|
class="select-inline">
|
||||||
{% for mins in valid_max_connections %}
|
{% for mins in valid_max_connections %}
|
||||||
<option value="{{ mins }}" {% if mins == max_connection %}selected{% endif %}>{{ mins }} min</option>
|
<option value="{{ mins }}" {% if mins == max_connection %}selected{% endif %}>{{ mins }} min</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -76,40 +69,45 @@
|
||||||
window.location = '{{ url_for('results', slug=slug, travel_date=travel_date) }}?min_connection=' + min + '&max_connection=' + max;
|
window.location = '{{ url_for('results', slug=slug, travel_date=travel_date) }}?min_connection=' + min + '&max_connection=' + max;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<p style="color:#4a5568;margin:0">
|
<p class="card-meta">
|
||||||
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
|
{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }}
|
||||||
·
|
·
|
||||||
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
|
{{ eurostar_count }} Eurostar service{{ 's' if eurostar_count != 1 }}
|
||||||
{% if unreachable_morning_services %}
|
{% if unreachable_morning_services %}
|
||||||
·
|
·
|
||||||
<span style="color:#718096">
|
<span class="text-muted">
|
||||||
{{ unreachable_morning_services | length }} Eurostar service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol
|
{{ unreachable_morning_services | length }} Eurostar service{{ 's' if unreachable_morning_services | length != 1 }} unavailable from Bristol
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if from_cache %}
|
{% if from_cache %}
|
||||||
· <span style="color:#718096;font-size:0.85rem">(cached)</span>
|
· <span class="text-muted text-sm">(cached)</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div style="margin-top:1rem;padding:0.75rem 1rem;background:#fff5f5;border:1px solid #fc8181;border-radius:4px;color:#c53030">
|
<div class="alert alert-error">
|
||||||
<strong>Warning:</strong> {{ error }}
|
<strong>Warning:</strong> {{ error }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if no_prices_note %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{{ no_prices_note }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if trips or unreachable_morning_services %}
|
{% if trips or unreachable_morning_services %}
|
||||||
<div class="card" style="overflow-x:auto">
|
<div class="card card-scroll">
|
||||||
<table style="width:100%;border-collapse:collapse;font-size:0.95rem">
|
<table class="results-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="border-bottom:2px solid #e2e8f0;text-align:left">
|
<tr>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Bristol</th>
|
<th class="nowrap">Bristol</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Paddington</th>
|
<th class="nowrap">Paddington</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">GWR Fare</th>
|
<th class="nowrap">GWR Fare</th>
|
||||||
<th class="col-transfer" style="padding:0.6rem 0.8rem;white-space:nowrap">Transfer</th>
|
<th class="col-transfer nowrap">Transfer</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Depart St Pancras</th>
|
<th class="nowrap">Depart STP</th>
|
||||||
<th style="padding:0.6rem 0.8rem">{{ destination }}</th>
|
<th>{{ destination }}</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">ES Std</th>
|
<th class="nowrap">ES Std</th>
|
||||||
<th style="padding:0.6rem 0.8rem;white-space:nowrap">Total</th>
|
<th class="nowrap">Total</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -124,82 +122,91 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for row in result_rows %}
|
{% for row in result_rows %}
|
||||||
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
||||||
{% set row_bg = 'background:#f0fff4' %}
|
{% set row_class = 'row-fast' %}
|
||||||
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
{% elif row.row_type == 'trip' and row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
||||||
{% set row_bg = 'background:#fff5f5' %}
|
{% set row_class = 'row-slow' %}
|
||||||
{% elif row.row_type == 'unreachable' %}
|
{% elif row.row_type == 'unreachable' %}
|
||||||
{% set row_bg = 'background:#f7fafc;color:#a0aec0' %}
|
{% set row_class = 'row-unreachable' %}
|
||||||
{% elif loop.index is odd %}
|
{% elif loop.index is odd %}
|
||||||
{% set row_bg = 'background:#f7fafc' %}
|
{% set row_class = 'row-alt' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set row_bg = '' %}
|
{% set row_class = '' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<tr style="border-bottom:1px solid #e2e8f0;{{ row_bg }}">
|
<tr class="{{ row_class }}">
|
||||||
{% if row.row_type == 'trip' %}
|
{% if row.row_type == 'trip' %}
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
<td class="font-bold">
|
||||||
{{ row.depart_bristol }}
|
{{ row.depart_bristol }}
|
||||||
{% if row.headcode %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ row.headcode }}</span>{% endif %}
|
{% if row.headcode %}<br><span class="text-xs font-normal text-muted">{{ row.headcode }}</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem">
|
<td>
|
||||||
{{ row.arrive_paddington }}
|
{{ row.arrive_paddington }}
|
||||||
<span style="font-size:0.8rem;color:#718096;white-space:nowrap">({{ row.gwr_duration }})</span>
|
<span class="text-sm text-muted nowrap">({{ row.gwr_duration }})</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
|
<td class="nowrap">
|
||||||
£{{ "%.2f"|format(row.ticket_price) }}
|
£{{ "%.2f"|format(row.ticket_price) }}
|
||||||
<br><span style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span>
|
<br><span class="text-xs text-muted">{{ row.ticket_name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-transfer" style="padding:0.6rem 0.8rem;color:#4a5568;white-space:nowrap">
|
<td class="col-transfer nowrap" style="color:#4a5568">
|
||||||
{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}
|
{{ 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>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
<td class="font-bold">
|
||||||
{{ row.depart_st_pancras }}
|
{{ row.depart_st_pancras }}
|
||||||
{% if row.train_number %}<br><span style="font-size:0.75rem;font-weight:400;color:#718096">{{ row.train_number }}</span>{% endif %}
|
{% 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 %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem">
|
<td>
|
||||||
{{ row.arrive_destination }}
|
{{ row.arrive_destination }}
|
||||||
<span style="font-weight:400;color:#718096;font-size:0.85em">(CET)</span>
|
<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 %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
|
<td class="nowrap">
|
||||||
{% if row.eurostar_price is not none %}
|
{% if row.eurostar_price is not none %}
|
||||||
£{{ row.eurostar_price }}
|
£{{ "%.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 %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color:#718096">–</span>
|
<span class="text-muted">–</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600;white-space:nowrap">
|
<td class="font-bold nowrap">
|
||||||
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
||||||
<span style="color:#276749" title="Fastest journey">{{ row.total_duration }} ⚡</span>
|
<span class="text-green" title="Fastest journey">{{ row.total_duration }} ⚡</span>
|
||||||
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
||||||
<span style="color:#c53030" title="Slowest journey">{{ row.total_duration }} 🐢</span>
|
<span class="text-red" title="Slowest journey">{{ row.total_duration }} 🐢</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color:#00539f">{{ row.total_duration }}</span>
|
<span class="text-blue">{{ row.total_duration }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if row.total_price is not none %}
|
{% if row.total_price is not none %}
|
||||||
<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>
|
<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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">—</td>
|
<td class="font-bold">—</td>
|
||||||
<td style="padding:0.6rem 0.8rem">—</td>
|
<td>—</td>
|
||||||
<td class="col-transfer" style="padding:0.6rem 0.8rem">—</td>
|
<td class="col-transfer">—</td>
|
||||||
<td style="padding:0.6rem 0.8rem">n/a</td>
|
<td>n/a</td>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
<td class="font-bold">
|
||||||
{{ row.depart_st_pancras }}
|
{{ row.depart_st_pancras }}
|
||||||
{% if row.train_number %}<br><span style="font-size:0.75rem;font-weight:400;color:#a0aec0">{{ row.train_number }}</span>{% endif %}
|
{% 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 %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem">
|
<td>
|
||||||
{{ row.arrive_destination }}
|
{{ row.arrive_destination }}
|
||||||
<span style="font-weight:400;color:#a0aec0;font-size:0.85em">(CET)</span>
|
<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 %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
|
<td class="nowrap">
|
||||||
{% if row.eurostar_price is not none %}
|
{% if row.eurostar_price is not none %}
|
||||||
<span style="color:#a0aec0">£{{ row.eurostar_price }}</span>
|
<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 %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color:#a0aec0">–</span>
|
<span class="text-dimmed">–</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
<td class="font-bold">
|
||||||
<span title="No same-day Bristol connection" style="color:#a0aec0">Too early</span>
|
<span title="No same-day Bristol connection" class="text-dimmed nowrap">Too early</span>
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -208,7 +215,7 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top:1rem;font-size:0.82rem;color:#718096">
|
<p class="footnote">
|
||||||
Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
||||||
GWR walk-on single prices for Bristol Temple Meads → Paddington.
|
GWR walk-on single prices for Bristol Temple Meads → Paddington.
|
||||||
Eurostar Standard prices are for 1 adult in GBP; always check
|
Eurostar Standard prices are for 1 adult in GBP; always check
|
||||||
|
|
@ -220,9 +227,9 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="color:#4a5568;text-align:center;padding:3rem 2rem">
|
<div class="card empty-state">
|
||||||
<p style="font-size:1.1rem;margin:0 0 0.5rem">No valid journeys found.</p>
|
<p>No valid journeys found.</p>
|
||||||
<p style="font-size:0.9rem;margin:0">
|
<p>
|
||||||
{% if gwr_count == 0 and eurostar_count == 0 %}
|
{% if gwr_count == 0 and eurostar_count == 0 %}
|
||||||
Could not retrieve train data. Check your network connection or try again.
|
Could not retrieve train data. Check your network connection or try again.
|
||||||
{% elif gwr_count == 0 %}
|
{% elif gwr_count == 0 %}
|
||||||
|
|
|
||||||
|
|
@ -16,25 +16,21 @@ def _stub_data(monkeypatch, prices=None):
|
||||||
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
|
{'depart_bristol': '07:00', 'arrive_paddington': '08:45', 'headcode': '1A23'},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
p = (prices or {}).get('10:01', {})
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.eurostar_scraper,
|
app_module.eurostar_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
lambda destination, travel_date, user_agent: [
|
lambda destination, travel_date: [
|
||||||
{
|
{
|
||||||
'depart_st_pancras': '10:01',
|
'depart_st_pancras': '10:01',
|
||||||
'arrive_destination': '13:34',
|
'arrive_destination': '13:34',
|
||||||
'destination': destination,
|
'destination': destination,
|
||||||
'train_number': 'ES 9014',
|
'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():
|
def test_index_shows_fixed_departure_and_destination_radios():
|
||||||
|
|
@ -96,7 +92,6 @@ def test_results_title_and_social_meta_include_destination(monkeypatch):
|
||||||
def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(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, 'get_cached', lambda key, ttl=None: None)
|
||||||
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
||||||
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.rtt_scraper,
|
app_module.rtt_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|
@ -111,44 +106,14 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.eurostar_scraper,
|
app_module.eurostar_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
lambda destination, travel_date, user_agent: [
|
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:30',
|
{'depart_st_pancras': '09:40', 'arrive_destination': '12:00', 'destination': destination, 'train_number': 'ES 1002', 'price': None, 'seats': None},
|
||||||
'arrive_destination': '11:50',
|
{'depart_st_pancras': '09:50', 'arrive_destination': '12:20', 'destination': destination, 'train_number': 'ES 1003', 'price': None, 'seats': None},
|
||||||
'destination': destination,
|
{'depart_st_pancras': '10:00', 'arrive_destination': '12:35', 'destination': destination, 'train_number': 'ES 1004', 'price': None, 'seats': None},
|
||||||
'train_number': 'ES 1001',
|
{'depart_st_pancras': '10:10', 'arrive_destination': '12:45', 'destination': destination, 'train_number': 'ES 1005', 'price': None, 'seats': None},
|
||||||
},
|
|
||||||
{
|
|
||||||
'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()
|
client = _client()
|
||||||
|
|
||||||
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
||||||
|
|
@ -168,7 +133,6 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
|
||||||
def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
||||||
monkeypatch.setattr(app_module, 'get_cached', lambda key, ttl=None: None)
|
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, 'set_cached', lambda key, data: None)
|
||||||
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.rtt_scraper,
|
app_module.rtt_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|
@ -179,32 +143,12 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.eurostar_scraper,
|
app_module.eurostar_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
lambda destination, travel_date, user_agent: [
|
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': '09:30',
|
{'depart_st_pancras': '10:15', 'arrive_destination': '13:40', 'destination': destination, 'train_number': 'ES 9002', 'price': None, 'seats': None},
|
||||||
'arrive_destination': '12:00',
|
{'depart_st_pancras': '12:30', 'arrive_destination': '15:55', 'destination': destination, 'train_number': 'ES 9003', 'price': None, 'seats': None},
|
||||||
'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()
|
client = _client()
|
||||||
|
|
||||||
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
||||||
|
|
@ -214,13 +158,13 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
||||||
assert '2 Eurostar services unavailable from Bristol' in html
|
assert '2 Eurostar services unavailable from Bristol' in html
|
||||||
assert '09:30' in html
|
assert '09:30' in html
|
||||||
assert 'ES 9001' in html
|
assert 'ES 9001' in html
|
||||||
assert 'Unavailable from Bristol' in html
|
assert 'Too early' in html
|
||||||
assert html.index('09:30') < html.index('10:15')
|
assert html.index('09:30') < html.index('10:15')
|
||||||
|
|
||||||
|
|
||||||
def test_results_shows_eurostar_price_and_total(monkeypatch):
|
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)
|
# 07:00 on Friday 2026-04-10 → Anytime £138.70 (weekday, 05:05–08:25 window)
|
||||||
_stub_data(monkeypatch, prices={'10:01': 59})
|
_stub_data(monkeypatch, prices={'10:01': {'price': 59, 'seats': 42}})
|
||||||
client = _client()
|
client = _client()
|
||||||
|
|
||||||
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
||||||
|
|
@ -234,7 +178,6 @@ def test_results_shows_eurostar_price_and_total(monkeypatch):
|
||||||
def test_results_can_show_only_unreachable_morning_services(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, 'get_cached', lambda key, ttl=None: None)
|
||||||
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
monkeypatch.setattr(app_module, 'set_cached', lambda key, data: None)
|
||||||
monkeypatch.setattr(app_module, 'fetch_eurostar_prices', lambda dest, date: {})
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.rtt_scraper,
|
app_module.rtt_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|
@ -245,20 +188,10 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
app_module.eurostar_scraper,
|
app_module.eurostar_scraper,
|
||||||
'fetch',
|
'fetch',
|
||||||
lambda destination, travel_date, user_agent: [
|
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': '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()
|
client = _client()
|
||||||
|
|
||||||
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
resp = client.get('/results/paris/2026-04-10?min_connection=60&max_connection=120')
|
||||||
|
|
@ -268,4 +201,4 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
|
||||||
assert 'No valid journeys found.' not in html
|
assert 'No valid journeys found.' not in html
|
||||||
assert '1 Eurostar service unavailable from Bristol' in html
|
assert '1 Eurostar service unavailable from Bristol' in html
|
||||||
assert '09:30' in html
|
assert '09:30' in html
|
||||||
assert 'Unavailable from Bristol' in html
|
assert 'Too early' in html
|
||||||
|
|
|
||||||
|
|
@ -1,97 +1,92 @@
|
||||||
import json
|
|
||||||
import pytest
|
import pytest
|
||||||
from scraper.eurostar import _hhmm, _parse, timetable_url
|
from scraper.eurostar import _parse_graphql, search_url
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
def _gql_response(journeys: list) -> dict:
|
||||||
# _hhmm
|
return {'data': {'journeySearch': {'outbound': {'journeys': journeys}}}}
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
'origin': {'model': {'scheduledDepartureDateTime': dep_dt}},
|
'timing': {'departureTime': departs, 'arrivalTime': arrives},
|
||||||
'destination': {'model': {'scheduledArrivalDateTime': arr_dt}},
|
'fares': [{
|
||||||
|
'classOfService': {'code': 'STANDARD'},
|
||||||
|
'prices': {'displayPrice': price},
|
||||||
|
'seats': seats,
|
||||||
|
'legs': [{'serviceName': service_name, 'serviceType': {'code': carrier}}]
|
||||||
|
if service_name else [],
|
||||||
|
}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_parse_single_departure():
|
# ---------------------------------------------------------------------------
|
||||||
html = _make_next_data([_departure('2026-03-30 06:01:00', '2026-03-30 09:34:00')])
|
# _parse_graphql
|
||||||
services = _parse(html, 'Paris Gare du Nord')
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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')
|
||||||
assert len(services) == 1
|
assert len(services) == 1
|
||||||
assert services[0] == {
|
s = services[0]
|
||||||
'depart_st_pancras': '06:01',
|
assert s['depart_st_pancras'] == '09:31'
|
||||||
'arrive_destination': '09:34',
|
assert s['arrive_destination'] == '12:55'
|
||||||
'destination': 'Paris Gare du Nord',
|
assert s['destination'] == 'Paris Gare du Nord'
|
||||||
'train_number': '',
|
assert s['train_number'] == 'ES 9014'
|
||||||
}
|
assert s['price'] == 156.0
|
||||||
|
assert s['seats'] == 37
|
||||||
|
|
||||||
|
|
||||||
def test_parse_results_sorted_by_departure():
|
def test_parse_graphql_half_pound_price():
|
||||||
html = _make_next_data([
|
data = _gql_response([_journey('09:01', '14:20', price=192.5, seats=25, service_name='9116')])
|
||||||
_departure('2026-03-30 10:00:00', '2026-03-30 13:00:00'),
|
services = _parse_graphql(data, 'Amsterdam Centraal')
|
||||||
_departure('2026-03-30 07:00:00', '2026-03-30 10:00:00'),
|
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'),
|
||||||
])
|
])
|
||||||
services = _parse(html, 'Paris Gare du Nord')
|
services = _parse_graphql(data, 'Paris Gare du Nord')
|
||||||
assert services[0]['depart_st_pancras'] == '07:00'
|
assert services[0]['depart_st_pancras'] == '07:31'
|
||||||
assert services[1]['depart_st_pancras'] == '10:00'
|
assert services[1]['depart_st_pancras'] == '10:31'
|
||||||
|
|
||||||
|
|
||||||
def test_parse_skips_entries_with_missing_times():
|
def test_parse_graphql_deduplicates_same_departure_time():
|
||||||
html = _make_next_data([
|
data = _gql_response([
|
||||||
_departure(None, '2026-03-30 09:34:00'),
|
_journey('06:16', '11:09', price=None, seats=0),
|
||||||
_departure('2026-03-30 08:00:00', None),
|
_journey('06:16', '11:09', price=None, seats=0),
|
||||||
_departure('2026-03-30 09:00:00', '2026-03-30 12:00:00'),
|
_journey('06:16', '11:09', price=None, seats=0),
|
||||||
])
|
])
|
||||||
services = _parse(html, 'Paris Gare du Nord')
|
services = _parse_graphql(data, 'Amsterdam Centraal')
|
||||||
assert len(services) == 1
|
assert len(services) == 1
|
||||||
assert services[0]['depart_st_pancras'] == '09:00'
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_no_next_data_returns_empty():
|
def test_parse_graphql_no_legs_gives_empty_train_number():
|
||||||
assert _parse('<html><body>nothing here</body></html>', 'Paris Gare du Nord') == []
|
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_empty_departures():
|
def test_parse_graphql_empty_journeys():
|
||||||
html = _make_next_data([])
|
data = _gql_response([])
|
||||||
assert _parse(html, 'Paris Gare du Nord') == []
|
assert _parse_graphql(data, 'Paris Gare du Nord') == []
|
||||||
|
|
||||||
|
|
||||||
def test_timetable_url_uses_station_id_table():
|
# ---------------------------------------------------------------------------
|
||||||
assert timetable_url('Paris Gare du Nord') == (
|
# search_url
|
||||||
'https://www.eurostar.com/uk-en/travel-info/timetable/'
|
# ---------------------------------------------------------------------------
|
||||||
'7015400/8727100/london-st-pancras-intl/paris-gare-du-nord'
|
|
||||||
)
|
def test_search_url():
|
||||||
|
url = search_url('Paris Gare du Nord', '2026-04-10')
|
||||||
|
assert url == (
|
||||||
def test_timetable_url_slugifies_destination_name():
|
'https://www.eurostar.com/search/uk-en'
|
||||||
assert timetable_url('Rotterdam Centraal') == (
|
'?adult=1&origin=7015400&destination=8727100&outbound=2026-04-10'
|
||||||
'https://www.eurostar.com/uk-en/travel-info/timetable/'
|
|
||||||
'7015400/8400530/london-st-pancras-intl/rotterdam-centraal'
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,17 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination t
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta, time as _time
|
from datetime import datetime, timedelta, time as _time
|
||||||
|
|
||||||
|
import circle_line
|
||||||
|
|
||||||
MIN_CONNECTION_MINUTES = 50
|
MIN_CONNECTION_MINUTES = 50
|
||||||
MAX_CONNECTION_MINUTES = 110
|
MAX_CONNECTION_MINUTES = 110
|
||||||
MAX_GWR_MINUTES = 110
|
MAX_GWR_MINUTES = 110
|
||||||
DATE_FMT = '%Y-%m-%d'
|
DATE_FMT = '%Y-%m-%d'
|
||||||
TIME_FMT = '%H:%M'
|
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.
|
# Bristol Temple Meads → London Paddington walk-on single fares.
|
||||||
# Weekday restrictions (Mon–Fri only):
|
# Weekday restrictions (Mon–Fri only):
|
||||||
|
|
@ -45,6 +50,24 @@ def _parse_dt(date: str, time: str) -> datetime:
|
||||||
return datetime.strptime(f"{date} {time}", f"{DATE_FMT} {TIME_FMT}")
|
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:
|
def _fmt_duration(minutes: int) -> str:
|
||||||
h, m = divmod(minutes, 60)
|
h, m = divmod(minutes, 60)
|
||||||
if h and m:
|
if h and m:
|
||||||
|
|
@ -121,7 +144,10 @@ def combine_trips(
|
||||||
dep_bri, arr_pad, dep_stp, arr_dest = connection
|
dep_bri, arr_pad, dep_stp, arr_dest = connection
|
||||||
|
|
||||||
total_mins = int((arr_dest - dep_bri).total_seconds() / 60)
|
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)
|
ticket = cheapest_gwr_ticket(gwr['depart_bristol'], travel_date)
|
||||||
|
circle_depart, arrive_checkin = _circle_line_times(arr_pad)
|
||||||
trips.append({
|
trips.append({
|
||||||
'depart_bristol': gwr['depart_bristol'],
|
'depart_bristol': gwr['depart_bristol'],
|
||||||
'arrive_paddington': gwr['arrive_paddington'],
|
'arrive_paddington': gwr['arrive_paddington'],
|
||||||
|
|
@ -129,8 +155,11 @@ def combine_trips(
|
||||||
'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)),
|
'gwr_duration': _fmt_duration(int((arr_pad - dep_bri).total_seconds() / 60)),
|
||||||
'connection_minutes': int((dep_stp - arr_pad).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)),
|
'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'],
|
'depart_st_pancras': es['depart_st_pancras'],
|
||||||
'arrive_destination': es['arrive_destination'],
|
'arrive_destination': es['arrive_destination'],
|
||||||
|
'eurostar_duration': _fmt_duration(eurostar_mins),
|
||||||
'train_number': es.get('train_number', ''),
|
'train_number': es.get('train_number', ''),
|
||||||
'total_duration': _fmt_duration(total_mins),
|
'total_duration': _fmt_duration(total_mins),
|
||||||
'total_minutes': total_mins,
|
'total_minutes': total_mins,
|
||||||
|
|
@ -167,6 +196,11 @@ def find_unreachable_morning_eurostars(
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
unreachable.append(es)
|
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)})
|
||||||
|
|
||||||
return sorted(unreachable, key=lambda s: s['depart_st_pancras'])
|
return sorted(unreachable, key=lambda s: s['depart_st_pancras'])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue