Compare commits
6 commits
6b044b9493
...
d089d3d716
| Author | SHA1 | Date | |
|---|---|---|---|
| d089d3d716 | |||
| e7695a5e49 | |||
| 19656f412a | |||
| 06afd57957 | |||
| 4e4202e220 | |||
| df94e822ae |
7 changed files with 50 additions and 31 deletions
20
app.py
20
app.py
|
|
@ -16,6 +16,12 @@ RTT_PADDINGTON_URL = (
|
||||||
"?stp=WVS&show=pax-calls&order=wtt"
|
"?stp=WVS&show=pax-calls&order=wtt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RTT_BRISTOL_URL = (
|
||||||
|
"https://www.realtimetrains.co.uk/search/detailed/"
|
||||||
|
"gb-nr:BRI/to/gb-nr:PAD/{date}/0000-2359"
|
||||||
|
"?stp=WVS&show=pax-calls&order=wtt"
|
||||||
|
)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
DESTINATIONS = {
|
DESTINATIONS = {
|
||||||
|
|
@ -30,10 +36,16 @@ DESTINATIONS = {
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
return render_template('index.html', destinations=DESTINATIONS, today=today)
|
return render_template(
|
||||||
|
'index.html',
|
||||||
|
destinations=DESTINATIONS,
|
||||||
|
today=today,
|
||||||
|
valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
|
||||||
|
valid_max_connections=sorted(VALID_MAX_CONNECTIONS),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
VALID_MIN_CONNECTIONS = {50, 60, 70, 80, 90, 100, 110, 120}
|
VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120}
|
||||||
VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180}
|
VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -154,8 +166,9 @@ def results(slug, travel_date):
|
||||||
next_date = (dt + timedelta(days=1)).isoformat()
|
next_date = (dt + timedelta(days=1)).isoformat()
|
||||||
travel_date_display = dt.strftime('%A %-d %B %Y')
|
travel_date_display = dt.strftime('%A %-d %B %Y')
|
||||||
|
|
||||||
eurostar_url = eurostar_scraper.timetable_url(destination) + f"?date={travel_date}"
|
eurostar_url = eurostar_scraper.search_url(destination, travel_date)
|
||||||
rtt_url = RTT_PADDINGTON_URL.format(date=travel_date)
|
rtt_url = RTT_PADDINGTON_URL.format(date=travel_date)
|
||||||
|
rtt_bristol_url = RTT_BRISTOL_URL.format(date=travel_date)
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'results.html',
|
'results.html',
|
||||||
|
|
@ -175,6 +188,7 @@ def results(slug, travel_date):
|
||||||
error=error,
|
error=error,
|
||||||
eurostar_url=eurostar_url,
|
eurostar_url=eurostar_url,
|
||||||
rtt_url=rtt_url,
|
rtt_url=rtt_url,
|
||||||
|
rtt_bristol_url=rtt_bristol_url,
|
||||||
min_connection=min_connection,
|
min_connection=min_connection,
|
||||||
max_connection=max_connection,
|
max_connection=max_connection,
|
||||||
valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
|
valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,14 @@ def _slugify_station_name(name: str) -> str:
|
||||||
return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
|
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:
|
def timetable_url(destination: str) -> str:
|
||||||
dest_id = DESTINATION_STATION_IDS[destination]
|
dest_id = DESTINATION_STATION_IDS[destination]
|
||||||
dest_slug = _slugify_station_name(destination)
|
dest_slug = _slugify_station_name(destination)
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
Minimum connection time (Paddington → St Pancras)
|
Minimum connection time (Paddington → St Pancras)
|
||||||
</label>
|
</label>
|
||||||
<select id="min_connection" name="min_connection" class="form-control">
|
<select id="min_connection" name="min_connection" class="form-control">
|
||||||
{% for mins in [50, 60, 70, 80, 90, 100, 110, 120] %}
|
{% for mins in valid_min_connections %}
|
||||||
<option value="{{ mins }}" {% if mins == 50 %}selected{% endif %}>{{ mins }} min</option>
|
<option value="{{ mins }}" {% if mins == 50 %}selected{% endif %}>{{ mins }} min</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -58,7 +58,7 @@
|
||||||
Maximum connection time (Paddington → St Pancras)
|
Maximum connection time (Paddington → St Pancras)
|
||||||
</label>
|
</label>
|
||||||
<select id="max_connection" name="max_connection" class="form-control">
|
<select id="max_connection" name="max_connection" class="form-control">
|
||||||
{% for mins in [60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180] %}
|
{% for mins in valid_max_connections %}
|
||||||
<option value="{{ mins }}" {% if mins == 110 %}selected{% endif %}>{{ mins }} min</option>
|
<option value="{{ mins }}" {% if mins == 110 %}selected{% endif %}>{{ mins }} min</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
{% if unreachable_morning_services %}
|
{% if unreachable_morning_services %}
|
||||||
·
|
·
|
||||||
<span style="color:#718096">
|
<span style="color:#718096">
|
||||||
{{ unreachable_morning_services | length }} morning 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 %}
|
||||||
|
|
@ -116,6 +116,11 @@
|
||||||
{% if trips %}
|
{% if trips %}
|
||||||
{% set best_mins = trips | map(attribute='total_minutes') | min %}
|
{% set best_mins = trips | map(attribute='total_minutes') | min %}
|
||||||
{% set worst_mins = trips | map(attribute='total_minutes') | max %}
|
{% set worst_mins = trips | map(attribute='total_minutes') | max %}
|
||||||
|
{% set priced_trips = trips | selectattr('total_price') | list %}
|
||||||
|
{% if priced_trips | length > 1 %}
|
||||||
|
{% set min_price = priced_trips | map(attribute='total_price') | min %}
|
||||||
|
{% set max_price = priced_trips | map(attribute='total_price') | max %}
|
||||||
|
{% endif %}
|
||||||
{% 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 %}
|
||||||
|
|
@ -137,14 +142,14 @@
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem">
|
<td style="padding:0.6rem 0.8rem">
|
||||||
{{ row.arrive_paddington }}
|
{{ row.arrive_paddington }}
|
||||||
<span style="font-size:0.8rem;color:#718096">({{ row.gwr_duration }})</span>
|
<span style="font-size:0.8rem;color:#718096;white-space:nowrap">({{ row.gwr_duration }})</span>
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;white-space:nowrap">
|
<td style="padding:0.6rem 0.8rem;white-space: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 style="font-size:0.75rem;color:#718096">{{ row.ticket_name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-transfer" style="padding:0.6rem 0.8rem;color:#4a5568">
|
<td class="col-transfer" style="padding:0.6rem 0.8rem;color:#4a5568">
|
||||||
{{ row.connection_duration }}
|
{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
<td style="padding:0.6rem 0.8rem;font-weight:600">
|
||||||
{{ row.depart_st_pancras }}
|
{{ row.depart_st_pancras }}
|
||||||
|
|
@ -163,14 +168,14 @@
|
||||||
</td>
|
</td>
|
||||||
<td style="padding:0.6rem 0.8rem;font-weight:600;white-space: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 %}
|
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
|
||||||
<span style="color:#276749" title="Fastest option">{{ 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 %}
|
{% elif row.total_minutes >= worst_mins - 5 and trips | length > 1 %}
|
||||||
<span style="color:#c53030" title="Slowest option">{{ row.total_duration }} 🐢</span>
|
<span style="color:#c53030" title="Slowest journey">{{ row.total_duration }} 🐢</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span style="color:#00539f">{{ row.total_duration }}</span>
|
<span style="color:#00539f">{{ 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) }}</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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -205,13 +210,12 @@
|
||||||
|
|
||||||
<p style="margin-top:1rem;font-size:0.82rem;color:#718096">
|
<p style="margin-top:1rem;font-size:0.82rem;color:#718096">
|
||||||
Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min.
|
||||||
{% if unreachable_morning_services %}
|
|
||||||
Morning means Eurostar departures before 12:00 from St Pancras.
|
|
||||||
{% endif %}
|
|
||||||
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
|
||||||
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
|
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
|
||||||
·
|
·
|
||||||
|
<a href="{{ rtt_bristol_url }}" target="_blank" rel="noopener">Bristol departures on RTT</a>
|
||||||
|
·
|
||||||
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
|
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,8 +155,8 @@ def test_results_marks_trips_within_five_minutes_of_fastest_and_slowest(monkeypa
|
||||||
html = resp.get_data(as_text=True)
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert html.count('title="Fastest option"') == 2
|
assert html.count('title="Fastest journey"') == 2
|
||||||
assert html.count('title="Slowest option"') == 2
|
assert html.count('title="Slowest journey"') == 2
|
||||||
assert '4h 50m ⚡' in html
|
assert '4h 50m ⚡' in html
|
||||||
assert '4h 55m ⚡' in html
|
assert '4h 55m ⚡' in html
|
||||||
assert '5h 20m 🐢' in html
|
assert '5h 20m 🐢' in html
|
||||||
|
|
@ -211,11 +211,10 @@ def test_results_shows_unreachable_morning_eurostar_services(monkeypatch):
|
||||||
html = resp.get_data(as_text=True)
|
html = resp.get_data(as_text=True)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert '1 morning service 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 'Unavailable from Bristol' in html
|
||||||
assert 'Morning means Eurostar departures before 12:00 from St Pancras.' in html
|
|
||||||
assert html.index('09:30') < html.index('10:15')
|
assert html.index('09:30') < html.index('10:15')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -267,6 +266,6 @@ def test_results_can_show_only_unreachable_morning_services(monkeypatch):
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert 'No valid journeys found.' not in html
|
assert 'No valid journeys found.' not in html
|
||||||
assert '1 morning 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 'Unavailable from Bristol' in html
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,9 @@ def test_connection_duration_in_trip():
|
||||||
assert trips[0]['connection_duration'] == '1h 16m'
|
assert trips[0]['connection_duration'] == '1h 16m'
|
||||||
|
|
||||||
|
|
||||||
def test_unreachable_morning_eurostars_lists_only_unreachable_morning_services():
|
def test_find_unreachable_eurostars_excludes_connectable_services():
|
||||||
|
# GWR arrives 08:45; default min=50/max=110 → viable window 09:35–10:35.
|
||||||
|
# 09:30 too early, 10:15 connectable, 12:30 beyond max connection.
|
||||||
gwr = [
|
gwr = [
|
||||||
{'depart_bristol': '07:00', 'arrive_paddington': '08:45'},
|
{'depart_bristol': '07:00', 'arrive_paddington': '08:45'},
|
||||||
]
|
]
|
||||||
|
|
@ -143,7 +145,7 @@ def test_unreachable_morning_eurostars_lists_only_unreachable_morning_services()
|
||||||
|
|
||||||
unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE)
|
unreachable = find_unreachable_morning_eurostars(gwr, eurostar, DATE)
|
||||||
|
|
||||||
assert [service['depart_st_pancras'] for service in unreachable] == ['09:30']
|
assert [s['depart_st_pancras'] for s in unreachable] == ['09:30', '12:30']
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -205,7 +207,7 @@ def test_combine_trips_includes_ticket_fields():
|
||||||
assert 'ticket_code' in t
|
assert 'ticket_code' in t
|
||||||
|
|
||||||
|
|
||||||
def test_unreachable_morning_eurostars_returns_empty_when_morning_service_is_reachable():
|
def test_find_unreachable_eurostars_returns_empty_when_all_connectable():
|
||||||
gwr = [
|
gwr = [
|
||||||
{'depart_bristol': '07:00', 'arrive_paddington': '08:45'},
|
{'depart_bristol': '07:00', 'arrive_paddington': '08:45'},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from datetime import datetime, timedelta, time as _time
|
||||||
MIN_CONNECTION_MINUTES = 50
|
MIN_CONNECTION_MINUTES = 50
|
||||||
MAX_CONNECTION_MINUTES = 110
|
MAX_CONNECTION_MINUTES = 110
|
||||||
MAX_GWR_MINUTES = 110
|
MAX_GWR_MINUTES = 110
|
||||||
MORNING_CUTOFF_HOUR = 12
|
|
||||||
DATE_FMT = '%Y-%m-%d'
|
DATE_FMT = '%Y-%m-%d'
|
||||||
TIME_FMT = '%H:%M'
|
TIME_FMT = '%H:%M'
|
||||||
|
|
||||||
|
|
@ -128,6 +127,7 @@ def combine_trips(
|
||||||
'arrive_paddington': gwr['arrive_paddington'],
|
'arrive_paddington': gwr['arrive_paddington'],
|
||||||
'headcode': gwr.get('headcode', ''),
|
'headcode': gwr.get('headcode', ''),
|
||||||
'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_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)),
|
'connection_duration': _fmt_duration(int((dep_stp - arr_pad).total_seconds() / 60)),
|
||||||
'depart_st_pancras': es['depart_st_pancras'],
|
'depart_st_pancras': es['depart_st_pancras'],
|
||||||
'arrive_destination': es['arrive_destination'],
|
'arrive_destination': es['arrive_destination'],
|
||||||
|
|
@ -155,14 +155,6 @@ def find_unreachable_morning_eurostars(
|
||||||
unreachable = []
|
unreachable = []
|
||||||
|
|
||||||
for es in eurostar_trains:
|
for es in eurostar_trains:
|
||||||
try:
|
|
||||||
dep_stp = _parse_dt(travel_date, es['depart_st_pancras'])
|
|
||||||
except (ValueError, KeyError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if dep_stp.hour >= MORNING_CUTOFF_HOUR:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if any(
|
if any(
|
||||||
_is_viable_connection(
|
_is_viable_connection(
|
||||||
gwr,
|
gwr,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue