Skip to content
Open
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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jobs:
- 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 --gb tests/fixtures/gb-schedule.json --from 2026-10-05
- name: Link the stations to places from a fixture
run: npm run build:places -- --index tests/fixtures/places-index.json && test -s public/v1/places.json

live-feeds:
# Operator code changes are checked against the real feeds before they publish.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
env:
# The national feeds (Switzerland, Germany) expand to millions of rows.
NODE_OPTIONS: --max-old-space-size=12288
- run: npm run build:places
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
Expand Down
21 changes: 21 additions & 0 deletions docs/developers.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ python3 examples/search.py renfe interrail madrid-puerta-de-atocha-almudena-gran
| `<operator>/stations.json` | `[{ "id": "paris-intramuros", "name": "PARIS (intramuros)" }]`, sorted by name |
| `<operator>/<pass>/from/<stationId>.json` | the trains bookable with the pass that leave one station ([compact](#compact-snapshot)) |
| `<operator>/<pass>/all.json` | every train bookable with the pass ([compact](#compact-snapshot)); several MB for the large feeds |
| `places.json` | the town or city each station is in ([below](#guides-and-sights)) |

Operators today: `sncf`, `renfe`, `nl`, `de`, `it`, `ch`, `no`, `fi`, `ie`, with `gb` and
`se` added once their feeds' free accounts are set up. Passes:
Expand Down Expand Up @@ -192,6 +193,26 @@ The `source` field in `index.json` repeats each source and licence, so an app ca
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.

## Guides and sights

`places.json` links each station to the town or city it's in, so an app can show what to
see there. The places come from the [Offware shared data](https://github.com/offware-apps/data),
built from Wikidata (CC0); this file only holds the link:

```json
{
"placeUrl": "https://offware-apps.github.io/data/v1/places/{id}.json",
"license": "CC0-1.0",
"attribution": "Data from Wikidata (CC0). …",
"stations": { "sncf/lyon-intramuros": "lyon", "renfe/valencia-joaquin-sorolla": "valencia" }
}
```

Replace `{id}` in `placeUrl` with a station's place to get its links to free guides
(Wikivoyage, Wikipedia) and its sights. A station is matched by name, so one no place
matched is left out. Each place names the Wikidata item it comes from: an error is fixed
there, and reaches the shared data and this file on the next weekly build.

## Stability

Everything under `/v1/` keeps working for the clients written against it:
Expand Down
1 change: 1 addition & 0 deletions docs/format.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,6 @@ All static files under `/v1/`:
| `<operator>/stations.json` | `[{ id, name }]`; `id` is the URL slug used below |
| `<operator>/<pass>/all.json` | every bookable train for the pass (compact) |
| `<operator>/<pass>/from/<id>.json` | the bookable trains leaving one station (compact) |
| `places.json` | the town or city each station is in, as a place of the [Offware shared data](https://github.com/offware-apps/data) |

A search by origin, destination and date fetches `from/<origin>.json` and filters it.
30 changes: 30 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,36 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/CompactSnapshot" }
"404": { $ref: "#/components/responses/NotPublished" }
/places.json:
get:
operationId: getStationPlaces
summary: The town or city each station is in, for its guides and sights
description: |
Places come from Offware's shared data (Wikidata, CC0). Replace `{id}` in
`placeUrl` with a station's place id to read that place. Not published when the
dataset couldn't be read at build time.
responses:
"200":
description: Station to place links.
content:
application/json:
schema:
type: object
required: [version, updatedAt, placeUrl, license, attribution, stations]
properties:
version: { type: integer, const: 1 }
updatedAt: { type: string, format: date-time }
placeUrl:
type: string
example: https://offware-apps.github.io/data/v1/places/{id}.json
license: { type: string, example: CC0-1.0 }
attribution: { type: string }
stations:
type: object
description: '"<operator>/<station id>" to place id. Stations no place matched are left out.'
additionalProperties: { type: string }
example: { sncf/lyon-intramuros: lyon }
"404": { $ref: "#/components/responses/NotPublished" }
/archive/max/{year}/{date}.json.gz:
servers:
- url: https://raw.githubusercontent.com/offware-apps/train-api/data
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"test": "vitest run",
"fetch": "tsx scripts/fetch.ts",
"build:api": "tsx scripts/build-api.ts",
"build:places": "tsx scripts/build-places.ts",
"check": "npm run typecheck && npm test"
},
"devDependencies": {
Expand Down
60 changes: 60 additions & 0 deletions scripts/build-places.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Write public/v1/places.json: the town or city each station is in (see src/places.ts).
* Runs after `npm run build:api`, whose stations.json files it reads.
*
* It reads the shared data's places/index.json, or a saved copy with `--index <file>`. When the
* data can't be reached it writes nothing and exits cleanly: the timetables still publish.
*
* Run: npm run build:places [-- --index <places/index.json>]
*/
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { PLACES_BASE, buildStationPlaces, type PlacesIndex, type StationRef } from "../src/places";

const args = process.argv.slice(2);
const indexArg = args.includes("--index") ? args[args.indexOf("--index") + 1] : undefined;
const base = process.env.PLACES_BASE ?? PLACES_BASE;
const v1 = resolve("public/v1");

function readStations(): StationRef[] {
if (!existsSync(v1)) return [];
const out: StationRef[] = [];
for (const op of readdirSync(v1)) {
const file = join(v1, op, "stations.json");
if (!existsSync(file)) continue;
for (const s of JSON.parse(readFileSync(file, "utf-8")) as { id: string; name: string }[]) {
out.push({ operator: op, id: s.id, name: s.name });
}
}
return out;
}

async function readIndex(): Promise<PlacesIndex> {
if (indexArg) return JSON.parse(readFileSync(resolve(indexArg), "utf-8")) as PlacesIndex;
const res = await fetch(`${base}/places/index.json`, {
headers: { "User-Agent": "train-api/0.1 (https://github.com/offware-apps/train-api)" },
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as PlacesIndex;
}

const stations = readStations();
if (stations.length === 0) {
console.error("[places] no stations.json under public/v1; run `npm run build:api` first.");
process.exit(1);
}

let index: PlacesIndex;
try {
index = await readIndex();
// Only a CC0 dataset is republished here, with its attribution.
if (index.license !== "CC0-1.0" || !Array.isArray(index.places)) throw new Error("unexpected shape or licence");
} catch (err) {
console.error(`[places] shared data not read, places.json left out (${String(err)})`);
process.exit(0);
}

const out = buildStationPlaces(index, stations, new Date().toISOString(), base);
writeFileSync(join(v1, "places.json"), JSON.stringify(out), "utf-8");
console.log(`[places] ${Object.keys(out.stations).length} of ${stations.length} stations matched to a place`);
137 changes: 137 additions & 0 deletions src/places.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Which town or city each station is in, so an app can show a station's guides and sights.
* The places themselves come from the Offware shared data
* (https://github.com/offware-apps/data, Wikidata, CC0); this API only links stations to
* them, in one file:
*
* v1/places.json { "<operator>/<station id>": "<place id>" }, plus where to read a place
*
* See docs/format.md.
*/

export const PLACES_BASE = "https://offware-apps.github.io/data/v1";

/** Country each operator's stations are in first; a station abroad still matches by name. */
export const OPERATOR_COUNTRY: Record<string, string> = {
sncf: "FR",
renfe: "ES",
nl: "NL",
de: "DE",
it: "IT",
ch: "CH",
no: "NO",
fi: "FI",
ie: "IE",
se: "SE",
gb: "GB",
};

/** Countries a train in this API can reach. A place elsewhere never matches ("Paris", Texas). */
export const EUROPE = new Set([
"AD", "AL", "AT", "BA", "BE", "BG", "CH", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR",
"GB", "GR", "HR", "HU", "IE", "IS", "IT", "LI", "LT", "LU", "LV", "MC", "ME", "MK", "MT",
"NL", "NO", "PL", "PT", "RO", "RS", "SE", "SI", "SK", "SM", "TR", "XK",
]);

/** A town or city as the shared data's places/index.json lists it. */
export interface IndexPlace {
id: string;
name: string;
names: string[];
countryIso2: string;
wikidata: string;
population: number;
}

/** The shared data's places/index.json. */
export interface PlacesIndex {
license: string;
attribution: string;
places: IndexPlace[];
}

export interface StationRef {
operator: string;
id: string;
name: string;
}

/** v1/places.json. */
export interface StationPlaces {
version: 1;
updatedAt: string;
/** A place's file: replace {id} with the place id. */
placeUrl: string;
license: string;
attribution: string;
/** "<operator>/<station id>" → place id. A station no place matched is left out. */
stations: Record<string, string>;
}

/** Abbreviations operators use in station names ("ST ETIENNE"), read the same on both sides. */
const ABBREVIATIONS: Record<string, string> = { st: "saint", ste: "sainte" };

/** Words of a name, accents folded: "Bruxelles-Midi" → ["bruxelles", "midi"]. */
export function words(s: string): string[] {
return s
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/ß/g, "ss")
.split(/[^a-z0-9]+/)
.filter(Boolean)
.map((w) => ABBREVIATIONS[w] ?? w);
}

/**
* The place a station is in, from its name: the place whose name the station name starts
* with, word for word ("LYON PART DIEU" → Lyon, "Frankfurt(Main)Hbf" → Frankfurt). The
* longest name wins ("Saint-Etienne" over "Saint"); then a place in the operator's own
* country; then the largest. A name only found inside the station name doesn't count.
*/
export function placeMatcher(places: IndexPlace[]): (station: string, country?: string) => IndexPlace | undefined {
const byFirst = new Map<string, { words: string[]; place: IndexPlace }[]>();
for (const place of places) {
if (!EUROPE.has(place.countryIso2)) continue;
for (const n of new Set([place.name, ...(place.names ?? [])])) {
const w = words(n);
if (w.length === 0) continue;
const list = byFirst.get(w[0]!) ?? [];
list.push({ words: w, place });
byFirst.set(w[0]!, list);
}
}
return (station, country) => {
const sw = words(station);
let best: { len: number; place: IndexPlace } | undefined;
const home = (p: IndexPlace): number => Number(p.countryIso2 === country);
const better = (len: number, place: IndexPlace): boolean => {
if (!best) return true;
if (len !== best.len) return len > best.len;
if (home(place) !== home(best.place)) return home(place) > home(best.place);
return place.population > best.place.population;
};
for (const cand of byFirst.get(sw[0] ?? "") ?? []) {
if (cand.words.length > sw.length || !cand.words.every((w, i) => sw[i] === w)) continue;
if (better(cand.words.length, cand.place)) best = { len: cand.words.length, place: cand.place };
}
return best?.place;
};
}

export function buildStationPlaces(index: PlacesIndex, stations: StationRef[], updatedAt: string, base = PLACES_BASE): StationPlaces {
const match = placeMatcher(index.places);
const out: Record<string, string> = {};
for (const s of stations) {
const place = match(s.name, OPERATOR_COUNTRY[s.operator]);
if (place) out[`${s.operator}/${s.id}`] = place.id;
}
return {
version: 1,
updatedAt,
placeUrl: `${base}/places/{id}.json`,
license: index.license,
attribution: index.attribution,
stations: Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b))),
};
}
17 changes: 17 additions & 0 deletions tests/fixtures/places-index.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"version": 1,
"updatedAt": "2026-10-12T00:00:00.000Z",
"source": "Wikidata",
"license": "CC0-1.0",
"attribution": "Data from Wikidata (CC0). Guide links point to Wikivoyage and Wikipedia articles, which are CC BY-SA.",
"places": [
{ "id": "paris", "name": "Paris", "names": ["Paris", "Parigi", "París"], "countryIso2": "FR", "wikidata": "Q90", "population": 2102650, "lat": 48.85, "lon": 2.35, "sights": 15 },
{ "id": "paris-us", "name": "Paris", "names": ["Paris"], "countryIso2": "US", "wikidata": "Q830149", "population": 24171, "lat": 33.66, "lon": -95.55, "sights": 0 },
{ "id": "lyon", "name": "Lyon", "names": ["Lyon", "Lione"], "countryIso2": "FR", "wikidata": "Q456", "population": 522250, "lat": 45.76, "lon": 4.84, "sights": 15 },
{ "id": "valencia", "name": "Valencia", "names": ["Valencia", "València", "Valence"], "countryIso2": "ES", "wikidata": "Q8818", "population": 807693, "lat": 39.47, "lon": -0.38, "sights": 15 },
{ "id": "valence", "name": "Valence", "names": ["Valence"], "countryIso2": "FR", "wikidata": "Q6599", "population": 64726, "lat": 44.93, "lon": 4.89, "sights": 6 },
{ "id": "saint-etienne", "name": "Saint-Étienne", "names": ["Saint-Étienne"], "countryIso2": "FR", "wikidata": "Q42716", "population": 173089, "lat": 45.43, "lon": 4.39, "sights": 9 },
{ "id": "frankfurt", "name": "Frankfurt", "names": ["Frankfurt", "Frankfurt am Main", "Francfort-sur-le-Main"], "countryIso2": "DE", "wikidata": "Q1794", "population": 773068, "lat": 50.11, "lon": 8.68, "sights": 15 },
{ "id": "madrid", "name": "Madrid", "names": ["Madrid"], "countryIso2": "ES", "wikidata": "Q2807", "population": 3332035, "lat": 40.42, "lon": -3.7, "sights": 15 }
]
}
49 changes: 49 additions & 0 deletions tests/places.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { buildStationPlaces, placeMatcher, type PlacesIndex } from "../src/places";

const index = JSON.parse(readFileSync(new URL("./fixtures/places-index.json", import.meta.url), "utf-8")) as PlacesIndex;
const match = placeMatcher(index.places);
const id = (station: string, country?: string): string | undefined => match(station, country)?.id;

describe("station to place", () => {
it("matches the place a station name starts with", () => {
expect(id("LYON PART DIEU", "FR")).toBe("lyon");
expect(id("PARIS (intramuros)", "FR")).toBe("paris");
expect(id("Frankfurt(Main)Hbf", "DE")).toBe("frankfurt");
expect(id("Madrid-Puerta de Atocha", "ES")).toBe("madrid");
});

it("folds accents and abbreviations, and prefers the longest name", () => {
expect(id("ST ETIENNE CHATEAUCREUX", "FR")).toBe("saint-etienne");
expect(id("SAINT ETIENNE CHATEAUCREUX", "FR")).toBe("saint-etienne");
});

it("prefers a place in the operator's country, then the largest", () => {
expect(id("VALENCE TGV", "FR")).toBe("valence");
expect(id("Valencia Joaquín Sorolla", "ES")).toBe("valencia");
expect(id("Valence Ville")).toBe("valencia");
});

it("never matches a place outside Europe, or a name only found inside the station's", () => {
expect(match("Paris", "US")?.countryIso2).toBe("FR");
expect(id("Marne-la-Vallée Chessy", "FR")).toBeUndefined();
expect(id("Aéroport Lyon Saint-Exupéry", "FR")).toBeUndefined();
});

it("writes one place id per matched station, with the dataset's licence", () => {
const out = buildStationPlaces(
index,
[
{ operator: "sncf", id: "lyon-part-dieu", name: "LYON PART DIEU" },
{ operator: "renfe", id: "valencia-joaquin-sorolla", name: "Valencia Joaquín Sorolla" },
{ operator: "sncf", id: "chessy", name: "MARNE LA VALLEE CHESSY" },
],
"2026-10-12T00:00:00.000Z",
);
expect(out.stations).toEqual({ "renfe/valencia-joaquin-sorolla": "valencia", "sncf/lyon-part-dieu": "lyon" });
expect(out.license).toBe("CC0-1.0");
expect(out.attribution).toContain("Wikidata");
expect(out.placeUrl).toBe("https://offware-apps.github.io/data/v1/places/{id}.json");
});
});
Loading