From 7b9f884c0062a2449fbc8d53b9ed4eedb2518475 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:29:20 +0000 Subject: [PATCH] Add a developer guide, an OpenAPI spec and search examples Anyone can now build on the public API without reading the build code: endpoints, the compact format with decoders in JS and Python, caching, licences and the credit each operator asks for, and a stability promise for /v1/. docs/openapi.yaml describes every file and was checked against a fixture build. --- README.md | 6 + docs/developers.md | 223 +++++++++++++++++++++++++++++++++++ docs/openapi.yaml | 282 ++++++++++++++++++++++++++++++++++++++++++++ examples/search.mjs | 59 +++++++++ examples/search.py | 69 +++++++++++ 5 files changed, 639 insertions(+) create mode 100644 docs/developers.md create mode 100644 docs/openapi.yaml create mode 100644 examples/search.mjs create mode 100644 examples/search.py diff --git a/README.md b/README.md index 92253c4..f781b18 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ change between operators aren't joined here. No open data says whether an Interrail pass-holder seat is left, so Interrail trains are published as running, with the seat `unknown`. +## Use the API + +Free, no key, CORS open, refreshed daily: start with the [developer guide](docs/developers.md) +(endpoints, JS and Python examples, licences and credits, stability promise) and the +[OpenAPI spec](docs/openapi.yaml). + ## How it works 1. `npm run fetch` downloads every source to `data/raw/`: the SNCF `tgvmax` feed (every diff --git a/docs/developers.md b/docs/developers.md new file mode 100644 index 0000000..7e82a8f --- /dev/null +++ b/docs/developers.md @@ -0,0 +1,223 @@ +# Build on train-api + +train-api publishes, every day, which trains a rail-pass holder can take: MAX JEUNE and MAX +SENIOR on SNCF, and Interrail on nine European operators. It is plain JSON on GitHub +Pages, so anyone can use it, from a browser or a server, for free: + +- **No key, no sign-up, no rate limit of ours.** All public data stays public. +- **CORS is open.** GitHub Pages answers every origin with `Access-Control-Allow-Origin: *`, + so a web page on any site can `fetch` it directly. +- **Stable.** Everything under `/v1/` follows the [promise below](#stability). +- **Machine-readable.** [`openapi.yaml`](openapi.yaml) (OpenAPI 3.1, JSON Schema 2020-12) + describes every file. Load it in Swagger UI, Postman or a code generator. + +Base URL: `https://offware-apps.github.io/train-api/v1` + +## Quick start + +```sh +# What is published, how fresh, which passes per operator +curl -s https://offware-apps.github.io/train-api/v1/index.json + +# Station ids for one operator +curl -s https://offware-apps.github.io/train-api/v1/sncf/stations.json + +# Every MAX JEUNE train leaving Paris in the next ~30 days +curl -s https://offware-apps.github.io/train-api/v1/sncf/max-jeune/from/paris-intramuros.json +``` + +Ready-to-run search, no dependencies: + +```sh +node examples/search.mjs sncf max-jeune paris-intramuros lyon-intramuros 2026-10-20 +python3 examples/search.py renfe interrail madrid-puerta-de-atocha-almudena-grandes zaragoza-delicias 2026-10-20 +``` + +## Endpoints + +| Path | Content | +|------|---------| +| `index.json` | build time (`updatedAt`), and per operator: `source` with its licence, `dates` covered, `stationCount`, and each pass with `seatKnown`, `bookingUrl`, `note`, `trainCount` | +| `/stations.json` | `[{ "id": "paris-intramuros", "name": "PARIS (intramuros)" }]`, sorted by name | +| `//from/.json` | the trains bookable with the pass that leave one station ([compact](#compact-snapshot)) | +| `//all.json` | every train bookable with the pass ([compact](#compact-snapshot)); several MB for the large feeds | + +Operators today: `sncf`, `renfe`, `nl`, `de`, `it`, `ch`, `no`, `fi`, `ie`. Passes: +`max-jeune` and `max-senior` (SNCF only), `interrail` (all). Read both lists from +`index.json` rather than hard-coding them: operators and passes get added. + +A path that doesn't exist returns **404** with an HTML body. For `from/.json` +that just means no train leaving that station is bookable with that pass in the window. + +### Search by origin, destination and date + +1. Look the two stations up in `/stations.json` (by `id` or by `name`). +2. Fetch `//from/.json`. +3. Expand it and keep the rows whose `destination` equals the destination's `name` and + whose `date` is the day you want. + +Each row is one train between two of its stops, so a train Paris → Lyon → Marseille shows +up as Paris → Lyon, Paris → Marseille and Lyon → Marseille. Trips that change trains, or +change operators, aren't joined. + +## Compact snapshot + +Timetable files store each service once with a bitmask of the dates it runs on: + +```json +{ + "v": 1, + "dates": ["2026-10-05", "2026-10-06", "2026-10-07"], + "stations": ["PARIS (intramuros)", "LYON (intramuros)"], + "axes": ["SUD EST"], + "trains": [[0, 1, "08:00", "10:00", "6601", 0, "5"]] +} +``` + +A train is `[originIdx, destinationIdx, depart, arrive, trainNo, axeIdx, dateMask]`: + +- `originIdx`, `destinationIdx` point into this file's `stations`; `axeIdx` into its + `axes` (the SNCF axe, or a train family such as `AVE`, `ICE`, `Intercity`), `-1` for none. +- `dateMask` is hex. Hex digit `j` holds dates `4j` to `4j+3` of this file's `dates`, low bit + first. Above, `"5"` = binary `0101`: the train runs on `dates[0]` and `dates[2]`. +- `dates`, `stations` and `axes` belong to each file; don't reuse indexes across files. + +Expand it to one row per train per date: + +```js +function decodeCompact(c) { + const rows = []; + for (const [o, d, depart, arrive, trainNo, axe, mask] of c.trains) { + for (let j = 0; j < mask.length; j++) { + const n = parseInt(mask[j], 16); + for (let b = 0; b < 4; b++) { + const date = c.dates[j * 4 + b]; + if (!(n & (1 << b)) || date === undefined) continue; + rows.push({ date, origin: c.stations[o], destination: c.stations[d], depart, arrive, trainNo, + category: axe >= 0 ? c.axes[axe] : undefined }); + } + } + } + return rows; +} +``` + +```python +def decode_compact(c): + rows = [] + for o, d, depart, arrive, train_no, axe, mask in (t[:7] for t in c["trains"]): + for j, digit in enumerate(mask): + n = int(digit, 16) + for b in range(4): + i = j * 4 + b + if n & (1 << b) and i < len(c["dates"]): + rows.append({"date": c["dates"][i], "origin": c["stations"][o], + "destination": c["stations"][d], "depart": depart, + "arrive": arrive, "trainNo": train_no, + "category": c["axes"][axe] if axe >= 0 else None}) + return rows +``` + +In TypeScript, copy [`src/compact.ts`](../src/compact.ts): it has no imports. + +## What the fields mean + +- **Times** are `HH:MM`, local time at the station. An `arrive` earlier than `depart` is on + the next day. `date` is the departure date from `origin`. +- **Station names** are exactly as each operator publishes them, so the same city is spelled + differently by different operators (`BRUXELLES MIDI`, `Bruxelles-Midi`…). Station ids + are per operator and are made from the name, so a station renamed by its operator gets a + new id. Read ids from `stations.json` instead of storing them for good. +- **`trainNo`** is the train number, or the line name when a feed has none (Germany and + Norway: `ICE 1`, `F6`). +- **Seats.** For MAX passes (`seatKnown: true`), a train is listed only when SNCF showed a + free MAX seat when the feed was read, about once a day; seats can go between two + builds. For Interrail (`seatKnown: false`), no open data publishes pass-holder seats: + the train runs and takes the pass, but the reservation quota may be sold out. Never tell + users a seat is free when `seatKnown` is false. +- **`note`** carries pass rules no timetable can check (MAX SENIOR's peak periods, + Interrail's residence rule). Show it next to results. +- **Window.** About 30 days ahead. `index.json` lists the exact `dates` per operator. + +The [open train format](format.md) explains how each operator's feed is mapped and which +trains are kept. + +## Freshness and caching + +- The **Publish API** workflow rebuilds everything every day at 13:30 UTC, after SNCF's + midday refresh, and after every change merged to `main`. `updatedAt` in `index.json` is + the build time. +- GitHub Pages sends `ETag`, `Last-Modified` and `Cache-Control: max-age=600`, and gzips + responses. Conditional requests (`If-None-Match`) are cheap. +- Please cache: re-fetching more than once an hour gains nothing. For heavy use (a busy + backend, a bulk import), fetch `all.json` once a day and serve your own copy, or build the + API yourself (below). GitHub Pages has a soft limit of 100 GB of traffic a month for the + whole site, shared by everyone. + +## MAX seat history + +The SNCF feed only shows today's MAX seats. A daily snapshot is kept on the public `data` +branch, for computing seat odds by weekday, time or days before travel: + +``` +https://raw.githubusercontent.com/offware-apps/train-api/data/archive/max/2026/2026-10-06.json.gz +``` + +Gzipped JSON, one file per day; format in [archive.md](archive.md). The archive is outside +`/v1/` and its promise, though its format isn't expected to change. + +## Licences and credit + +The code is AGPL-3.0. The data keeps each source's licence. Every source below allows +reuse, commercial use included, as long as you credit it where its licence asks. When you +show or redistribute an operator's trains, credit it, for example in your app's footer or +about page: + +| Operator | Source | Licence | Credit line | +|----------|--------|---------|-------------| +| `sncf` | SNCF Open Data, `tgvmax` dataset | [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/) | "SNCF Open Data", with the date of the data (`updatedAt`) | +| `renfe` | Renfe Data | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "Renfe Data" | +| `nl` | OVapi, NDOV (Dutch national timetable) | [CC0](https://creativecommons.org/publicdomain/zero/1.0/) | none required; "OVapi / NDOV" appreciated | +| `de` | DELFI e.V. via gtfs.de | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "DELFI e.V. / gtfs.de" | +| `it` | Trenitalia via the Italian National Access Point, GTFS by Clément Desouche | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "Trenitalia, Italian NAP; GTFS by Clément Desouche" | +| `ch` | opentransportdata.swiss | [terms of use](https://opentransportdata.swiss/en/terms-of-use/): free use, source cited | "opentransportdata.swiss" | +| `no` | Entur | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Entur" | +| `fi` | Fintraffic, digitraffic.fi | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "Fintraffic / digitraffic.fi" | +| `ie` | National Transport Authority | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "National Transport Authority" | + +The `source` field in `index.json` repeats each source and licence, so an app can build its +credit line from it. A link back to this repository is welcome and never required. This +table is a summary: the source's own licence text is what applies. + +## Stability + +Everything under `/v1/` keeps working for the clients written against it: + +- **Additive changes only.** New operators, passes, files, object fields and trailing + elements in a compact train may appear at any time. Ignore what you don't know: unknown + keys, and anything after the seventh element of a train. +- **Never in v1:** removing or renaming a path or field, changing a field's type or + meaning, or changing how `dateMask` is read. +- **A breaking change gets a new version** at `/v2/`. `/v1/` then keeps being published, + with data refreshed daily, for at least 6 months, and the change is announced in a + GitHub issue labelled `api` and in this file. +- **Not covered:** which trains a source publishes, station names and ids (they follow the + operator), an operator dropped because its source stops publishing or changes its + licence, and a day's build failing (the previous day's files stay up). + +## Self-hosting + +The whole API is a build step, so you can run your own copy, on your own schedule: + +```sh +git clone https://github.com/offware-apps/train-api && cd train-api +npm ci && npm run fetch && npm run build:api # writes public/v1/ +``` + +The national feeds need about 12 GB of memory (`NODE_OPTIONS=--max-old-space-size=12288`). +Serve `public/` from any static host. + +## Help and requests + +Open an issue on [offware-apps/train-api](https://github.com/offware-apps/train-api/issues) +for a bug, a wrong train, an operator to add, or a field you need. diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..a5bde78 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,282 @@ +openapi: 3.1.0 +info: + title: train-api + version: "1" + summary: Open train timetables for rail passes (MAX JEUNE, MAX SENIOR, Interrail), one static API per operator. + description: | + Static JSON files on GitHub Pages, rebuilt every day from each operator's open data. + No key, no sign-up, CORS open to every origin. A path that isn't published returns + 404 (GitHub Pages' HTML page, not JSON). + + Guide, examples, licences and the stability promise: docs/developers.md. + The timetable files use the compact snapshot format (CompactSnapshot below). + license: + name: AGPL-3.0-only (code). Data keeps each source's licence, see docs/developers.md. + identifier: AGPL-3.0-only + contact: + url: https://github.com/offware-apps/train-api/issues +servers: + - url: https://offware-apps.github.io/train-api/v1 +paths: + /index.json: + get: + operationId: getIndex + summary: What is published, how fresh, and each operator's passes + responses: + "200": + description: The index. + content: + application/json: + schema: { $ref: "#/components/schemas/Index" } + /{operator}/stations.json: + get: + operationId: getStations + summary: Every station one operator's trains call at, with its URL id + parameters: + - $ref: "#/components/parameters/operator" + responses: + "200": + description: Stations sorted by name. + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Station" } + "404": { $ref: "#/components/responses/NotPublished" } + /{operator}/{pass}/all.json: + get: + operationId: getAllTrains + summary: Every train bookable with one pass on one operator + description: Can be several megabytes for the large national feeds; prefer `from/.json` for a search. + parameters: + - $ref: "#/components/parameters/operator" + - $ref: "#/components/parameters/pass" + responses: + "200": + description: Compact snapshot. + content: + application/json: + schema: { $ref: "#/components/schemas/CompactSnapshot" } + "404": { $ref: "#/components/responses/NotPublished" } + /{operator}/{pass}/from/{stationId}.json: + get: + operationId: getTrainsFrom + summary: The trains bookable with one pass that leave one station + description: | + A search by origin, destination and date fetches this file and filters it on the + destination's name and the date. 404 means no bookable train leaves that station + with that pass in the window. + parameters: + - $ref: "#/components/parameters/operator" + - $ref: "#/components/parameters/pass" + - name: stationId + in: path + required: true + description: A station `id` from `/{operator}/stations.json`. + schema: { type: string, pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" } + example: paris-intramuros + responses: + "200": + description: Compact snapshot. + content: + application/json: + schema: { $ref: "#/components/schemas/CompactSnapshot" } + "404": { $ref: "#/components/responses/NotPublished" } + /archive/max/{year}/{date}.json.gz: + servers: + - url: https://raw.githubusercontent.com/offware-apps/train-api/data + description: The public `data` branch. + get: + operationId: getMaxSeatArchive + summary: One day's MAX seat flags from the SNCF feed (gzipped JSON) + description: Outside /v1 and outside this version's promise; see docs/archive.md. + parameters: + - name: year + in: path + required: true + schema: { type: string, pattern: "^\\d{4}$" } + example: "2026" + - name: date + in: path + required: true + description: The day the feed was read (UTC). + schema: { type: string, format: date } + example: "2026-10-06" + responses: + "200": + description: Gzipped JSON. raw.githubusercontent.com serves it as a plain file, so gunzip it yourself. + content: + application/gzip: + schema: { $ref: "#/components/schemas/MaxArchive" } + "404": { $ref: "#/components/responses/NotPublished" } +components: + parameters: + operator: + name: operator + in: path + required: true + description: An operator `id` from index.json. New operators are added over time, so read the list from there. + schema: { type: string, pattern: "^[a-z0-9-]+$" } + examples: + sncf: { value: sncf } + renfe: { value: renfe } + nl: { value: nl } + de: { value: de } + it: { value: it } + ch: { value: ch } + "no": { value: "no" } + fi: { value: fi } + ie: { value: ie } + pass: + name: pass + in: path + required: true + description: A pass `id` listed under the operator in index.json. More passes may be added. + schema: { type: string, pattern: "^[a-z0-9-]+$" } + examples: + max-jeune: { value: max-jeune } + max-senior: { value: max-senior } + interrail: { value: interrail } + responses: + NotPublished: + description: Not published (unknown operator, pass or station, or nothing bookable). The body is an HTML page. + schemas: + Date: + type: string + pattern: "^\\d{4}-\\d{2}-\\d{2}$" + examples: ["2026-10-05"] + Time: + type: string + description: Local time at the station, 24-hour. An arrival earlier than the departure is on the next day. + pattern: "^\\d{2}:\\d{2}$" + examples: ["08:00"] + Index: + type: object + required: [version, updatedAt, operators] + properties: + version: + const: 1 + updatedAt: + type: string + format: date-time + description: When this build ran (UTC). + operators: + type: array + items: { $ref: "#/components/schemas/Operator" } + Operator: + type: object + required: [id, source, dates, stationCount, passes] + properties: + id: + type: string + description: Used in every path below `/v1//`. + examples: [sncf] + source: + type: string + description: The data source and its licence, for display and credit. + dates: + type: array + description: Every departure date with at least one train, sorted. + items: { $ref: "#/components/schemas/Date" } + stationCount: { type: integer, minimum: 0 } + passes: + type: array + description: Only passes with at least one bookable train are listed. + items: { $ref: "#/components/schemas/Pass" } + Pass: + type: object + required: [id, name, seatKnown, bookingUrl, trainCount] + properties: + id: + type: string + examples: [max-jeune, max-senior, interrail] + name: + type: string + examples: [MAX JEUNE] + seatKnown: + type: boolean + description: | + True when the data says a seat is free (MAX: every listed train had a free MAX + seat when the feed was read). False when no open data says (Interrail): the + train runs, but a pass-holder seat may be sold out. + bookingUrl: { type: string, format: uri } + note: + type: string + description: Pass rules the data can't check. Show it to your users. + trainCount: + type: integer + minimum: 0 + description: Bookable trains, counted once per date. + Station: + type: object + required: [id, name] + properties: + id: + type: string + description: URL slug of the name, unique within the operator. Not shared across operators. + examples: [paris-intramuros] + name: + type: string + description: The name exactly as the operator publishes it; the compact snapshots use this. + examples: [PARIS (intramuros)] + CompactSnapshot: + type: object + description: | + Each distinct service once, with a bitmask of the dates it runs on. Expand with + `decodeCompact` (src/compact.ts, no imports) or the examples in docs/developers.md. + Indexes point into this file's own `dates`, `stations` and `axes`, which differ + from file to file. + required: [v, dates, stations, axes, trains] + properties: + v: + const: 1 + dates: + type: array + description: The departure dates found in this file, sorted. + items: { $ref: "#/components/schemas/Date" } + stations: + type: array + description: Station names, as in stations.json. + items: { type: string } + axes: + type: array + description: Train categories (SNCF axe, or train family such as AVE, ICE, Intercity). + items: { type: string } + trains: + type: array + items: { $ref: "#/components/schemas/CompactTrain" } + CompactTrain: + type: array + description: | + [originIdx, destinationIdx, depart, arrive, trainNo, axeIdx, dateMask]. + Clients must ignore any element after the seventh. + prefixItems: + - { type: integer, minimum: 0, description: Index into `stations`. } + - { type: integer, minimum: 0, description: Index into `stations`. } + - { $ref: "#/components/schemas/Time" } + - { $ref: "#/components/schemas/Time" } + - { type: string, description: Train number, or the line name when the feed has none. } + - { type: integer, minimum: -1, description: Index into `axes`, or -1 for none. } + - type: string + pattern: "^[0-9a-f]*$" + description: Hex; digit j holds dates 4j..4j+3 of `dates`, low bit first. + minItems: 7 + MaxArchive: + type: object + required: [snapshot, start, days, services] + properties: + snapshot: { $ref: "#/components/schemas/Date" } + start: { $ref: "#/components/schemas/Date" } + days: { type: integer, minimum: 0 } + services: + type: array + items: + type: array + description: "[origin, destination, trainNo, depart, flags]; flags has one character per day from `start`: 1 free MAX seat, 0 none, . not running." + prefixItems: + - { type: string } + - { type: string } + - { type: string } + - { $ref: "#/components/schemas/Time" } + - { type: string, pattern: "^[01.]*$" } + minItems: 5 diff --git a/examples/search.mjs b/examples/search.mjs new file mode 100644 index 0000000..f22b589 --- /dev/null +++ b/examples/search.mjs @@ -0,0 +1,59 @@ +// Find the trains a pass holder can take between two stations on one day. +// Node 18+ or any modern browser, no dependencies. +// +// node examples/search.mjs sncf max-jeune paris-intramuros lyon-intramuros 2026-10-20 +// +// Set TRAIN_API to use a mirror or a local build (e.g. http://localhost:8000/v1). + +const BASE = process.env.TRAIN_API ?? "https://offware-apps.github.io/train-api/v1"; + +/** Expand a compact snapshot to one row per train per date (same as src/compact.ts). */ +export function decodeCompact(c) { + const rows = []; + for (const [o, d, depart, arrive, trainNo, axe, mask] of c.trains) { + for (let j = 0; j < mask.length; j++) { + const n = parseInt(mask[j], 16); + for (let b = 0; b < 4; b++) { + if (!(n & (1 << b))) continue; + const date = c.dates[j * 4 + b]; + if (date === undefined) continue; + const row = { date, origin: c.stations[o], destination: c.stations[d], depart, arrive, trainNo }; + if (axe >= 0) row.category = c.axes[axe]; + rows.push(row); + } + } + } + return rows; +} + +/** Parsed JSON, or null when the file doesn't exist (404). */ +async function getJson(path) { + const res = await fetch(`${BASE}/${path}`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`${res.status} for ${path}`); + return res.json(); +} + +export async function search(operator, pass, fromId, toId, date) { + const stations = await getJson(`${operator}/stations.json`); + if (!stations) throw new Error(`no operator "${operator}"; see ${BASE}/index.json`); + const to = stations.find((s) => s.id === toId); + if (!to) throw new Error(`no station "${toId}" for ${operator}; see ${BASE}/${operator}/stations.json`); + // No file: no bookable train leaves this station with this pass. + const snapshot = await getJson(`${operator}/${pass}/from/${fromId}.json`); + if (!snapshot) return []; + return decodeCompact(snapshot) + .filter((t) => t.destination === to.name && t.date === date) + .sort((a, b) => a.depart.localeCompare(b.depart)); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const [operator, pass, from, to, date] = process.argv.slice(2); + if (!date) { + console.error("usage: node examples/search.mjs "); + process.exit(2); + } + const trains = await search(operator, pass, from, to, date); + if (trains.length === 0) console.log("No bookable train."); + for (const t of trains) console.log(`${t.depart} → ${t.arrive} ${t.trainNo} ${t.origin} → ${t.destination}`); +} diff --git a/examples/search.py b/examples/search.py new file mode 100644 index 0000000..a16ab93 --- /dev/null +++ b/examples/search.py @@ -0,0 +1,69 @@ +"""Find the trains a pass holder can take between two stations on one day. + +Python 3.8+, standard library only. + + python3 examples/search.py sncf max-jeune paris-intramuros lyon-intramuros 2026-10-20 + +Set TRAIN_API to use a mirror or a local build (e.g. http://localhost:8000/v1). +""" +import json +import os +import sys +import urllib.error +import urllib.request + +BASE = os.environ.get("TRAIN_API", "https://offware-apps.github.io/train-api/v1") + + +def decode_compact(c): + """Expand a compact snapshot to one row per train per date (same as src/compact.ts).""" + rows = [] + for o, d, depart, arrive, train_no, axe, mask in (t[:7] for t in c["trains"]): + for j, digit in enumerate(mask): + n = int(digit, 16) + for b in range(4): + i = j * 4 + b + if not n & (1 << b) or i >= len(c["dates"]): + continue + row = { + "date": c["dates"][i], + "origin": c["stations"][o], + "destination": c["stations"][d], + "depart": depart, + "arrive": arrive, + "trainNo": train_no, + } + if axe >= 0: + row["category"] = c["axes"][axe] + rows.append(row) + return rows + + +def get_json(path): + with urllib.request.urlopen(f"{BASE}/{path}") as res: + return json.load(res) + + +def search(operator, pass_id, from_id, to_id, date): + stations = get_json(f"{operator}/stations.json") + to = next((s for s in stations if s["id"] == to_id), None) + if to is None: + raise SystemExit(f'no station "{to_id}" for {operator}; see {BASE}/{operator}/stations.json') + try: + snapshot = get_json(f"{operator}/{pass_id}/from/{from_id}.json") + except urllib.error.HTTPError as e: + if e.code == 404: # no bookable train leaves this station for this pass + return [] + raise + trains = [t for t in decode_compact(snapshot) if t["destination"] == to["name"] and t["date"] == date] + return sorted(trains, key=lambda t: t["depart"]) + + +if __name__ == "__main__": + if len(sys.argv) != 6: + raise SystemExit("usage: python3 examples/search.py ") + trains = search(*sys.argv[1:]) + if not trains: + print("No bookable train.") + for t in trains: + print(f'{t["depart"]} → {t["arrive"]} {t["trainNo"]} {t["origin"]} → {t["destination"]}')