From ea7c88cc6e6c27175d61d3dbfa116a92a453fdbb Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:10:17 +0000 Subject: [PATCH 1/7] Read a country in parts when its whole query times out The monthly fetch on 1 November left out the towns of Italy, Japan and the United States and the stations of Japan: their queries hit the query service's time limit three times each, so the run stopped with nothing published. A country whose query fails is now read in five parts, by the last digit of each item's Wikidata id, and its whole query is tried twice rather than three times. --- .github/workflows/ci.yml | 8 ++++++++ scripts/fetch.ts | 37 +++++++++++++++++++++++++++++++------ src/wikidata.ts | 18 ++++++++++++++++-- tests/places.test.ts | 11 ++++++++++- 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33ae143..22c8420 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,11 @@ jobs: du -sh public/v1 public/v1/*/*.json jq '.places | length' public/v1/places/index.json jq '.stations | length' public/v1/stations/railways.json + - name: Read Japan in parts + # Japan's towns and stations once timed out in a single query: check the parts find them. + run: | + npm run fetch -- JP + jq -e '(.cities | length) > 500 and (.stations | length) > 1000' data/raw/wikidata.json + env: + FETCH_TRIES: "2" + FETCH_IN_PARTS: "1" diff --git a/scripts/fetch.ts b/scripts/fetch.ts index 3334a70..2734a58 100644 --- a/scripts/fetch.ts +++ b/scripts/fetch.ts @@ -3,8 +3,9 @@ * data/raw/wikidata.json, which is committed: a full fetch takes about two hours, so it runs * monthly in its own workflow and the publish workflow builds from the saved copy. One * query per country for towns and for stations, then the sights in batches of cities, - * slowly enough to respect the query service's limits. A batch that fails is retried in - * smaller ones; whatever keeps failing is left out and named. + * slowly enough to respect the query service's limits. A country whose query fails is read + * in parts, and a batch of sights that fails in smaller batches; whatever keeps failing is + * left out and named. * Run: npm run fetch [-- FR DE] (default: every country) */ import { mkdirSync, writeFileSync } from "node:fs"; @@ -16,6 +17,7 @@ import { parseCountries, parseSights, parseStations, + PARTS, sightsQuery, stationsQuery, type Raw, @@ -30,6 +32,8 @@ const BATCH = 20; const SMALL_BATCH = 4; /** FETCH_TRIES=1 makes a test run fail fast. */ const TRIES = Number(process.env.FETCH_TRIES) || 3; +/** Tries of a country's whole query before it is read in parts. */ +const WHOLE_TRIES = Math.min(TRIES, 2); const PAUSE_MS = 1_000; /** Tiny territories time out now and then; more than this many missing countries fails the run. */ const MAX_MISSING_COUNTRIES = 2; @@ -75,12 +79,30 @@ const raw: Raw = { fetchedAt: new Date().toISOString(), cities: [], sights: {}, const all = countries.length ? countries : parseCountries(await sparql(COUNTRIES_QUERY)); log(`${all.length} countries`); +/** FETCH_IN_PARTS=1 skips a country's whole query, to test reading in parts. */ +const whole = (query: string, tries: number): Promise => + process.env.FETCH_IN_PARTS === "1" ? Promise.reject(new Error("read in parts")) : sparql(query, tries); + +/** A country too large for one query, read one part at a time. */ +async function inParts(what: string, query: (digits: string) => string, parse: (result: unknown) => T[]): Promise { + log(`${what}: reading in ${PARTS.length} parts`); + const out: T[] = []; + for (const digits of PARTS) { + await sleep(PAUSE_MS); + out.push(...parse(await sparql(query(digits)))); + } + return out; +} + const missing: string[] = []; for (const cc of all) { try { - // The quick way first; a country too large for it is read the slow, sure way. - const result = await sparql(citiesQuery(cc), 1).catch(() => sparql(citiesQuery(cc, true))); - const cities = parseCities(result, cc); + // The quick way first; a country too large for it is read the slow, sure way, and in + // parts when even that times out. + const cities = await whole(citiesQuery(cc), 1) + .catch(() => whole(citiesQuery(cc, true), WHOLE_TRIES)) + .then((result) => parseCities(result, cc)) + .catch(() => inParts(`${cc} towns`, (digits) => citiesQuery(cc, true, digits), (result) => parseCities(result, cc))); raw.cities.push(...cities); log(`${cc}: ${cities.length} towns and cities`); } catch (err) { @@ -95,7 +117,10 @@ const missingStations: string[] = []; const seen = new Set(); for (const cc of all) { try { - const stations = parseStations(await sparql(stationsQuery(cc))).filter((s) => !seen.has(s.wikidata)); + const found = await whole(stationsQuery(cc), WHOLE_TRIES) + .then(parseStations) + .catch(() => inParts(`${cc} stations`, (digits) => stationsQuery(cc, digits), parseStations)); + const stations = found.filter((s) => !seen.has(s.wikidata)); for (const s of stations) seen.add(s.wikidata); raw.stations.push(...stations); if (stations.length) log(`${cc}: ${stations.length} railway stations`); diff --git a/src/wikidata.ts b/src/wikidata.ts index ea086bd..3a99d26 100644 --- a/src/wikidata.ts +++ b/src/wikidata.ts @@ -98,6 +98,17 @@ function country(countryIso2: string): string { const TOWN_CLASSES = `FILTER(?cls NOT IN (wd:Q17051044, wd:Q123705, wd:Q2983893)) { ?cls wdt:P279* wd:Q486972 . } UNION { ?cls wdt:P279* wd:Q15284 . }`; +/** + * A country too large for one query is read in five parts, by the last digit of each item's + * Wikidata id: each part checks a fifth of the items, so it ends well within the time limit. + */ +export const PARTS = ["01", "23", "45", "67", "89"]; + +/** Keeps the items of one part, or every item when no part is given. */ +function part(variable: string, digits?: string): string { + return digits ? `FILTER(REGEX(STR(?${variable}), "[${digits}]$"))` : ""; +} + export function parseCountries(result: unknown): string[] { const codes = bindings(result) .map((b) => b.iso2?.value?.toUpperCase() ?? "") @@ -111,7 +122,7 @@ export function parseCountries(result: unknown): string[] { * their details read once each: a town with several populations, coordinates and labels * would otherwise multiply into hundreds of rows, which made large countries time out. */ -export function citiesQuery(countryIso2: string, byPopulation = false): string { +export function citiesQuery(countryIso2: string, byPopulation = false, digits?: string): string { // byPopulation: for countries with millions of items (Great Britain, the United States), // read every item with a population and keep the country's, in that order, rather than // read every item of the country. Slower for small countries, but never times out. @@ -120,11 +131,13 @@ export function citiesQuery(countryIso2: string, byPopulation = false): string { ${country(countryIso2)} ?city wdt:P1082 ?p0 . FILTER(?p0 >= ${minPopulation(countryIso2)}) + ${part("city", digits)} ?city wdt:P17 ?country ; wdt:P31 ?cls . ${TOWN_CLASSES}` : `${country(countryIso2)} ?city wdt:P17 ?country ; wdt:P1082 ?p0 ; wdt:P31 ?cls . FILTER(?p0 >= ${minPopulation(countryIso2)}) + ${part("city", digits)} ${TOWN_CLASSES}`; return `SELECT ?city ?cityLabel (MAX(?p) AS ?pop) (SAMPLE(?c) AS ?coord) (SAMPLE(?v) AS ?voy) (SAMPLE(?w) AS ?wp) (SAMPLE(?s) AS ?site) (GROUP_CONCAT(DISTINCT ?alt; separator="|") AS ?names) @@ -302,13 +315,14 @@ export function parseSights(result: unknown): Record { * Wikidata, then the country's, then their labels. Reading a large country's items first * timed out. */ -export function stationsQuery(countryIso2: string): string { +export function stationsQuery(countryIso2: string, digits?: string): string { return `SELECT ?station ?stationLabel ?iso2 ?coord WITH { SELECT ?station (SAMPLE(?c) AS ?coord) WHERE { hint:SubQuery hint:optimizer "None" . ${country(countryIso2)} ?station wdt:P31 wd:Q55488 . + ${part("station", digits)} ?station wdt:P17 ?country ; wdt:P625 ?c ; wikibase:sitelinks ?links . FILTER NOT EXISTS { ?station wdt:P576 ?closed . } FILTER NOT EXISTS { ?station wdt:P5817 wd:Q56556915 . } diff --git a/tests/places.test.ts b/tests/places.test.ts index 8023b31..45889ad 100644 --- a/tests/places.test.ts +++ b/tests/places.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { PACK_MAX, buildFiles, placeIds, slug, type Place, type PlacesIndex } from "../src/places"; import type { Catalog } from "../src/catalog"; -import { citiesQuery, minPopulation, parseCities, parseCountries, parseSights, parseStations, point, sightsQuery, stationsQuery, type Raw } from "../src/wikidata"; +import { citiesQuery, minPopulation, PARTS, parseCities, parseCountries, parseSights, parseStations, point, sightsQuery, stationsQuery, type Raw } from "../src/wikidata"; const fixture = (name: string): unknown => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf-8")); @@ -66,6 +66,15 @@ describe("Wikidata results", () => { expect(stationsQuery("NL")).toContain("BIND(wd:Q55 AS ?country)"); expect(stationsQuery("NL")).not.toContain("P297"); }); + + it("reads a large country in parts that cover every id once", () => { + expect(PARTS.join("").split("").sort().join("")).toBe("0123456789"); + expect(citiesQuery("US")).not.toContain("REGEX"); + expect(citiesQuery("US", true, "01")).toContain('FILTER(REGEX(STR(?city), "[01]$"))'); + expect(citiesQuery("IT", false, "45")).toContain('FILTER(REGEX(STR(?city), "[45]$"))'); + expect(stationsQuery("JP")).not.toContain("REGEX"); + expect(stationsQuery("JP", "89")).toContain('FILTER(REGEX(STR(?station), "[89]$"))'); + }); }); describe("dataset files", () => { From 01f1fcfa00dcba3ffb3f7d214bec595e15bc4cd0 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:39:55 +0000 Subject: [PATCH 2/7] Check each town class once, read populations as an index range Read in parts, Great Britain and the United States still timed out: the check that an item is a kind of town walked the class tree once per item. It now runs once per class, in its own subquery, and the slow way reads only the populations large enough, as an index range. The live check now fails when a country's towns or stations are left out, which only warned. --- .github/workflows/ci.yml | 7 +++++-- src/wikidata.ts | 41 ++++++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22c8420..05c4e87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,12 @@ jobs: - run: npm ci - name: Fetch three countries from Wikidata # Great Britain and the United States are the heaviest countries for the time limit. - run: npm run fetch -- MC GB US + # A country left out only warns in a full fetch; here every one must come back. + run: | + npm run fetch -- MC GB US + jq -e '([.cities[].countryIso2] | unique) == ["GB", "MC", "US"] and (["GB", "US"] - [.stations[].countryIso2]) == []' data/raw/wikidata.json env: - FETCH_TRIES: "1" + FETCH_TRIES: "2" - run: npm run build - name: Sizes run: | diff --git a/src/wikidata.ts b/src/wikidata.ts index 3a99d26..920a91d 100644 --- a/src/wikidata.ts +++ b/src/wikidata.ts @@ -117,33 +117,50 @@ export function parseCountries(result: unknown): string[] { } /** - * Towns and cities of one country with at least minPopulation() people. The towns are - * found first, in a named subquery the query service always runs before the rest, then - * their details read once each: a town with several populations, coordinates and labels - * would otherwise multiply into hundreds of rows, which made large countries time out. + * Towns and cities of one country with at least minPopulation() people, in named subqueries + * the query service runs first and in order: the country's items with a population and + * their classes, then which of those few classes are kinds of town (checked once per class, + * not once per item, which made large countries time out), then the towns. Their details + * are read last, once each: a town with several populations, coordinates and labels would + * otherwise multiply into hundreds of rows. */ export function citiesQuery(countryIso2: string, byPopulation = false, digits?: string): string { // byPopulation: for countries with millions of items (Great Britain, the United States), - // read every item with a population and keep the country's, in that order, rather than - // read every item of the country. Slower for small countries, but never times out. - const towns = byPopulation + // read the items with a large enough population (an index range, as every population is + // a number) and keep the country's, rather than read every item of the country. + const candidates = byPopulation ? `hint:SubQuery hint:optimizer "None" . ${country(countryIso2)} ?city wdt:P1082 ?p0 . + hint:Prior hint:rangeSafe true . FILTER(?p0 >= ${minPopulation(countryIso2)}) ${part("city", digits)} - ?city wdt:P17 ?country ; wdt:P31 ?cls . - ${TOWN_CLASSES}` + ?city wdt:P17 ?country ; wdt:P31 ?cls .` : `${country(countryIso2)} ?city wdt:P17 ?country ; wdt:P1082 ?p0 ; wdt:P31 ?cls . FILTER(?p0 >= ${minPopulation(countryIso2)}) - ${part("city", digits)} - ${TOWN_CLASSES}`; + ${part("city", digits)}`; return `SELECT ?city ?cityLabel (MAX(?p) AS ?pop) (SAMPLE(?c) AS ?coord) (SAMPLE(?v) AS ?voy) (SAMPLE(?w) AS ?wp) (SAMPLE(?s) AS ?site) (GROUP_CONCAT(DISTINCT ?alt; separator="|") AS ?names) +WITH { + SELECT DISTINCT ?city ?cls WHERE { + ${candidates} + } +} AS %candidates +WITH { + SELECT DISTINCT ?cls WHERE { INCLUDE %candidates . } +} AS %classes +WITH { + SELECT ?cls WHERE { + hint:SubQuery hint:optimizer "None" . + INCLUDE %classes . + ${TOWN_CLASSES} + } +} AS %townClasses WITH { SELECT DISTINCT ?city WHERE { - ${towns} + INCLUDE %townClasses . + INCLUDE %candidates . } } AS %towns WHERE { From 7f237c478873338b59c7326f2059748c911f4729 Mon Sep 17 00:00:00 2001 From: David <60177543+davd-gzl@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:00:55 +0000 Subject: [PATCH 3/7] Keep a country's last fetch when this run can't read it A country whose towns or stations can't be read, even in parts, now keeps what the committed copy had for it, so a bad day at the query service leaves last month's data rather than failing the whole run. The populations are read as before: the index-range hint was not proven to help. The live check goes back to warning on a country left out, as the query service's load decides that more than the code does. --- .github/workflows/ci.yml | 7 ++----- scripts/fetch.ts | 29 ++++++++++++++++++++++------- src/wikidata.ts | 5 ++--- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05c4e87..22c8420 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,12 +33,9 @@ jobs: - run: npm ci - name: Fetch three countries from Wikidata # Great Britain and the United States are the heaviest countries for the time limit. - # A country left out only warns in a full fetch; here every one must come back. - run: | - npm run fetch -- MC GB US - jq -e '([.cities[].countryIso2] | unique) == ["GB", "MC", "US"] and (["GB", "US"] - [.stations[].countryIso2]) == []' data/raw/wikidata.json + run: npm run fetch -- MC GB US env: - FETCH_TRIES: "2" + FETCH_TRIES: "1" - run: npm run build - name: Sizes run: | diff --git a/scripts/fetch.ts b/scripts/fetch.ts index 2734a58..83e15af 100644 --- a/scripts/fetch.ts +++ b/scripts/fetch.ts @@ -4,11 +4,11 @@ * monthly in its own workflow and the publish workflow builds from the saved copy. One * query per country for towns and for stations, then the sights in batches of cities, * slowly enough to respect the query service's limits. A country whose query fails is read - * in parts, and a batch of sights that fails in smaller batches; whatever keeps failing is - * left out and named. + * in parts, and a batch of sights that fails in smaller batches. A country that still can't + * be read keeps what the last fetch had for it; whatever is left is left out and named. * Run: npm run fetch [-- FR DE] (default: every country) */ -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { COUNTRIES_QUERY, @@ -94,6 +94,19 @@ async function inParts(what: string, query: (digits: string) => string, parse return out; } +/** The last fetch, committed: a country this run can't read keeps what it had there. */ +const previous = existsSync(OUT) ? (JSON.parse(readFileSync(OUT, "utf-8")) as Raw) : null; + +/** Adds what the last fetch had for each missing country; returns the countries it had nothing for. */ +function keepPrevious(missing: string[], into: T[], old: T[], what: string): string[] { + return missing.filter((cc) => { + const kept = old.filter((x) => x.countryIso2 === cc); + into.push(...kept); + if (kept.length) log(`${cc}: ${kept.length} ${what} kept from the last fetch`); + return kept.length === 0; + }); +} + const missing: string[] = []; for (const cc of all) { try { @@ -112,6 +125,7 @@ for (const cc of all) { await sleep(PAUSE_MS); } if (raw.cities.length === 0) throw new Error("no town or city could be fetched from Wikidata"); +const noTowns = keepPrevious(missing, raw.cities, previous?.cities ?? [], "towns and cities"); const missingStations: string[] = []; const seen = new Set(); @@ -130,6 +144,7 @@ for (const cc of all) { } await sleep(PAUSE_MS); } +const noStations = keepPrevious(missingStations, raw.stations, (previous?.stations ?? []).filter((s) => !seen.has(s.wikidata)), "railway stations"); log(`${raw.stations.length} railway stations`); /** Sights of a batch of towns; a batch that fails is tried again a few towns at a time. */ @@ -163,13 +178,13 @@ mkdirSync(dirname(OUT), { recursive: true }); writeFileSync(OUT, JSON.stringify(raw), "utf-8"); const sightCount = Object.values(raw.sights).reduce((n, s) => n + s.length, 0); log(`${raw.cities.length} towns and cities, ${sightCount} sights, ${raw.stations.length} stations → ${OUT}`); -if (missing.length) console.log(`[fetch] countries without towns: ${missing.join(" ")}`); -if (missingStations.length) console.log(`[fetch] countries without stations: ${missingStations.join(" ")}`); +if (noTowns.length) console.log(`[fetch] countries without towns: ${noTowns.join(" ")}`); +if (noStations.length) console.log(`[fetch] countries without stations: ${noStations.join(" ")}`); if (noSights.length) console.log(`[fetch] towns without sights: ${noSights.length}`); // A fetch with real holes would publish them: stop instead, and let the next run retry. if ( - missing.length > MAX_MISSING_COUNTRIES || - missingStations.length > MAX_MISSING_COUNTRIES || + noTowns.length > MAX_MISSING_COUNTRIES || + noStations.length > MAX_MISSING_COUNTRIES || noSights.length > ids.length * MAX_MISSING_SIGHTS ) { console.error("[fetch] too much was left out to publish; nothing is published this run."); diff --git a/src/wikidata.ts b/src/wikidata.ts index 920a91d..512166c 100644 --- a/src/wikidata.ts +++ b/src/wikidata.ts @@ -126,13 +126,12 @@ export function parseCountries(result: unknown): string[] { */ export function citiesQuery(countryIso2: string, byPopulation = false, digits?: string): string { // byPopulation: for countries with millions of items (Great Britain, the United States), - // read the items with a large enough population (an index range, as every population is - // a number) and keep the country's, rather than read every item of the country. + // read every item with a population and keep the country's, in that order, rather than + // read every item of the country. Slower for small countries, but lighter for large ones. const candidates = byPopulation ? `hint:SubQuery hint:optimizer "None" . ${country(countryIso2)} ?city wdt:P1082 ?p0 . - hint:Prior hint:rangeSafe true . FILTER(?p0 >= ${minPopulation(countryIso2)}) ${part("city", digits)} ?city wdt:P17 ?country ; wdt:P31 ?cls .` From 003bf5cce7dad1cb1495ec3432f09590be3b9455 Mon Sep 17 00:00:00 2001 From: davd-agent Date: Tue, 3 Nov 2026 00:27:07 +0800 Subject: [PATCH 4/7] Read a large country's towns by id, in batches Splitting a country by the last digit of each item's id left a fifth of the United States still timing out: the digit filter reads the id of every item with a population worldwide before any detail is read. A country whose whole town query fails now has its town ids read alone, then their details 500 at a time. Stations keep the parts. The live check reads Japan and the United States the slow way and fails when fewer than 5,000 United States towns come back. --- .github/workflows/ci.yml | 16 ++++++----- scripts/fetch.ts | 33 ++++++++++++++++----- src/wikidata.ts | 62 ++++++++++++++++++++++++++++++---------- tests/places.test.ts | 18 +++++++++--- 4 files changed, 96 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22c8420..e740ed2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,9 +31,9 @@ jobs: node-version: 22 cache: npm - run: npm ci - - name: Fetch three countries from Wikidata - # Great Britain and the United States are the heaviest countries for the time limit. - run: npm run fetch -- MC GB US + - name: Fetch two countries from Wikidata + # Great Britain is the heaviest country read in one query. + run: npm run fetch -- MC GB env: FETCH_TRIES: "1" - run: npm run build @@ -42,11 +42,13 @@ jobs: du -sh public/v1 public/v1/*/*.json jq '.places | length' public/v1/places/index.json jq '.stations | length' public/v1/stations/railways.json - - name: Read Japan in parts - # Japan's towns and stations once timed out in a single query: check the parts find them. + - name: Read Japan and the United States in parts + # Their towns and Japan's stations time out in a single query: check the parts find them. run: | - npm run fetch -- JP - jq -e '(.cities | length) > 500 and (.stations | length) > 1000' data/raw/wikidata.json + npm run fetch -- JP US + jq -e '[.cities[] | select(.countryIso2 == "JP")] | length > 500' data/raw/wikidata.json + jq -e '[.cities[] | select(.countryIso2 == "US")] | length > 5000' data/raw/wikidata.json + jq -e '[.stations[] | select(.countryIso2 == "JP")] | length > 1000' data/raw/wikidata.json env: FETCH_TRIES: "2" FETCH_IN_PARTS: "1" diff --git a/scripts/fetch.ts b/scripts/fetch.ts index 83e15af..2b2fee4 100644 --- a/scripts/fetch.ts +++ b/scripts/fetch.ts @@ -3,23 +3,28 @@ * data/raw/wikidata.json, which is committed: a full fetch takes about two hours, so it runs * monthly in its own workflow and the publish workflow builds from the saved copy. One * query per country for towns and for stations, then the sights in batches of cities, - * slowly enough to respect the query service's limits. A country whose query fails is read - * in parts, and a batch of sights that fails in smaller batches. A country that still can't - * be read keeps what the last fetch had for it; whatever is left is left out and named. + * slowly enough to respect the query service's limits. A country whose query fails has its + * town ids read first, then their details in batches, and its stations read in parts; a + * batch of sights that fails is read in smaller batches. A country that still can't be read + * keeps what the last fetch had for it; whatever is left is left out and named. * Run: npm run fetch [-- FR DE] (default: every country) */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { COUNTRIES_QUERY, + citiesByIdQuery, citiesQuery, parseCities, parseCountries, parseSights, parseStations, + parseTownIds, PARTS, sightsQuery, stationsQuery, + townIdsQuery, + type City, type Raw, } from "../src/wikidata"; @@ -28,11 +33,13 @@ const ENDPOINT = "https://query.wikidata.org/sparql"; const USER_AGENT = "offware-data/0.1 (https://github.com/offware-apps/data)"; const OUT = resolve("data/raw/wikidata.json"); const BATCH = 20; +/** Towns whose details are read in one query, for a country read in batches. */ +const TOWN_BATCH = 500; /** A failed batch of sights is retried this many cities at a time. */ const SMALL_BATCH = 4; /** FETCH_TRIES=1 makes a test run fail fast. */ const TRIES = Number(process.env.FETCH_TRIES) || 3; -/** Tries of a country's whole query before it is read in parts. */ +/** Tries of a country's whole query before it is read in parts or batches. */ const WHOLE_TRIES = Math.min(TRIES, 2); const PAUSE_MS = 1_000; /** Tiny territories time out now and then; more than this many missing countries fails the run. */ @@ -79,7 +86,7 @@ const raw: Raw = { fetchedAt: new Date().toISOString(), cities: [], sights: {}, const all = countries.length ? countries : parseCountries(await sparql(COUNTRIES_QUERY)); log(`${all.length} countries`); -/** FETCH_IN_PARTS=1 skips a country's whole query, to test reading in parts. */ +/** FETCH_IN_PARTS=1 skips a country's whole query, to test reading in parts and batches. */ const whole = (query: string, tries: number): Promise => process.env.FETCH_IN_PARTS === "1" ? Promise.reject(new Error("read in parts")) : sparql(query, tries); @@ -94,6 +101,18 @@ async function inParts(what: string, query: (digits: string) => string, parse return out; } +/** A country with too many towns for one query: their ids first, then their details in batches. */ +async function townsInBatches(cc: string): Promise { + const ids = parseTownIds(await sparql(townIdsQuery(cc))); + log(`${cc} towns: reading ${ids.length} in batches of ${TOWN_BATCH}`); + const out: City[] = []; + for (let i = 0; i < ids.length; i += TOWN_BATCH) { + await sleep(PAUSE_MS); + out.push(...parseCities(await sparql(citiesByIdQuery(ids.slice(i, i + TOWN_BATCH))), cc)); + } + return out; +} + /** The last fetch, committed: a country this run can't read keeps what it had there. */ const previous = existsSync(OUT) ? (JSON.parse(readFileSync(OUT, "utf-8")) as Raw) : null; @@ -111,11 +130,11 @@ const missing: string[] = []; for (const cc of all) { try { // The quick way first; a country too large for it is read the slow, sure way, and in - // parts when even that times out. + // batches when even that times out. const cities = await whole(citiesQuery(cc), 1) .catch(() => whole(citiesQuery(cc, true), WHOLE_TRIES)) .then((result) => parseCities(result, cc)) - .catch(() => inParts(`${cc} towns`, (digits) => citiesQuery(cc, true, digits), (result) => parseCities(result, cc))); + .catch(() => townsInBatches(cc)); raw.cities.push(...cities); log(`${cc}: ${cities.length} towns and cities`); } catch (err) { diff --git a/src/wikidata.ts b/src/wikidata.ts index 512166c..eba06e2 100644 --- a/src/wikidata.ts +++ b/src/wikidata.ts @@ -117,14 +117,12 @@ export function parseCountries(result: unknown): string[] { } /** - * Towns and cities of one country with at least minPopulation() people, in named subqueries - * the query service runs first and in order: the country's items with a population and - * their classes, then which of those few classes are kinds of town (checked once per class, - * not once per item, which made large countries time out), then the towns. Their details - * are read last, once each: a town with several populations, coordinates and labels would - * otherwise multiply into hundreds of rows. + * The towns and cities of one country with at least minPopulation() people, as a named + * subquery %towns, after others the query service runs first and in order: the country's + * items with a population and their classes, then which of those few classes are kinds of + * town (checked once per class, not once per item, which made large countries time out). */ -export function citiesQuery(countryIso2: string, byPopulation = false, digits?: string): string { +function towns(countryIso2: string, byPopulation: boolean): string { // byPopulation: for countries with millions of items (Great Britain, the United States), // read every item with a population and keep the country's, in that order, rather than // read every item of the country. Slower for small countries, but lighter for large ones. @@ -133,15 +131,11 @@ export function citiesQuery(countryIso2: string, byPopulation = false, digits?: ${country(countryIso2)} ?city wdt:P1082 ?p0 . FILTER(?p0 >= ${minPopulation(countryIso2)}) - ${part("city", digits)} ?city wdt:P17 ?country ; wdt:P31 ?cls .` : `${country(countryIso2)} ?city wdt:P17 ?country ; wdt:P1082 ?p0 ; wdt:P31 ?cls . - FILTER(?p0 >= ${minPopulation(countryIso2)}) - ${part("city", digits)}`; - return `SELECT ?city ?cityLabel (MAX(?p) AS ?pop) (SAMPLE(?c) AS ?coord) (SAMPLE(?v) AS ?voy) (SAMPLE(?w) AS ?wp) (SAMPLE(?s) AS ?site) - (GROUP_CONCAT(DISTINCT ?alt; separator="|") AS ?names) -WITH { + FILTER(?p0 >= ${minPopulation(countryIso2)})`; + return `WITH { SELECT DISTINCT ?city ?cls WHERE { ${candidates} } @@ -161,9 +155,20 @@ WITH { INCLUDE %townClasses . INCLUDE %candidates . } -} AS %towns +} AS %towns`; +} + +/** + * The columns parseCities reads, for the towns `source` binds to ?city. Each town's details + * are grouped into one row: a town with several populations, coordinates and labels would + * otherwise multiply into hundreds of rows. + */ +function details(source: string, withs = ""): string { + return `SELECT ?city ?cityLabel (MAX(?p) AS ?pop) (SAMPLE(?c) AS ?coord) (SAMPLE(?v) AS ?voy) (SAMPLE(?w) AS ?wp) (SAMPLE(?s) AS ?site) + (GROUP_CONCAT(DISTINCT ?alt; separator="|") AS ?names) +${withs} WHERE { - INCLUDE %towns . + ${source} ?city wdt:P1082 ?p ; wdt:P625 ?c . OPTIONAL { ?city rdfs:label ?alt . FILTER(LANG(?alt) IN (${LANGS.map((l) => `"${l}"`).join(", ")})) } OPTIONAL { ?v schema:about ?city ; schema:isPartOf . } @@ -174,6 +179,26 @@ WHERE { GROUP BY ?city ?cityLabel`; } +/** Towns and cities of one country, with their details, in one query. */ +export function citiesQuery(countryIso2: string, byPopulation = false): string { + return details("INCLUDE %towns .", towns(countryIso2, byPopulation)); +} + +/** + * The Wikidata ids of one country's towns and cities, without their details: for a country + * whose towns are too many to read with their details in one query. + */ +export function townIdsQuery(countryIso2: string): string { + return `SELECT ?city +${towns(countryIso2, true)} +WHERE { INCLUDE %towns . }`; +} + +/** A batch of towns and cities by Wikidata id, with their details. */ +export function citiesByIdQuery(ids: string[]): string { + return details(`VALUES ?city { ${ids.map((id) => `wd:${id}`).join(" ")} }`); +} + /** Kinds of sight, by Wikidata class. UNESCO World Heritage sites are kept whatever their class. */ export const SIGHT_CLASSES: [string, string][] = [ ["Q33506", "museum"], @@ -254,6 +279,13 @@ function url(v: string | undefined, host?: RegExp): string | undefined { const VOY = /^en\.wikivoyage\.org$/; const WP = /^en\.wikipedia\.org$/; +/** Wikidata ids from townIdsQuery. */ +export function parseTownIds(result: unknown): string[] { + return bindings(result) + .map((b) => qid(b.city?.value)) + .filter((id) => /^Q\d+$/.test(id)); +} + /** Cities from citiesQuery; when an id repeats (two populations or coordinates), the largest wins. */ export function parseCities(result: unknown, countryIso2: string): City[] { const byId = new Map(); diff --git a/tests/places.test.ts b/tests/places.test.ts index 45889ad..7986226 100644 --- a/tests/places.test.ts +++ b/tests/places.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { PACK_MAX, buildFiles, placeIds, slug, type Place, type PlacesIndex } from "../src/places"; import type { Catalog } from "../src/catalog"; -import { citiesQuery, minPopulation, PARTS, parseCities, parseCountries, parseSights, parseStations, point, sightsQuery, stationsQuery, type Raw } from "../src/wikidata"; +import { citiesByIdQuery, citiesQuery, minPopulation, PARTS, parseCities, parseCountries, parseSights, parseStations, parseTownIds, point, sightsQuery, stationsQuery, townIdsQuery, type Raw } from "../src/wikidata"; const fixture = (name: string): unknown => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf-8")); @@ -69,12 +69,22 @@ describe("Wikidata results", () => { it("reads a large country in parts that cover every id once", () => { expect(PARTS.join("").split("").sort().join("")).toBe("0123456789"); - expect(citiesQuery("US")).not.toContain("REGEX"); - expect(citiesQuery("US", true, "01")).toContain('FILTER(REGEX(STR(?city), "[01]$"))'); - expect(citiesQuery("IT", false, "45")).toContain('FILTER(REGEX(STR(?city), "[45]$"))'); expect(stationsQuery("JP")).not.toContain("REGEX"); expect(stationsQuery("JP", "89")).toContain('FILTER(REGEX(STR(?station), "[89]$"))'); }); + + it("reads a large country's town ids alone, then their details by id", () => { + const ids = townIdsQuery("US"); + expect(ids).toContain('wdt:P297 "US"'); + expect(ids).toContain("?p0 >= 15000"); + expect(ids).toContain("wd:Q15284"); + // Every item with a population first, then the country's: the order light enough for the US. + expect(ids).toMatch(/wdt:P1082 \?p0 \.[\s\S]*wdt:P17 \?country/); + expect(ids).not.toContain("rdfs:label"); + const result = { results: { bindings: [{ city: { value: "http://www.wikidata.org/entity/Q60" } }, { city: { value: "x" } }] } }; + expect(parseTownIds(result)).toEqual(["Q60"]); + expect(citiesByIdQuery(["Q60", "Q65"])).toContain("VALUES ?city { wd:Q60 wd:Q65 }"); + }); }); describe("dataset files", () => { From 5eaceff757d049b646684638457fdd5d747ce073 Mon Sep 17 00:00:00 2001 From: davd-agent Date: Tue, 3 Nov 2026 00:27:13 +0800 Subject: [PATCH 5/7] Test keeping a country's last fetch keepPrevious moves to src/wikidata.ts so a test can reach it: a country this run missed gets what the last fetch had for it, and only the ones it had nothing for count as left out. --- scripts/fetch.ts | 15 +++------------ src/wikidata.ts | 10 ++++++++++ tests/places.test.ts | 9 ++++++++- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/scripts/fetch.ts b/scripts/fetch.ts index 2b2fee4..d5780a6 100644 --- a/scripts/fetch.ts +++ b/scripts/fetch.ts @@ -15,6 +15,7 @@ import { COUNTRIES_QUERY, citiesByIdQuery, citiesQuery, + keepPrevious, parseCities, parseCountries, parseSights, @@ -116,16 +117,6 @@ async function townsInBatches(cc: string): Promise { /** The last fetch, committed: a country this run can't read keeps what it had there. */ const previous = existsSync(OUT) ? (JSON.parse(readFileSync(OUT, "utf-8")) as Raw) : null; -/** Adds what the last fetch had for each missing country; returns the countries it had nothing for. */ -function keepPrevious(missing: string[], into: T[], old: T[], what: string): string[] { - return missing.filter((cc) => { - const kept = old.filter((x) => x.countryIso2 === cc); - into.push(...kept); - if (kept.length) log(`${cc}: ${kept.length} ${what} kept from the last fetch`); - return kept.length === 0; - }); -} - const missing: string[] = []; for (const cc of all) { try { @@ -144,7 +135,7 @@ for (const cc of all) { await sleep(PAUSE_MS); } if (raw.cities.length === 0) throw new Error("no town or city could be fetched from Wikidata"); -const noTowns = keepPrevious(missing, raw.cities, previous?.cities ?? [], "towns and cities"); +const noTowns = keepPrevious(missing, raw.cities, previous?.cities ?? [], "towns and cities", log); const missingStations: string[] = []; const seen = new Set(); @@ -163,7 +154,7 @@ for (const cc of all) { } await sleep(PAUSE_MS); } -const noStations = keepPrevious(missingStations, raw.stations, (previous?.stations ?? []).filter((s) => !seen.has(s.wikidata)), "railway stations"); +const noStations = keepPrevious(missingStations, raw.stations, (previous?.stations ?? []).filter((s) => !seen.has(s.wikidata)), "railway stations", log); log(`${raw.stations.length} railway stations`); /** Sights of a batch of towns; a batch that fails is tried again a few towns at a time. */ diff --git a/src/wikidata.ts b/src/wikidata.ts index eba06e2..08c80fe 100644 --- a/src/wikidata.ts +++ b/src/wikidata.ts @@ -279,6 +279,16 @@ function url(v: string | undefined, host?: RegExp): string | undefined { const VOY = /^en\.wikivoyage\.org$/; const WP = /^en\.wikipedia\.org$/; +/** Adds what the last fetch had for each missing country; returns the countries it had nothing for. */ +export function keepPrevious(missing: string[], into: T[], old: T[], what: string, log: (msg: string) => void): string[] { + return missing.filter((cc) => { + const kept = old.filter((x) => x.countryIso2 === cc); + into.push(...kept); + if (kept.length) log(`${cc}: ${kept.length} ${what} kept from the last fetch`); + return kept.length === 0; + }); +} + /** Wikidata ids from townIdsQuery. */ export function parseTownIds(result: unknown): string[] { return bindings(result) diff --git a/tests/places.test.ts b/tests/places.test.ts index 7986226..55f2b04 100644 --- a/tests/places.test.ts +++ b/tests/places.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { PACK_MAX, buildFiles, placeIds, slug, type Place, type PlacesIndex } from "../src/places"; import type { Catalog } from "../src/catalog"; -import { citiesByIdQuery, citiesQuery, minPopulation, PARTS, parseCities, parseCountries, parseSights, parseStations, parseTownIds, point, sightsQuery, stationsQuery, townIdsQuery, type Raw } from "../src/wikidata"; +import { citiesByIdQuery, citiesQuery, keepPrevious, minPopulation, PARTS, parseCities, parseCountries, parseSights, parseStations, parseTownIds, point, sightsQuery, stationsQuery, townIdsQuery, type Raw } from "../src/wikidata"; const fixture = (name: string): unknown => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf-8")); @@ -85,6 +85,13 @@ describe("Wikidata results", () => { expect(parseTownIds(result)).toEqual(["Q60"]); expect(citiesByIdQuery(["Q60", "Q65"])).toContain("VALUES ?city { wd:Q60 wd:Q65 }"); }); + + it("keeps the last fetch's data for a country this run missed, and names the ones it had none for", () => { + const old = [{ countryIso2: "IT", id: 1 }, { countryIso2: "US", id: 2 }]; + const into: { countryIso2: string; id: number }[] = []; + expect(keepPrevious(["IT", "JP"], into, old, "towns", () => {})).toEqual(["JP"]); + expect(into.map((x) => x.id)).toEqual([1]); + }); }); describe("dataset files", () => { From 25ea6c31ce6c45b8559f53016647700facf8a460 Mon Sep 17 00:00:00 2001 From: davd-agent Date: Tue, 3 Nov 2026 00:37:05 +0800 Subject: [PATCH 6/7] Fail the fetch when too many countries are kept from the last one A run where every station query fails kept every country's stations from the last fetch and passed, publishing them under today's date. More than 10 countries kept, for towns or stations, now fails the run. --- scripts/fetch.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/fetch.ts b/scripts/fetch.ts index d5780a6..016a9a3 100644 --- a/scripts/fetch.ts +++ b/scripts/fetch.ts @@ -45,6 +45,8 @@ const WHOLE_TRIES = Math.min(TRIES, 2); const PAUSE_MS = 1_000; /** Tiny territories time out now and then; more than this many missing countries fails the run. */ const MAX_MISSING_COUNTRIES = 2; +/** More countries than this kept from the last fetch means the query service or a query is broken: fail the run. */ +const MAX_KEPT_COUNTRIES = 10; /** Share of towns whose sights may be missing before the run fails. */ const MAX_MISSING_SIGHTS = 0.01; @@ -195,6 +197,8 @@ if (noSights.length) console.log(`[fetch] towns without sights: ${noSights.lengt if ( noTowns.length > MAX_MISSING_COUNTRIES || noStations.length > MAX_MISSING_COUNTRIES || + missing.length > MAX_KEPT_COUNTRIES || + missingStations.length > MAX_KEPT_COUNTRIES || noSights.length > ids.length * MAX_MISSING_SIGHTS ) { console.error("[fetch] too much was left out to publish; nothing is published this run."); From e0409f777cbec05e46268d11c8b8e219a7a142a1 Mon Sep 17 00:00:00 2001 From: davd-agent Date: Tue, 3 Nov 2026 01:52:57 +0800 Subject: [PATCH 7/7] Read every railway station in one worldwide query The stations took 151 of the 351 minutes on 1 November, in 255 queries, one per country, each reading every station on Wikidata. One query now reads them all, with their countries and names read by their own queries, and a failure there stops the run before the sights. The digit parts go: nothing reads a country in parts any more. --- .github/workflows/ci.yml | 5 +- scripts/fetch.ts | 66 +++++++--------- src/wikidata.ts | 115 +++++++++++++++++----------- tests/fixtures/country-codes.json | 49 ++++++++++++ tests/fixtures/station-labels.json | 69 +++++++++++++++++ tests/fixtures/stations.json | 116 +++++++++++++++++++++-------- tests/places.test.ts | 31 +++++--- 7 files changed, 323 insertions(+), 128 deletions(-) create mode 100644 tests/fixtures/country-codes.json create mode 100644 tests/fixtures/station-labels.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e740ed2..7b993af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,9 @@ jobs: du -sh public/v1 public/v1/*/*.json jq '.places | length' public/v1/places/index.json jq '.stations | length' public/v1/stations/railways.json - - name: Read Japan and the United States in parts - # Their towns and Japan's stations time out in a single query: check the parts find them. + - name: Read Japan and the United States in batches + # Their towns time out in a single query: check the batches find them, and the + # worldwide stations query finds Japan's. run: | npm run fetch -- JP US jq -e '[.cities[] | select(.countryIso2 == "JP")] | length > 500' data/raw/wikidata.json diff --git a/scripts/fetch.ts b/scripts/fetch.ts index 016a9a3..471cb0c 100644 --- a/scripts/fetch.ts +++ b/scripts/fetch.ts @@ -2,28 +2,33 @@ * Fetch towns, cities, their sights and railway stations from Wikidata to * data/raw/wikidata.json, which is committed: a full fetch takes about two hours, so it runs * monthly in its own workflow and the publish workflow builds from the saved copy. One - * query per country for towns and for stations, then the sights in batches of cities, - * slowly enough to respect the query service's limits. A country whose query fails has its - * town ids read first, then their details in batches, and its stations read in parts; a - * batch of sights that fails is read in smaller batches. A country that still can't be read - * keeps what the last fetch had for it; whatever is left is left out and named. + * query per country for towns, one for the stations worldwide and their labels in batches, + * then the sights in batches of cities, slowly enough to respect the query service's limits. + * A country whose town query fails has its town ids read first, then their details in + * batches; a batch of sights that fails is read in smaller batches. A country whose towns + * still can't be read keeps what the last fetch had for it; whatever is left is left out and + * named. * Run: npm run fetch [-- FR DE] (default: every country) */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { COUNTRIES_QUERY, + COUNTRY_CODES_QUERY, + STATIONS_QUERY, citiesByIdQuery, citiesQuery, keepPrevious, + labelsQuery, + nameStations, parseCities, parseCountries, + parseCountryCodes, + parseLabels, parseSights, parseStations, parseTownIds, - PARTS, sightsQuery, - stationsQuery, townIdsQuery, type City, type Raw, @@ -36,11 +41,13 @@ const OUT = resolve("data/raw/wikidata.json"); const BATCH = 20; /** Towns whose details are read in one query, for a country read in batches. */ const TOWN_BATCH = 500; +/** Stations labelled per query: 10,000 took up to 44 s of the query service's 60. */ +const LABEL_BATCH = 5_000; /** A failed batch of sights is retried this many cities at a time. */ const SMALL_BATCH = 4; /** FETCH_TRIES=1 makes a test run fail fast. */ const TRIES = Number(process.env.FETCH_TRIES) || 3; -/** Tries of a country's whole query before it is read in parts or batches. */ +/** Tries of a country's whole town query before its towns are read in batches. */ const WHOLE_TRIES = Math.min(TRIES, 2); const PAUSE_MS = 1_000; /** Tiny territories time out now and then; more than this many missing countries fails the run. */ @@ -89,21 +96,10 @@ const raw: Raw = { fetchedAt: new Date().toISOString(), cities: [], sights: {}, const all = countries.length ? countries : parseCountries(await sparql(COUNTRIES_QUERY)); log(`${all.length} countries`); -/** FETCH_IN_PARTS=1 skips a country's whole query, to test reading in parts and batches. */ +/** FETCH_IN_PARTS=1 skips a country's whole town query, to test reading in batches. */ const whole = (query: string, tries: number): Promise => process.env.FETCH_IN_PARTS === "1" ? Promise.reject(new Error("read in parts")) : sparql(query, tries); -/** A country too large for one query, read one part at a time. */ -async function inParts(what: string, query: (digits: string) => string, parse: (result: unknown) => T[]): Promise { - log(`${what}: reading in ${PARTS.length} parts`); - const out: T[] = []; - for (const digits of PARTS) { - await sleep(PAUSE_MS); - out.push(...parse(await sparql(query(digits)))); - } - return out; -} - /** A country with too many towns for one query: their ids first, then their details in batches. */ async function townsInBatches(cc: string): Promise { const ids = parseTownIds(await sparql(townIdsQuery(cc))); @@ -139,24 +135,19 @@ for (const cc of all) { if (raw.cities.length === 0) throw new Error("no town or city could be fetched from Wikidata"); const noTowns = keepPrevious(missing, raw.cities, previous?.cities ?? [], "towns and cities", log); -const missingStations: string[] = []; -const seen = new Set(); -for (const cc of all) { - try { - const found = await whole(stationsQuery(cc), WHOLE_TRIES) - .then(parseStations) - .catch(() => inParts(`${cc} stations`, (digits) => stationsQuery(cc, digits), parseStations)); - const stations = found.filter((s) => !seen.has(s.wikidata)); - for (const s of stations) seen.add(s.wikidata); - raw.stations.push(...stations); - if (stations.length) log(`${cc}: ${stations.length} railway stations`); - } catch (err) { - missingStations.push(cc); - console.error(`[fetch] ${cc}: stations left out (${String(err)})`); - } +// Every country's stations come from the same few queries, so one that keeps failing stops +// the run here, before the sights, rather than publish a copy without stations. +const codes = parseCountryCodes(await sparql(COUNTRY_CODES_QUERY)); +await sleep(PAUSE_MS); +const stations = parseStations(await sparql(STATIONS_QUERY), codes, all); +const labels = new Map(); +for (let i = 0; i < stations.length; i += LABEL_BATCH) { await sleep(PAUSE_MS); + const batch = await sparql(labelsQuery(stations.slice(i, i + LABEL_BATCH).map((s) => s.wikidata))); + for (const [id, label] of parseLabels(batch)) labels.set(id, label); } -const noStations = keepPrevious(missingStations, raw.stations, (previous?.stations ?? []).filter((s) => !seen.has(s.wikidata)), "railway stations", log); +raw.stations = nameStations(stations, labels); +if (raw.stations.length === 0) throw new Error("no railway station could be fetched from Wikidata"); log(`${raw.stations.length} railway stations`); /** Sights of a batch of towns; a batch that fails is tried again a few towns at a time. */ @@ -191,14 +182,11 @@ writeFileSync(OUT, JSON.stringify(raw), "utf-8"); const sightCount = Object.values(raw.sights).reduce((n, s) => n + s.length, 0); log(`${raw.cities.length} towns and cities, ${sightCount} sights, ${raw.stations.length} stations → ${OUT}`); if (noTowns.length) console.log(`[fetch] countries without towns: ${noTowns.join(" ")}`); -if (noStations.length) console.log(`[fetch] countries without stations: ${noStations.join(" ")}`); if (noSights.length) console.log(`[fetch] towns without sights: ${noSights.length}`); // A fetch with real holes would publish them: stop instead, and let the next run retry. if ( noTowns.length > MAX_MISSING_COUNTRIES || - noStations.length > MAX_MISSING_COUNTRIES || missing.length > MAX_KEPT_COUNTRIES || - missingStations.length > MAX_KEPT_COUNTRIES || noSights.length > ids.length * MAX_MISSING_SIGHTS ) { console.error("[fetch] too much was left out to publish; nothing is published this run."); diff --git a/src/wikidata.ts b/src/wikidata.ts index 08c80fe..2fddd88 100644 --- a/src/wikidata.ts +++ b/src/wikidata.ts @@ -98,17 +98,6 @@ function country(countryIso2: string): string { const TOWN_CLASSES = `FILTER(?cls NOT IN (wd:Q17051044, wd:Q123705, wd:Q2983893)) { ?cls wdt:P279* wd:Q486972 . } UNION { ?cls wdt:P279* wd:Q15284 . }`; -/** - * A country too large for one query is read in five parts, by the last digit of each item's - * Wikidata id: each part checks a fifth of the items, so it ends well within the time limit. - */ -export const PARTS = ["01", "23", "45", "67", "89"]; - -/** Keeps the items of one part, or every item when no part is given. */ -function part(variable: string, digits?: string): string { - return digits ? `FILTER(REGEX(STR(?${variable}), "[${digits}]$"))` : ""; -} - export function parseCountries(result: unknown): string[] { const codes = bindings(result) .map((b) => b.iso2?.value?.toUpperCase() ?? "") @@ -366,48 +355,84 @@ export function parseSights(result: unknown): Record { // ---------------------------------------------------------------- railway stations +/** Every item with an ISO 3166-1 code, dissolved ones too: the countries stations name. */ +export const COUNTRY_CODES_QUERY = `SELECT ?country ?iso2 WHERE { ?country wdt:P297 ?iso2 . }`; + /** - * Mainline railway stations of one country that are still open, with coordinates and - * either a UIC code or a page on a Wikimedia project (which leaves out most tram and metro - * stops). One query per country, run in the written order: every railway station on - * Wikidata, then the country's, then their labels. Reading a large country's items first - * timed out. + * Mainline railway stations worldwide that are still open, with their countries, + * coordinates and either a UIC code or a page on a Wikimedia project (which leaves out most + * tram and metro stops). One query reads every railway station on Wikidata, in the written + * order, in about half a minute. The countries' ISO codes and the labels are read by their + * own queries: reading the labels here, or the codes with the Netherlands' exception, took + * this one past the query service's one-minute limit. */ -export function stationsQuery(countryIso2: string, digits?: string): string { - return `SELECT ?station ?stationLabel ?iso2 ?coord -WITH { - SELECT ?station (SAMPLE(?c) AS ?coord) WHERE { - hint:SubQuery hint:optimizer "None" . - ${country(countryIso2)} - ?station wdt:P31 wd:Q55488 . - ${part("station", digits)} - ?station wdt:P17 ?country ; wdt:P625 ?c ; wikibase:sitelinks ?links . - FILTER NOT EXISTS { ?station wdt:P576 ?closed . } - FILTER NOT EXISTS { ?station wdt:P5817 wd:Q56556915 . } - FILTER NOT EXISTS { ?station wdt:P5817 wd:Q45382883 . } - FILTER(?links > 0 || EXISTS { ?station wdt:P954 ?uic . }) - } - GROUP BY ?station -} AS %stations -WHERE { - INCLUDE %stations . - BIND("${countryIso2}" AS ?iso2) +export const STATIONS_QUERY = `SELECT ?station ?country (SAMPLE(?c) AS ?coord) WHERE { + hint:Query hint:optimizer "None" . + ?station wdt:P31 wd:Q55488 . + ?station wdt:P17 ?country ; wdt:P625 ?c ; wikibase:sitelinks ?links . + FILTER NOT EXISTS { ?station wdt:P576 ?closed . } + FILTER NOT EXISTS { ?station wdt:P5817 wd:Q56556915 . } + FILTER NOT EXISTS { ?station wdt:P5817 wd:Q45382883 . } + FILTER(?links > 0 || EXISTS { ?station wdt:P954 ?uic . }) +} +GROUP BY ?station ?country`; + +/** Labels of a batch of items. */ +export function labelsQuery(ids: string[]): string { + return `SELECT ?item ?itemLabel WHERE { + VALUES ?item { ${ids.map((id) => `wd:${id}`).join(" ")} } ${labelService} }`; } -/** Stations from stationsQuery; a station listed twice keeps the first. */ -export function parseStations(result: unknown): Station[] { - const out: Station[] = []; - const seen = new Set(); +/** A station before nameStations gives it its label. */ +type UnnamedStation = Omit; + +/** Item → ISO code, from COUNTRY_CODES_QUERY, with the codes COUNTRY_ITEMS moves to another item. */ +export function parseCountryCodes(result: unknown): Map { + const codes = new Map(); + for (const b of bindings(result)) { + const id = qid(b.country?.value); + const cc = b.iso2?.value ?? ""; + if (/^Q\d+$/.test(id) && /^[A-Z]{2}$/.test(cc) && !(cc in COUNTRY_ITEMS)) codes.set(id, cc); + } + for (const [cc, id] of Object.entries(COUNTRY_ITEMS)) codes.set(id, cc); + return codes; +} + +/** + * Stations from STATIONS_QUERY in these countries, country by country in their order, a + * station in several kept in the first. They are named by nameStations. + */ +export function parseStations(result: unknown, codes: Map, countries: string[]): UnnamedStation[] { + const rank = new Map(countries.map((cc, i) => [cc, i])); + const best = new Map(); for (const b of bindings(result)) { const id = qid(b.station?.value); + const cc = codes.get(qid(b.country?.value)) ?? ""; + const r = rank.get(cc); const at = point(b.coord?.value); - const name = b.stationLabel?.value?.trim() ?? ""; - const cc = b.iso2?.value?.toUpperCase() ?? ""; - if (!/^Q\d+$/.test(id) || !at || !name || name === id || !/^[A-Z]{2}$/.test(cc) || seen.has(id)) continue; - seen.add(id); - out.push({ wikidata: id, name, countryIso2: cc, ...at }); + if (!/^Q\d+$/.test(id) || r === undefined || !at || (best.get(id)?.rank ?? Infinity) <= r) continue; + best.set(id, { rank: r, station: { wikidata: id, countryIso2: cc, ...at } }); } - return out; + return [...best.values()].sort((a, b) => a.rank - b.rank).map((s) => s.station); +} + +/** Item → label, from labelsQuery; an item with no label in the languages above has none. */ +export function parseLabels(result: unknown): Map { + const labels = new Map(); + for (const b of bindings(result)) { + const id = qid(b.item?.value); + const label = b.itemLabel?.value?.trim() ?? ""; + if (label && label !== id) labels.set(id, label); + } + return labels; +} + +/** Stations named by their labels, from parseLabels; a station without one is left out. */ +export function nameStations(stations: UnnamedStation[], labels: Map): Station[] { + return stations.flatMap(({ wikidata, ...rest }) => { + const name = labels.get(wikidata); + return name ? [{ wikidata, name, ...rest }] : []; + }); } diff --git a/tests/fixtures/country-codes.json b/tests/fixtures/country-codes.json new file mode 100644 index 0000000..090f30b --- /dev/null +++ b/tests/fixtures/country-codes.json @@ -0,0 +1,49 @@ +{ + "head": { + "vars": [] + }, + "results": { + "bindings": [ + { + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q142" + }, + "iso2": { + "type": "literal", + "value": "FR" + } + }, + { + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q39" + }, + "iso2": { + "type": "literal", + "value": "CH" + } + }, + { + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q17" + }, + "iso2": { + "type": "literal", + "value": "JP" + } + }, + { + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q29999" + }, + "iso2": { + "type": "literal", + "value": "NL" + } + } + ] + } +} diff --git a/tests/fixtures/station-labels.json b/tests/fixtures/station-labels.json new file mode 100644 index 0000000..7e38fe6 --- /dev/null +++ b/tests/fixtures/station-labels.json @@ -0,0 +1,69 @@ +{ + "head": { + "vars": [] + }, + "results": { + "bindings": [ + { + "item": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q801124" + }, + "itemLabel": { + "type": "literal", + "value": "Tokyo Station" + } + }, + { + "item": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q745942" + }, + "itemLabel": { + "type": "literal", + "value": "Paris Gare de Lyon" + } + }, + { + "item": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q1" + }, + "itemLabel": { + "type": "literal", + "value": "Frontier" + } + }, + { + "item": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q5" + }, + "itemLabel": { + "type": "literal", + "value": "Q5" + } + }, + { + "item": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q1124795" + }, + "itemLabel": { + "type": "literal", + "value": "Amsterdam Centraal" + } + }, + { + "item": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q3" + }, + "itemLabel": { + "type": "literal", + "value": "Border" + } + } + ] + } +} diff --git a/tests/fixtures/stations.json b/tests/fixtures/stations.json index 98226f7..6c2b7e3 100644 --- a/tests/fixtures/stations.json +++ b/tests/fixtures/stations.json @@ -6,16 +6,26 @@ "bindings": [ { "station": { - "type": "literal", - "value": "http://www.wikidata.org/entity/Q745942" + "type": "uri", + "value": "http://www.wikidata.org/entity/Q801124" }, - "stationLabel": { - "type": "literal", - "value": "Paris Gare de Lyon" + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q17" }, - "iso2": { + "coord": { "type": "literal", - "value": "FR" + "value": "Point(139.7671 35.6812)" + } + }, + { + "station": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q745942" + }, + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q142" }, "coord": { "type": "literal", @@ -24,58 +34,102 @@ }, { "station": { - "type": "literal", - "value": "http://www.wikidata.org/entity/Q745942" + "type": "uri", + "value": "http://www.wikidata.org/entity/Q1" }, - "stationLabel": { - "type": "literal", - "value": "Paris Gare de Lyon" + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q55" }, - "iso2": { + "coord": { "type": "literal", - "value": "CH" + "value": "Point(1 1)" + } + }, + { + "station": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q1" + }, + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q39" }, "coord": { "type": "literal", - "value": "Point(2.3733 48.8443)" + "value": "Point(1 1)" } }, { "station": { - "type": "literal", - "value": "http://www.wikidata.org/entity/Q801124" + "type": "uri", + "value": "http://www.wikidata.org/entity/Q5" }, - "stationLabel": { - "type": "literal", - "value": "Tokyo Station" + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q142" }, - "iso2": { + "coord": { "type": "literal", - "value": "jp" + "value": "Point(1 1)" + } + }, + { + "station": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q2" + }, + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q29999" }, "coord": { "type": "literal", - "value": "Point(139.7671 35.6812)" + "value": "Point(4.9 52.37)" } }, { "station": { - "type": "literal", - "value": "http://www.wikidata.org/entity/Q5" + "type": "uri", + "value": "http://www.wikidata.org/entity/Q1124795" + }, + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q55" }, - "stationLabel": { + "coord": { "type": "literal", - "value": "Q5" + "value": "Point(4.9003 52.3789)" + } + }, + { + "station": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q3" + }, + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q39" }, - "iso2": { + "coord": { "type": "literal", - "value": "FR" + "value": "Point(6.14 46.21)" + } + }, + { + "station": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q3" + }, + "country": { + "type": "uri", + "value": "http://www.wikidata.org/entity/Q55" }, "coord": { "type": "literal", - "value": "Point(1 1)" + "value": "Point(6.14 46.21)" } } ] } -} \ No newline at end of file +} diff --git a/tests/places.test.ts b/tests/places.test.ts index 55f2b04..0528694 100644 --- a/tests/places.test.ts +++ b/tests/places.test.ts @@ -2,13 +2,17 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { PACK_MAX, buildFiles, placeIds, slug, type Place, type PlacesIndex } from "../src/places"; import type { Catalog } from "../src/catalog"; -import { citiesByIdQuery, citiesQuery, keepPrevious, minPopulation, PARTS, parseCities, parseCountries, parseSights, parseStations, parseTownIds, point, sightsQuery, stationsQuery, townIdsQuery, type Raw } from "../src/wikidata"; +import { citiesByIdQuery, citiesQuery, keepPrevious, labelsQuery, minPopulation, nameStations, parseCities, parseCountries, parseCountryCodes, parseLabels, parseSights, parseStations, parseTownIds, point, sightsQuery, townIdsQuery, type Raw } from "../src/wikidata"; const fixture = (name: string): unknown => JSON.parse(readFileSync(new URL(`./fixtures/${name}`, import.meta.url), "utf-8")); +const codes = parseCountryCodes(fixture("country-codes.json")); +const stations = (countries: string[]) => + nameStations(parseStations(fixture("stations.json"), codes, countries), parseLabels(fixture("station-labels.json"))); + const raw = (): Raw => { const cities = [...parseCities(fixture("cities-fr.json"), "FR"), ...parseCities(fixture("cities-es.json"), "ES")]; - return { fetchedAt: "2026-10-12T00:00:00.000Z", cities, sights: parseSights(fixture("sights.json")), stations: parseStations(fixture("stations.json")) }; + return { fetchedAt: "2026-10-12T00:00:00.000Z", cities, sights: parseSights(fixture("sights.json")), stations: stations(["FR", "JP"]) }; }; describe("Wikidata results", () => { @@ -41,12 +45,23 @@ describe("Wikidata results", () => { it("reads countries and stations worldwide", () => { expect(parseCountries(fixture("countries.json"))).toEqual(["FR", "JP"]); - expect(parseStations(fixture("stations.json"))).toEqual([ + expect(stations(["FR", "JP"])).toEqual([ { wikidata: "Q745942", name: "Paris Gare de Lyon", countryIso2: "FR", lat: 48.8443, lon: 2.3733 }, { wikidata: "Q801124", name: "Tokyo Station", countryIso2: "JP", lat: 35.6812, lon: 139.7671 }, ]); }); + it("keeps a station in several countries in the first, and drops unlabelled ones", () => { + expect(stations(["CH", "FR", "JP", "NL"]).map((s) => `${s.countryIso2} ${s.name}`)).toEqual([ + "CH Frontier", + "CH Border", + "FR Paris Gare de Lyon", + "JP Tokyo Station", + "NL Amsterdam Centraal", + ]); + expect(labelsQuery(["Q1", "Q2"])).toContain("VALUES ?item { wd:Q1 wd:Q2 }"); + }); + it("keeps smaller towns in Europe than elsewhere", () => { expect(minPopulation("FR")).toBe(5000); expect(minPopulation("JP")).toBe(15000); @@ -63,14 +78,8 @@ describe("Wikidata results", () => { it("finds Dutch towns and stations by the country they name", () => { expect(citiesQuery("NL")).toContain("BIND(wd:Q55 AS ?country)"); expect(citiesQuery("NL", true)).toContain("BIND(wd:Q55 AS ?country)"); - expect(stationsQuery("NL")).toContain("BIND(wd:Q55 AS ?country)"); - expect(stationsQuery("NL")).not.toContain("P297"); - }); - - it("reads a large country in parts that cover every id once", () => { - expect(PARTS.join("").split("").sort().join("")).toBe("0123456789"); - expect(stationsQuery("JP")).not.toContain("REGEX"); - expect(stationsQuery("JP", "89")).toContain('FILTER(REGEX(STR(?station), "[89]$"))'); + expect(codes.get("Q55")).toBe("NL"); + expect(codes.has("Q29999")).toBe(false); }); it("reads a large country's town ids alone, then their details by id", () => {