diff --git a/app.py b/app.py index debc5a1..70b0884 100644 --- a/app.py +++ b/app.py @@ -2,9 +2,10 @@ Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination trains. """ -from flask import Flask, render_template, redirect, url_for, request, abort, jsonify +from flask import Flask, render_template, redirect, url_for, request, abort, jsonify, Response, stream_with_context from datetime import date, timedelta from pathlib import Path +import json import os from cache import get_cached, set_cached @@ -80,6 +81,10 @@ def index(): 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_NR_CLASSES = {'walkon', 'advance_std', 'advance_1st'} +VALID_ES_CLASSES = {'standard', 'plus'} +DEFAULT_NR_CLASS = 'walkon' +DEFAULT_ES_CLASS = 'standard' def _get_defaults(): @@ -111,6 +116,12 @@ def search(): max_conn = _parse_connection( request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS ) + nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS) + if nr_class not in VALID_NR_CLASSES: + nr_class = DEFAULT_NR_CLASS + es_class = request.args.get("es_class", DEFAULT_ES_CLASS) + if es_class not in VALID_ES_CLASSES: + es_class = DEFAULT_ES_CLASS if slug in DESTINATIONS and travel_date: return redirect( url_for( @@ -120,6 +131,8 @@ def search(): travel_date=travel_date, min_connection=None if min_conn == default_min else min_conn, max_connection=None if max_conn == default_max else max_conn, + nr_class=None if nr_class == DEFAULT_NR_CLASS else nr_class, + es_class=None if es_class == DEFAULT_ES_CLASS else es_class, ) ) return redirect(url_for("index")) @@ -141,11 +154,21 @@ def results(station_crs, slug, travel_date): max_connection = _parse_connection( request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS ) + nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS) + if nr_class not in VALID_NR_CLASSES: + nr_class = DEFAULT_NR_CLASS + es_class = request.args.get("es_class", DEFAULT_ES_CLASS) + if es_class not in VALID_ES_CLASSES: + es_class = DEFAULT_ES_CLASS - # Redirect to clean URL when both params are at their defaults - if ( - "min_connection" in request.args or "max_connection" in request.args - ) and min_connection == default_min and max_connection == default_max: + # Redirect to clean URL when all params are at their defaults + _clean_url_params = ["min_connection", "max_connection", "nr_class", "es_class"] + if any(k in request.args for k in _clean_url_params) and ( + min_connection == default_min + and max_connection == default_max + and nr_class == DEFAULT_NR_CLASS + and es_class == DEFAULT_ES_CLASS + ): return redirect( url_for("results", station_crs=station_crs, slug=slug, travel_date=travel_date) ) @@ -287,6 +310,39 @@ def results(station_crs, slug, travel_date): url_min = None if min_connection == default_min else min_connection url_max = None if max_connection == default_max else max_connection + url_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class + url_es = None if es_class == DEFAULT_ES_CLASS else es_class + + # Build per-row fare data for JS consumption + trip_fares = {} + for row in result_rows: + stp = row.get("depart_st_pancras") + if not stp: + continue + circle_svcs = row.get("circle_services") or [] + circle_fare = circle_svcs[0]["fare"] if circle_svcs else 0 + walkon = ( + {"price": row["ticket_price"], "ticket": row.get("ticket_name", "")} + if row.get("ticket_price") is not None + else None + ) + es_std = ( + {"price": row["eurostar_price"], "seats": row.get("eurostar_seats")} + if row.get("eurostar_price") is not None + else None + ) + es_plus = ( + {"price": row["eurostar_plus_price"], "seats": row.get("eurostar_plus_seats")} + if row.get("eurostar_plus_price") is not None + else None + ) + trip_fares[stp] = { + "depart_bristol": row.get("depart_bristol"), + "walkon": walkon, + "es_standard": es_std, + "es_plus": es_plus, + "circle_fare": circle_fare, + } return render_template( "results.html", @@ -316,7 +372,14 @@ def results(station_crs, slug, travel_date): default_max_connection=default_max, url_min_connection=url_min, url_max_connection=url_max, - cached_advance_fares=cached_advance_fares, + nr_class=nr_class, + es_class=es_class, + url_nr_class=url_nr, + url_es_class=url_es, + trip_fares_json=json.dumps(trip_fares), + advance_fares_json=json.dumps(cached_advance_fares), + advance_fares_api_url=url_for("api_advance_fares", station_crs=station_crs, travel_date=travel_date), + advance_fares_stream_url=url_for("api_advance_fares_stream", station_crs=station_crs, travel_date=travel_date), valid_min_connections=sorted(VALID_MIN_CONNECTIONS), valid_max_connections=sorted(VALID_MAX_CONNECTIONS), ) @@ -338,5 +401,43 @@ def api_advance_fares(station_crs, travel_date): return jsonify({"error": str(e)}), 500 +@app.route("/api/advance_fares_stream//") +def api_advance_fares_stream(station_crs, travel_date): + if station_crs not in STATION_BY_CRS: + abort(404) + cache_key = f"gwr_advance_{station_crs}_{travel_date}" + + def generate(): + cached = get_cached(cache_key, ttl=24 * 3600) + if cached is not None: + yield f"data: {json.dumps({'type': 'fares', 'fares': cached})}\n\n" + yield f"data: {json.dumps({'type': 'done'})}\n\n" + return + + accumulated: dict = {} + try: + for page_fares in gwr_fares_scraper.fetch_advance_streaming(station_crs, travel_date): + for dep_time, fare_data in page_fares.items(): + if dep_time not in accumulated: + accumulated[dep_time] = {"advance_std": None, "advance_1st": None} + if fare_data.get("advance_std"): + accumulated[dep_time]["advance_std"] = fare_data["advance_std"] + if fare_data.get("advance_1st"): + accumulated[dep_time]["advance_1st"] = fare_data["advance_1st"] + yield f"data: {json.dumps({'type': 'fares', 'fares': page_fares})}\n\n" + except Exception as e: + yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" + return + + set_cached(cache_key, accumulated) + yield f"data: {json.dumps({'type': 'done'})}\n\n" + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + if __name__ == "__main__": app.run(debug=True, host="0.0.0.0") diff --git a/scraper/gwr_fares.py b/scraper/gwr_fares.py index c36b0e9..4d9f203 100644 --- a/scraper/gwr_fares.py +++ b/scraper/gwr_fares.py @@ -85,7 +85,7 @@ def _run_pages(station_crs: str, travel_date: str, first_class: bool = False): body["standardclass"] = False resp = client.post(_API_URL, json=body) resp.raise_for_status() - data = resp.json().get("data", {}) + data = resp.json().get("data") or {} conversation_token = data.get("conversationToken") for journey in data.get("outwardOpenPureReturnFare", []): dep_iso = journey.get("departureTime", "") @@ -99,6 +99,39 @@ def _run_pages(station_crs: str, travel_date: str, first_class: bool = False): later = True +def _run_pages_batched(station_crs: str, travel_date: str, first_class: bool = False): + """ + Like _run_pages but yields one list of (dep_time, fares_list) per API page call, + allowing callers to stream results a page at a time. + """ + seen: set[str] = set() + with httpx.Client(headers=_headers(), timeout=30) as client: + conversation_token = None + later = False + for _ in range(_MAX_PAGES): + body = _request_body(station_crs, travel_date, conversation_token, later) + if first_class: + body["firstclass"] = True + body["standardclass"] = False + resp = client.post(_API_URL, json=body) + resp.raise_for_status() + data = resp.json().get("data") or {} + conversation_token = data.get("conversationToken") + batch = [] + for journey in data.get("outwardOpenPureReturnFare", []): + dep_iso = journey.get("departureTime", "") + dep_time = dep_iso[11:16] + if not dep_time or dep_time in seen: + continue + seen.add(dep_time) + batch.append((dep_time, journey.get("journeyFareDetails", []))) + if batch: + yield batch + if not data.get("showLaterOutward", False): + break + later = True + + def fetch(station_crs: str, travel_date: str) -> dict[str, dict]: """ Fetch GWR walk-on single fares from station_crs to London Paddington on travel_date. @@ -192,3 +225,69 @@ def fetch_advance(station_crs: str, travel_date: str) -> dict[str, dict]: } for t in all_times } + + +def fetch_advance_streaming(station_crs: str, travel_date: str): + """ + Generator yielding partial advance fare dicts one GWR API page at a time. + + Each yield is {dep_time: {'advance_std': dict|None, 'advance_1st': dict|None}}. + Two passes are made (standard class then first class); each page of results is + yielded immediately so callers can stream prices to clients as they arrive. + """ + # Pass 1: standard class advance fares + for batch in _run_pages_batched(station_crs, travel_date, first_class=False): + page: dict[str, dict] = {} + for dep_time, fares in batch: + cheapest = None + for fare in fares: + code = fare.get("ticketTypeCode") + if code in _WALKON_CODES: + continue + if not fare.get("isStandardClass"): + continue + price_pence = fare.get("fare", 0) + if cheapest is None or price_pence < cheapest["price_pence"]: + cheapest = { + "ticket": fare.get("ticketType", ""), + "price": price_pence / 100, + "price_pence": price_pence, + "code": code, + } + if cheapest: + page[dep_time] = { + "advance_std": { + "ticket": cheapest["ticket"], + "price": cheapest["price"], + "code": cheapest["code"], + }, + "advance_1st": None, + } + if page: + yield page + + # Pass 2: first class advance fares + for batch in _run_pages_batched(station_crs, travel_date, first_class=True): + page = {} + for dep_time, fares in batch: + cheapest = None + for fare in fares: + price_pence = fare.get("fare", 0) + if cheapest is None or price_pence < cheapest["price_pence"]: + cheapest = { + "ticket": fare.get("ticketType", ""), + "price": price_pence / 100, + "price_pence": price_pence, + "code": fare.get("ticketTypeCode"), + } + if cheapest: + page[dep_time] = { + "advance_std": None, + "advance_1st": { + "ticket": cheapest["ticket"], + "price": cheapest["price"], + "code": cheapest["code"], + }, + } + if page: + yield page diff --git a/templates/base.html b/templates/base.html index 00e6724..4d7e5e8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -281,6 +281,24 @@ .empty-state p:first-child { font-size: 1.1rem; margin-bottom: 0.5rem; } .empty-state p:last-child { font-size: 0.9rem; } + /* Ticket class button group */ + .btn-group { display: inline-flex; border: 1px solid #cbd5e0; border-radius: 4px; overflow: hidden; vertical-align: middle; } + .btn-group-option { padding: 0.28rem 0.65rem; font-size: 0.82rem; background: #fff; border: none; border-right: 1px solid #cbd5e0; cursor: pointer; color: #374151; white-space: nowrap; } + .btn-group-option:last-child { border-right: none; } + .btn-group-option.active { background: #00539f; color: #fff; font-weight: 600; } + .btn-group-option:hover:not(.active) { background: #f0f4f8; } + + /* Flash animation for total price */ + @keyframes price-flash { 0%,100% { background-color: transparent; } 40% { background-color: #fef08a; } } + .price-flash { animation: price-flash 0.7s ease-out; border-radius: 3px; } + + /* Loading state */ + #advance-loading { font-size: 0.82rem; color: #718096; margin-left: 0.5rem; } + + /* Fare lines — show all, dim inactive */ + .fare-line { display: block; line-height: 1.6; transition: opacity 0.15s; } + .fare-inactive { opacity: 0.4; } + /* Utilities */ .text-muted { color: #718096; } .text-dimmed { color: #a0aec0; } diff --git a/templates/results.html b/templates/results.html index 92642d0..a439983 100644 --- a/templates/results.html +++ b/templates/results.html @@ -15,10 +15,10 @@ {{ departure_station_name }} → {{ destination }}
- ← Prev {{ travel_date_display }} - Next →
@@ -30,7 +30,7 @@ {% else %} {{ destination_name }} {% endif %} {% endfor %} @@ -38,40 +38,225 @@
- - {% for mins in valid_min_connections %} {% endfor %}
- - {% for mins in valid_max_connections %} {% endfor %}
+
+
+ NR ticket: +
+ + + +
+ +
+
+ Eurostar: +
+ + +
+
+

{{ gwr_count }} GWR service{{ 's' if gwr_count != 1 }} @@ -95,28 +280,6 @@ {% if trips or unreachable_morning_services %}

- {% if trips %} -
-
- - -
-
- - -
- -
- {% endif %} @@ -143,16 +306,7 @@ {% else %} {% set row_class = '' %} {% endif %} - + {% if row.row_type == 'trip' %} {% else %} {% endif %} @@ -253,9 +382,9 @@

Paddington → St Pancras connection: {{ min_connection }}–{{ max_connection }} min. - GWR walk-on single prices from + GWR walk-on and advance prices from gwr.com. - Eurostar Standard and Plus prices are for 1 adult in GBP; always check + Eurostar Standard and Standard Premier prices are for 1 adult in GBP; always check eurostar.com to book.  ·  {{ departure_station_name }} departures on RTT @@ -263,116 +392,6 @@ Paddington arrivals on RTT

- - {% else %}

No valid journeys found.

{{ row.depart_bristol }} → {{ row.arrive_paddington }} @@ -164,18 +318,9 @@ {%- if row.arrive_platform %}Plat {{ row.arrive_platform }}{% endif %} {% endif %} - {% if row.ticket_price is not none %} -
£{{ "%.2f"|format(row.ticket_price) }} - {{ row.ticket_name }} - {% else %} -
- {% endif %} - - + + +
{{ row.connection_duration }}{% if row.connection_minutes < 80 %} ⚠️{% endif %} @@ -197,16 +342,8 @@ {%- if row.train_number %}{% for part in row.train_number.split(' + ') %}{{ part }}{% if not loop.last %} + {% endif %}{% endfor %}{% endif %} {% endif %} - {% if row.eurostar_price is not none %} -
£{{ "%.2f"|format(row.eurostar_price) }} - Std{% if row.eurostar_seats is not none %} · {{ row.eurostar_seats }}{% endif %} - {% else %} -
- {% endif %} - {% if row.eurostar_plus_price is not none %} -
£{{ "%.2f"|format(row.eurostar_plus_price) }} - Plus{% if row.eurostar_plus_seats is not none %} · {{ row.eurostar_plus_seats }}{% endif %} - {% endif %} + +
{% if row.total_minutes <= best_mins + 5 and trips | length > 1 %} @@ -216,7 +353,7 @@ {% else %} {{ row.total_duration }} {% endif %} - +
@@ -232,16 +369,8 @@ {%- if row.train_number %}{% for part in row.train_number.split(' + ') %}{{ part }}{% if not loop.last %} + {% endif %}{% endfor %}{% endif %} {% endif %} - {% if row.eurostar_price is not none %} -
£{{ "%.2f"|format(row.eurostar_price) }} - Std{% if row.eurostar_seats is not none %} · {{ row.eurostar_seats }}{% endif %} - {% else %} -
- {% endif %} - {% if row.eurostar_plus_price is not none %} -
£{{ "%.2f"|format(row.eurostar_plus_price) }} - Plus{% if row.eurostar_plus_seats is not none %} · {{ row.eurostar_plus_seats }}{% endif %} - {% endif %} + +