diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1db0fe..f6850a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e20c67e..8b00db0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: diff --git a/docs/developers.md b/docs/developers.md index 2827b4c..da12a04 100644 --- a/docs/developers.md +++ b/docs/developers.md @@ -41,6 +41,7 @@ python3 examples/search.py renfe interrail madrid-puerta-de-atocha-almudena-gran | `/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 | +| `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: @@ -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: diff --git a/docs/format.md b/docs/format.md index fcfebbc..65a4218 100644 --- a/docs/format.md +++ b/docs/format.md @@ -78,5 +78,6 @@ All static files under `/v1/`: | `/stations.json` | `[{ id, name }]`; `id` is the URL slug used below | | `//all.json` | every bookable train for the pass (compact) | | `//from/.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/.json` and filters it. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a5bde78..1a2bd19 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: '"/" 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 diff --git a/package.json b/package.json index 41a7079..c753630 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/build-places.ts b/scripts/build-places.ts new file mode 100644 index 0000000..176942a --- /dev/null +++ b/scripts/build-places.ts @@ -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 `. When the + * data can't be reached it writes nothing and exits cleanly: the timetables still publish. + * + * Run: npm run build:places [-- --index ] + */ +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 { + 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`); diff --git a/src/places.ts b/src/places.ts new file mode 100644 index 0000000..b45f126 --- /dev/null +++ b/src/places.ts @@ -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 { "/": "" }, 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 = { + 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; + /** "/" → place id. A station no place matched is left out. */ + stations: Record; +} + +/** Abbreviations operators use in station names ("ST ETIENNE"), read the same on both sides. */ +const ABBREVIATIONS: Record = { 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(); + 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 = {}; + 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))), + }; +} diff --git a/tests/fixtures/places-index.json b/tests/fixtures/places-index.json new file mode 100644 index 0000000..d22e56e --- /dev/null +++ b/tests/fixtures/places-index.json @@ -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 } + ] +} diff --git a/tests/places.test.ts b/tests/places.test.ts new file mode 100644 index 0000000..bee8220 --- /dev/null +++ b/tests/places.test.ts @@ -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"); + }); +});