Add Eurostar Plus prices and NR advance fare support

- Eurostar scraper now fetches both Standard and Plus (PLUS class code)
  prices/seats in a single API call; each service dict gains plus_price
  and plus_seats fields
- GWR fares scraper gains fetch_advance() which makes two sets of
  paginated calls (standard advance + first-class advance) and returns
  cheapest per departure; shared _run_pages() generator reduces
  duplication in fetch()
- New /api/advance_fares/<station_crs>/<travel_date> endpoint returns
  advance fares as JSON, cached for 24 hours
- Results page gains NR ticket selector (Walk-on / Std Advance / 1st
  Advance) and Eurostar selector (Standard / Plus); total column is
  JS-computed from the selected combination with cheapest/priciest
  highlighting
- Load advance prices button fetches the API lazily; if advance fares
  are already cached they are embedded in the page and applied on load
  so the button is hidden automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2026-04-11 16:22:24 +01:00
parent 5583a20143
commit 89a536dfd3
8 changed files with 515 additions and 83 deletions

View file

@ -95,6 +95,28 @@
{% if trips or unreachable_morning_services %}
<div class="card">
{% if trips %}
<div class="filter-row" style="margin-bottom:0.75rem;margin-top:0">
<div>
<label for="nr-type-select" class="filter-label">NR ticket:</label>
<select id="nr-type-select" class="select-inline" onchange="updatePrices()">
<option value="walkon">Walk-on</option>
<option value="advanceStd" disabled>Std Advance</option>
<option value="advanceFirst" disabled>1st Class Advance</option>
</select>
</div>
<div>
<label for="es-type-select" class="filter-label">Eurostar:</label>
<select id="es-type-select" class="select-inline" onchange="updatePrices()">
<option value="esStd">Standard</option>
<option value="esPlus">Plus</option>
</select>
</div>
<button id="load-advance-btn" class="btn-secondary" onclick="loadAdvanceFares()">
Load advance prices
</button>
</div>
{% endif %}
<table class="results-table">
<thead>
<tr>
@ -108,11 +130,6 @@
{% if trips %}
{% set best_mins = trips | map(attribute='total_minutes') | min %}
{% 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 %}
{% for row in result_rows %}
{% if row.row_type == 'trip' and row.total_minutes <= best_mins + 5 and trips | length > 1 %}
@ -126,7 +143,16 @@
{% else %}
{% set row_class = '' %}
{% endif %}
<tr class="{{ row_class }}">
<tr class="{{ row_class }}"
{% if row.row_type == 'trip' %}
data-depart="{{ row.depart_bristol }}"
data-walkon="{{ row.ticket_price if row.ticket_price is not none else '' }}"
data-advance-std=""
data-advance-first=""
data-es-std="{{ row.eurostar_price if row.eurostar_price is not none else '' }}"
data-es-plus="{{ row.eurostar_plus_price if row.eurostar_plus_price is not none else '' }}"
{% endif %}
>
{% if row.row_type == 'trip' %}
<td>
<span class="font-bold nowrap">{{ row.depart_bristol }} &rarr; {{ row.arrive_paddington }}</span>
@ -144,6 +170,12 @@
{% else %}
<br><span class="text-sm text-muted">&ndash;</span>
{% endif %}
<span class="nr-advance-std-display" style="display:none">
<br><span class="text-xs text-muted">Adv std: </span><span class="text-sm nr-advance-std-text"></span>
</span>
<span class="nr-advance-first-display" style="display:none">
<br><span class="text-xs text-muted">Adv 1st: </span><span class="text-sm nr-advance-first-text"></span>
</span>
</td>
<td class="col-transfer" style="color:#4a5568">
<span class="nowrap">{{ row.connection_duration }}{% if row.connection_minutes < 80 %} <span title="Tight connection">⚠️</span>{% endif %}</span>
@ -167,12 +199,14 @@
{% endif %}
{% if row.eurostar_price is not none %}
<br><span class="text-sm font-bold">£{{ "%.2f"|format(row.eurostar_price) }}</span>
{% if row.eurostar_seats is not none %}
<span class="text-xs text-muted">{{ row.eurostar_seats }} at this price</span>
{% endif %}
<span class="text-xs text-muted">Std{% if row.eurostar_seats is not none %} &middot; {{ row.eurostar_seats }}{% endif %}</span>
{% else %}
<br><span class="text-sm text-muted">&ndash;</span>
{% endif %}
{% if row.eurostar_plus_price is not none %}
<br><span class="text-sm">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>
<span class="text-xs text-muted">Plus{% if row.eurostar_plus_seats is not none %} &middot; {{ row.eurostar_plus_seats }}{% endif %}</span>
{% endif %}
</td>
<td class="font-bold nowrap">
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %}
@ -182,9 +216,7 @@
{% else %}
<span class="text-blue">{{ row.total_duration }}</span>
{% endif %}
{% if row.total_price is not none %}
<br><span class="text-sm text-green" style="font-weight:700">£{{ "%.2f"|format(row.total_price) }}{% if min_price is defined and max_price is defined %}{% if row.total_price <= min_price + 10 %} <span title="Cheapest journey">🪙</span>{% elif row.total_price >= max_price - 10 %} <span title="Most expensive journey">💸</span>{% endif %}{% endif %}</span>
{% endif %}
<span class="total-price-span"></span>
</td>
{% else %}
<td>
@ -202,12 +234,14 @@
{% endif %}
{% if row.eurostar_price is not none %}
<br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_price) }}</span>
{% if row.eurostar_seats is not none %}
<span class="text-xs text-dimmed">{{ row.eurostar_seats }} at this price</span>
{% endif %}
<span class="text-xs text-dimmed">Std{% if row.eurostar_seats is not none %} &middot; {{ row.eurostar_seats }}{% endif %}</span>
{% else %}
<br><span class="text-sm text-dimmed">&ndash;</span>
{% endif %}
{% if row.eurostar_plus_price is not none %}
<br><span class="text-sm text-dimmed">£{{ "%.2f"|format(row.eurostar_plus_price) }}</span>
<span class="text-xs text-dimmed">Plus{% if row.eurostar_plus_seats is not none %} &middot; {{ row.eurostar_plus_seats }}{% endif %}</span>
{% endif %}
</td>
<td class="text-dimmed">&mdash;</td>
{% endif %}
@ -221,7 +255,7 @@
Paddington &rarr; St&nbsp;Pancras connection: {{ min_connection }}&ndash;{{ max_connection }}&nbsp;min.
GWR walk-on single prices from
<a href="https://www.gwr.com/" target="_blank" rel="noopener">gwr.com</a>.
Eurostar Standard prices are for 1 adult in GBP; always check
Eurostar Standard and Plus prices are for 1 adult in GBP; always check
<a href="{{ eurostar_url }}" target="_blank" rel="noopener">eurostar.com</a> to book.
&nbsp;&middot;&nbsp;
<a href="{{ rtt_station_url }}" target="_blank" rel="noopener">{{ departure_station_name }} departures on RTT</a>
@ -229,6 +263,117 @@
<a href="{{ rtt_url }}" target="_blank" rel="noopener">Paddington arrivals on RTT</a>
</p>
<script>
var stationCrs = {{ station_crs | tojson }};
var travelDate = {{ travel_date | tojson }};
var cachedAdvanceFares = {{ cached_advance_fares | tojson }};
function updatePrices() {
var nrSel = document.getElementById('nr-type-select').value;
var esSel = document.getElementById('es-type-select').value;
var rows = document.querySelectorAll('tr[data-depart]');
var validTotals = [];
rows.forEach(function(row) {
var nrVal = row.dataset[nrSel];
var esVal = row.dataset[esSel];
var totalSpan = row.querySelector('.total-price-span');
if (!totalSpan) return;
var nrPrice = nrVal !== '' && nrVal !== undefined ? parseFloat(nrVal) : null;
var esPrice = esVal !== '' && esVal !== undefined ? parseFloat(esVal) : null;
if (nrPrice !== null && esPrice !== null) {
var total = nrPrice + esPrice;
totalSpan.dataset.total = total;
validTotals.push(total);
} else {
delete totalSpan.dataset.total;
totalSpan.innerHTML = '';
}
});
var minTotal = validTotals.length ? Math.min.apply(null, validTotals) : null;
var maxTotal = validTotals.length ? Math.max.apply(null, validTotals) : null;
rows.forEach(function(row) {
var totalSpan = row.querySelector('.total-price-span');
if (!totalSpan || !('total' in totalSpan.dataset)) return;
var total = parseFloat(totalSpan.dataset.total);
var html = '<br><span class="text-sm text-green" style="font-weight:700">£' + total.toFixed(2);
if (minTotal !== null && maxTotal !== null && validTotals.length > 1) {
if (total <= minTotal + 10) html += ' <span title="Cheapest journey">\uD83E\uDE99</span>';
else if (total >= maxTotal - 10) html += ' <span title="Most expensive journey">\uD83D\uDCB8</span>';
}
html += '</span>';
totalSpan.innerHTML = html;
});
}
function loadAdvanceFares() {
var btn = document.getElementById('load-advance-btn');
btn.disabled = true;
btn.textContent = 'Loading\u2026';
fetch('/api/advance_fares/' + stationCrs + '/' + travelDate)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
btn.textContent = 'Error';
btn.disabled = false;
return;
}
applyAdvanceFares(data);
updatePrices();
})
.catch(function() {
btn.textContent = 'Error \u2014 try again';
btn.disabled = false;
});
}
function applyAdvanceFares(data) {
var hasStd = false, hasFirst = false;
document.querySelectorAll('tr[data-depart]').forEach(function(row) {
var dep = row.dataset.depart;
var fares = data[dep];
if (!fares) return;
if (fares.advance_std) {
row.dataset.advanceStd = fares.advance_std.price;
var span = row.querySelector('.nr-advance-std-display');
var text = row.querySelector('.nr-advance-std-text');
if (span && text) {
text.textContent = '\u00a3' + fares.advance_std.price.toFixed(2) + ' ' + fares.advance_std.ticket;
span.style.display = '';
}
hasStd = true;
}
if (fares.advance_1st) {
row.dataset.advanceFirst = fares.advance_1st.price;
var span1 = row.querySelector('.nr-advance-first-display');
var text1 = row.querySelector('.nr-advance-first-text');
if (span1 && text1) {
text1.textContent = '\u00a3' + fares.advance_1st.price.toFixed(2) + ' ' + fares.advance_1st.ticket;
span1.style.display = '';
}
hasFirst = true;
}
});
var nrSelect = document.getElementById('nr-type-select');
if (nrSelect) {
if (hasStd) nrSelect.querySelector('[value="advanceStd"]').disabled = false;
if (hasFirst) nrSelect.querySelector('[value="advanceFirst"]').disabled = false;
}
var btn = document.getElementById('load-advance-btn');
if (btn) btn.style.display = 'none';
}
document.addEventListener('DOMContentLoaded', function() {
if (cachedAdvanceFares) applyAdvanceFares(cachedAdvanceFares);
updatePrices();
});
</script>
{% else %}
<div class="card empty-state">
<p>No valid journeys found.</p>