Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- run: npm run typecheck
- run: npm test
- name: Build the API from the fixtures
run: npm run build:api -- tests/fixtures/sncf-tgvmax.sample.json --gtfs renfe=tests/fixtures/gtfs-mini --from 2026-10-05
run: npm run build:api -- tests/fixtures/sncf-tgvmax.sample.json --gtfs renfe=tests/fixtures/gtfs-mini --gb tests/fixtures/gb-schedule.json --from 2026-10-05

live-feeds:
# Operator code changes are checked against the real feeds before they publish.
Expand All @@ -32,6 +32,11 @@ jobs:
cache: npm
- run: npm ci
- run: npm run fetch
env:
# Feeds behind a free sign-up; each is left out while its secret isn't set.
TRAFIKLAB_KEY: ${{ secrets.TRAFIKLAB_KEY }}
NETWORK_RAIL_EMAIL: ${{ secrets.NETWORK_RAIL_EMAIL }}
NETWORK_RAIL_PASSWORD: ${{ secrets.NETWORK_RAIL_PASSWORD }}
- name: Build the API from the real feeds
run: /usr/bin/time -f "%e s, %M KB peak memory" npm run build:api
env:
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ jobs:
cache: npm
- run: npm ci
- run: npm run fetch
env:
# Feeds behind a free sign-up; each is left out while its secret isn't set.
TRAFIKLAB_KEY: ${{ secrets.TRAFIKLAB_KEY }}
NETWORK_RAIL_EMAIL: ${{ secrets.NETWORK_RAIL_EMAIL }}
NETWORK_RAIL_PASSWORD: ${{ secrets.NETWORK_RAIL_PASSWORD }}
- run: npm run build:api
env:
# The national feeds (Switzerland, Germany) expand to millions of rows.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- Never mention AI tools or assistants anywhere that is pushed: no assistant co-author or
session trailers, no "generated with" lines, in commits, PRs, comments, code or docs.
- Merge pull requests with squash and merge.
- Before a commit: `npm run check` and `npm run build:api -- tests/fixtures/sncf-tgvmax.sample.json --gtfs renfe=tests/fixtures/gtfs-mini --from 2026-10-05`.
- Before a commit: `npm run check` and `npm run build:api -- tests/fixtures/sncf-tgvmax.sample.json --gtfs renfe=tests/fixtures/gtfs-mini --gb tests/fixtures/gb-schedule.json --from 2026-10-05`.
- `docs/format.md` is the contract with clients: change it in the same change as
`src/format.ts`, `src/compact.ts` or `src/api.ts`.
- Never claim a seat the data doesn't show: a pass whose seat no open data publishes
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ API per operator, built from each operator's own open timetable:
| `no` | Entur (GTFS): Vy, Go-Ahead and SJ trains in Norway | NLOD | Interrail |
| `fi` | Fintraffic / digitraffic.fi (GTFS): VR long-distance trains | CC BY 4.0 | Interrail |
| `ie` | National Transport Authority (GTFS): Irish Rail, without the DART | CC BY 4.0 | Interrail |
| `se` | Samtrafiken via Trafiklab, GTFS Sverige 2: long-distance trains, regional between their stations. Needs `TRAFIKLAB_KEY` | CC0 | Interrail |
| `gb` | Network Rail open data SCHEDULE feed: express trains and sleepers. Needs `NETWORK_RAIL_EMAIL` and `NETWORK_RAIL_PASSWORD` | Network Rail licence (attribution) | Interrail |

`se` and `gb` come from feeds behind a free sign-up: the publish workflow reads the keys
from repository secrets and leaves an operator out while its secret isn't set.

The SNCF part is built on top of [MAX-Finder](https://github.com/offware-apps/MAX-Finder):
the same feed, without MAX-Finder's "free MAX seat only" filter, so every train an
Expand Down Expand Up @@ -44,18 +49,19 @@ Live at <https://offware-apps.github.io/train-api/v1/index.json> once Pages is o
npm ci
npm run check # typecheck + tests
npm run build:api -- tests/fixtures/sncf-tgvmax.sample.json \
--gtfs renfe=tests/fixtures/gtfs-mini --from 2026-10-05 # build from the fixtures
--gtfs renfe=tests/fixtures/gtfs-mini --gb tests/fixtures/gb-schedule.json \
--from 2026-10-05 # build from the fixtures
```

## Next

- The front-end on top of MAX-Finder's search core, with Interrail as the pass.
- More operators through the same GTFS reader. Waiting on a free sign-up or a licence
check: Great Britain (Network Rail), Sweden (Trafiklab key), Belgium (SNCB), Austria
(ÖBB), Denmark (Rejseplanen licence).
check: Belgium (SNCB), Austria (ÖBB), Denmark (Rejseplanen licence).

Data: SNCF Open Data (Licence Ouverte), Renfe Data (CC BY 4.0), OVapi / NDOV (CC0),
DELFI e.V. / gtfs.de (CC BY 4.0), Trenitalia via the Italian NAP and Clément Desouche
(CC BY 4.0), opentransportdata.swiss, Entur (NLOD), Fintraffic / digitraffic.fi (CC BY 4.0),
National Transport Authority (CC BY 4.0).
National Transport Authority (CC BY 4.0), Samtrafiken (CC0), contains information of
Network Rail Infrastructure Limited.
Code: AGPL-3.0.
8 changes: 7 additions & 1 deletion docs/format.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ publishes it per pass in the compact snapshot format (`src/compact.ts`).

| Field | Type | Meaning |
|-------|------|---------|
| `operator` | string | `sncf`, `renfe`, `nl`, `de`, `it`, `ch`, `no`, `fi`, `ie` |
| `operator` | string | `sncf`, `renfe`, `nl`, `de`, `it`, `ch`, `no`, `fi`, `ie`, `se`, `gb` |
| `date` | `YYYY-MM-DD` | departure date |
| `origin`, `destination` | string | station names exactly as the operator publishes them |
| `depart`, `arrive` | `HH:MM` | local time; an arrival before the departure is the next day |
Expand Down Expand Up @@ -43,6 +43,12 @@ suburban lines, charters and trains that don't take Interrail (Flytoget, the DAR
panoramic trains) left out. `trainNo` is the trip's number, or the route's short name when
the feed has none (Germany and Norway give only the line, e.g. `ICE 1`, `F6`).

Great Britain (`gb`) comes from Network Rail's SCHEDULE feed instead of GTFS. Each train's
permanent schedule is replaced, date by date, by any short-term overlay or cancellation
that covers that date. Only public calls count, express trains and sleepers are kept
(stopping trains, Heathrow Express, Lumo and Eurostar are left out), `trainNo` is the
headcode (e.g. `1S07`) and `category` the train operator (LNER, Avanti West Coast…).

## Compact snapshot

Most of a 30-day timetable is the same service repeated every day, so each distinct
Expand Down
16 changes: 14 additions & 2 deletions scripts/build-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,32 @@
* Build the static API into public/ from the sources on disk (see `npm run fetch`):
* data/raw/sncf-tgvmax.json, or the feed file given as the first argument
* data/raw/<operator>/ each GTFS operator, or `--gtfs <operator>=<dir>`
* data/raw/gb-schedule.json.gz Network Rail's SCHEDULE extract, or `--gb <file>`
* `--from YYYY-MM-DD` sets the first day of the GTFS window (default: today, UTC).
* Sources that are missing are left out; the build fails only when none is there.
* Run: npm run build:api [-- <feed.json>] [--gtfs renfe=<dir>] [--from <date>]
* Run: npm run build:api [-- <feed.json>] [--gtfs renfe=<dir>] [--gb <file>] [--from <date>]
*/
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { buildIndex, buildOperatorApi, type ApiFile, type ApiIndex } from "../src/api";
import type { Train } from "../src/format";
import { readGtfs } from "../src/gtfs";
import { GTFS_OPERATORS } from "../src/operators";
import { GB_SOURCE, readNetworkRail } from "../src/operators/gb";
import { mapSncfFeed } from "../src/operators/sncf";

const args = process.argv.slice(2);
const gtfsDirs = new Map<string, string>();
let feedArg: string | undefined;
let gbArg: string | undefined;
let from = new Date().toISOString().slice(0, 10);
for (let i = 0; i < args.length; i++) {
const a = args[i] as string;
if (a === "--gtfs") {
const [id, dir] = (args[++i] ?? "").split("=");
if (id && dir) gtfsDirs.set(id, resolve(dir));
} else if (a === "--from") from = args[++i] ?? from;
} else if (a === "--gb") gbArg = resolve(args[++i] ?? "");
else if (a === "--from") from = args[++i] ?? from;
else feedArg = a;
}
const outDir = resolve("public");
Expand Down Expand Up @@ -76,6 +80,14 @@ for (const { op } of GTFS_OPERATORS) {
publish(op.id, op.source, trains);
}

// Great Britain: Network Rail's SCHEDULE extract, when `npm run fetch` had the account to get it.
const gbPath = gbArg ?? ["gb-schedule.json.gz", "gb-schedule.json"].map((f) => resolve("data/raw", f)).find((p) => existsSync(p));
if (gbPath) {
const trains = await readNetworkRail(gbPath, from);
if (trains.length > 0) publish("gb", GB_SOURCE, trains);
else console.error(`[build-api] gb: no trains from ${from}; left out.`);
}

if (entries.length === 0) {
console.error("[build-api] no source to publish.");
process.exit(1);
Expand Down
83 changes: 66 additions & 17 deletions scripts/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { pipeline } from "node:stream/promises";
import type { ReadableStream as WebReadableStream } from "node:stream/web";
import { dirname, join, resolve } from "node:path";
import { GTFS_OPERATORS } from "../src/operators";
import { NETWORK_RAIL_ENV, NETWORK_RAIL_URL } from "../src/operators/gb";
import { SNCF_FEED_URL } from "../src/operators/sncf";

const RAW = resolve("data/raw");
Expand Down Expand Up @@ -50,25 +51,43 @@ async function fetchSncf(): Promise<void> {
/** Files no reader uses; shapes.txt alone runs to gigabytes in some national feeds. */
const UNUSED = ["shapes.txt", "transfers.txt", "frequencies.txt", "pathways.txt", "levels.txt", "translations.txt"];

async function fetchGtfs(id: string, url: string, headers?: Record<string, string>): Promise<void> {
const tmp = join(RAW, `${id}.zip`);
const dir = join(RAW, id);
const next = `${dir}.new`;
mkdirSync(RAW, { recursive: true });
// Streamed to disk: national feeds run to hundreds of MB.
const bytes = await withRetries(id, async () => {
/** Stream `url` to `out`, retrying; `ok` checks the file's first bytes. Returns its size. */
async function download(
id: string,
url: string,
out: string,
ok: (head: Buffer) => boolean,
headers?: Record<string, string>,
): Promise<number> {
mkdirSync(dirname(out), { recursive: true });
return withRetries(id, async () => {
const res = await fetch(url, { headers, signal: AbortSignal.timeout(900_000) });
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
await pipeline(Readable.fromWeb(res.body as WebReadableStream), createWriteStream(tmp));
const head = Buffer.alloc(2);
const fd = openSync(tmp, "r");
readSync(fd, head, 0, 2, 0);
closeSync(fd);
const size = statSync(tmp).size;
// "PK": a zip archive, not an error page.
if (size < 100 || head[0] !== 0x50 || head[1] !== 0x4b) throw new Error("not a zip archive");
await pipeline(Readable.fromWeb(res.body as WebReadableStream), createWriteStream(out));
const size = statSync(out).size;
// Not an error page.
if (size < 100 || !ok(firstBytes(out))) throw new Error("unexpected file type");
return size;
});
}

function firstBytes(path: string): Buffer {
const head = Buffer.alloc(2);
const fd = openSync(path, "r");
readSync(fd, head, 0, 2, 0);
closeSync(fd);
return head;
}

const isZip = (h: Buffer): boolean => h[0] === 0x50 && h[1] === 0x4b;
const isGzip = (h: Buffer): boolean => h[0] === 0x1f && h[1] === 0x8b;

async function fetchGtfs(id: string, url: string, headers?: Record<string, string>): Promise<void> {
const tmp = join(RAW, `${id}.zip`);
const dir = join(RAW, id);
const next = `${dir}.new`;
// Streamed to disk: national feeds run to hundreds of MB. "PK": a zip archive.
const bytes = await download(id, url, tmp, isZip, headers);
rmSync(next, { recursive: true, force: true });
execFileSync("unzip", ["-q", "-o", tmp, "-d", next, "-x", ...UNUSED], { stdio: ["ignore", "inherit", "ignore"] });
rmSync(tmp, { force: true });
Expand All @@ -78,17 +97,47 @@ async function fetchGtfs(id: string, url: string, headers?: Record<string, strin
console.log(`[fetch] ${id}: ${(bytes / 1e6).toFixed(1)} MB → ${dir}`);
}

/** A source behind a sign-up whose key isn't set: skipped quietly, not an error. */
class Missing extends Error {}

/** Great Britain: Network Rail's daily full SCHEDULE extract, behind a free account. */
async function fetchGb(): Promise<void> {
const user = process.env[NETWORK_RAIL_ENV.user];
const password = process.env[NETWORK_RAIL_ENV.password];
if (!user || !password) throw new Missing(`${NETWORK_RAIL_ENV.user} and ${NETWORK_RAIL_ENV.password}`);
const auth = `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`;
// The answer redirects to a signed download URL; fetch drops the credentials on the way.
const tmp = join(RAW, "gb-schedule.new");
const bytes = await download("gb", NETWORK_RAIL_URL, tmp, (h) => isGzip(h) || h[0] === 0x7b, { Authorization: auth });
// Gzipped as published, or already inflated if the host sent it with Content-Encoding.
const gzipped = isGzip(firstBytes(tmp));
const out = join(RAW, gzipped ? "gb-schedule.json.gz" : "gb-schedule.json");
rmSync(join(RAW, "gb-schedule.json.gz"), { force: true });
rmSync(join(RAW, "gb-schedule.json"), { force: true });
execFileSync("mv", [tmp, out]);
console.log(`[fetch] gb: ${(bytes / 1e6).toFixed(1)} MB → ${out}`);
}

let ok = 0;
const jobs: [string, () => Promise<void>][] = [
["sncf", fetchSncf],
...GTFS_OPERATORS.map(({ op, url, headers }): [string, () => Promise<void>] => [op.id, () => fetchGtfs(op.id, url, headers)]),
...GTFS_OPERATORS.map(({ op, url, headers, keyEnv }): [string, () => Promise<void>] => [
op.id,
() => {
const key = keyEnv ? process.env[keyEnv] : undefined;
if (keyEnv && !key) throw new Missing(keyEnv);
return fetchGtfs(op.id, key ? url.replace("{key}", encodeURIComponent(key)) : url, headers);
},
]),
["gb", fetchGb],
];
for (const [id, job] of jobs) {
try {
await job();
ok++;
} catch (err) {
console.error(`[fetch] ${id}: skipped (${String(err)})`);
if (err instanceof Missing) console.log(`[fetch] ${id}: no ${err.message} set; left out.`);
else console.error(`[fetch] ${id}: skipped (${String(err)})`);
}
}
if (ok === 0) process.exit(1);
75 changes: 46 additions & 29 deletions src/gtfs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,12 @@ function isoDate(d: string): string {
return `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`;
}

function addDays(date: string, n: number): string {
export function addDays(date: string, n: number): string {
return new Date(Date.parse(`${date}T12:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10);
}

/** Shared strings for the values every row repeats, so millions of rows don't each hold a copy. */
function interner(): (s: string) => string {
export function interner(): (s: string) => string {
const seen = new Map<string, string>();
return (s) => {
const hit = seen.get(s);
Expand Down Expand Up @@ -160,13 +160,55 @@ async function serviceDates(dir: string, from: string, days: number): Promise<Ma
return out;
}

interface Call {
/** One stop of a train: minutes after midnight of its service day, past 1440 the next day. */
export interface Call {
seq: number;
station: string;
arr: number;
dep: number;
}

/**
* One `Train` per pair of the calls (in order), per date the train runs: "this train
* goes from A to B that day". A departure past 24:00 is dated the next calendar day.
*/
export function pushStopPairs(
out: Train[],
str: (s: string) => string,
operator: string,
calls: Call[],
runs: Iterable<string>,
trainNo: string,
category: string,
passes: Partial<Record<PassId, PassAvailability>>,
): void {
const no = str(trainNo);
const dates = [...runs];
for (let i = 0; i < calls.length; i++) {
for (let j = i + 1; j < calls.length; j++) {
const a = calls[i] as Call;
const b = calls[j] as Call;
if (a.station === b.station || b.arr < a.dep) continue;
const shift = Math.floor(a.dep / 1440);
const depart = str(hhmm(a.dep));
const arrive = str(hhmm(b.arr));
for (const d of dates) {
out.push({
operator,
date: str(shift ? addDays(d, shift) : d),
origin: a.station,
destination: b.station,
depart,
arrive,
trainNo: no,
category,
passes,
});
}
}
}
}

/**
* Read one operator's GTFS directory into trains running in [from, from + days).
* The train number is the trip's short name, or the route's when the feed gives none
Expand Down Expand Up @@ -234,32 +276,7 @@ export async function readGtfs(dir: string, op: GtfsOperator, from: string, days
list.sort((a, b) => a.seq - b.seq);
const narrow = op.narrow?.(trip.family) ?? false;
const keep = list.filter((c, i) => c.station && c.station !== list[i - 1]?.station && (!narrow || wide.has(c.station)));
const passes = op.passes(trip.family);
const trainNo = str(trip.trainNo);
for (let i = 0; i < keep.length; i++) {
for (let j = i + 1; j < keep.length; j++) {
const a = keep[i] as Call;
const b = keep[j] as Call;
if (a.station === b.station || b.arr < a.dep) continue;
// A departure past 24:00 belongs to the next calendar day.
const shift = Math.floor(a.dep / 1440);
const depart = str(hhmm(a.dep));
const arrive = str(hhmm(b.arr));
for (const d of trip.runs) {
out.push({
operator: op.id,
date: str(shift ? addDays(d, shift) : d),
origin: a.station,
destination: b.station,
depart,
arrive,
trainNo,
category: trip.family,
passes,
});
}
}
}
pushStopPairs(out, str, op.id, keep, trip.runs, trip.trainNo, trip.family, op.passes(trip.family));
}
return out;
}
Loading
Loading