/** * Wikidata connector — SPARQL (https://query.wikidata.org/sparql), items that are instances of * "data center" (Q671224) or one of its subclasses (P279*). Data is CC0. * * Note: the Q-id sometimes quoted for data center, Q1063421, is a French general; Q671224 is the class. * discover() runs one COUNT query and emits one page URL per `params.pageSize` items (LIMIT/OFFSET on an * ordered sub-select). Each page is one GET with the query in the URL; the result is grouped one row per * item (SAMPLE/GROUP_CONCAT) so multi-valued properties never duplicate a facility. */ import type { ConnectorContext, DiscoveredUrl, ExtractedRecord, RawDocument } from "@dci/connectors"; import type { Fact, FacilityStatus, FacilityType, NormalizedEntity, NormalizedFacility, Provenance } from "@dci/core"; import { inferFacilityType, validLatLng } from "@dci/core"; import { DatasetConnector, httpUrl, iso2, num, str, uniqNames } from "./shared.js"; interface SparqlBinding { type: string; value: string; "xml:lang"?: string; datatype?: string } interface SparqlResult { head: { vars: string[] }; results: { bindings: Array> } } const ACCEPT = "application/sparql-results+json"; export class WikidataConnector extends DatasetConnector { private endpoint(): string { return String(this.param("endpoint", "https://query.wikidata.org/sparql")); } private classQid(): string { return String(this.param("classQid", "Q671224")); } private languages(): string { return String(this.param("labelLanguages", "en,fr,de,es,nl,it,pt,ja,zh,mul")); } private sparqlUrl(query: string): string { return `${this.endpoint()}?query=${encodeURIComponent(query.replace(/\s+/g, " ").trim())}`; } private countQuery(): string { return `SELECT (COUNT(DISTINCT ?item) AS ?n) WHERE { ?item wdt:P31/wdt:P279* wd:${this.classQid()} . }`; } private pageQuery(limit: number, offset: number): string { return `SELECT ?item ?itemLabel ?itemDescription (SAMPLE(?coord) AS ?coord) (SAMPLE(?inception) AS ?inception) (SAMPLE(?incPrec) AS ?incPrec) (SAMPLE(?dissolved) AS ?dissolved) (GROUP_CONCAT(DISTINCT ?operatorLabel; separator="|") AS ?operators) (GROUP_CONCAT(DISTINCT ?ownerLabel; separator="|") AS ?owners) (GROUP_CONCAT(DISTINCT ?iso2; separator="|") AS ?iso2s) (SAMPLE(?website) AS ?website) (GROUP_CONCAT(DISTINCT ?locatedLabel; separator="|") AS ?located) (GROUP_CONCAT(DISTINCT ?classLabel; separator="|") AS ?classes) (GROUP_CONCAT(DISTINCT ?altLabel; separator="|") AS ?altLabels) (SAMPLE(?enwiki) AS ?enwiki) WHERE { { SELECT DISTINCT ?item WHERE { ?item wdt:P31/wdt:P279* wd:${this.classQid()} . } ORDER BY ?item LIMIT ${limit} OFFSET ${offset} } OPTIONAL { ?item wdt:P625 ?coord . } OPTIONAL { ?item p:P571/psv:P571 ?incNode . ?incNode wikibase:timeValue ?inception ; wikibase:timePrecision ?incPrec . } OPTIONAL { ?item wdt:P576 ?dissolved . } OPTIONAL { ?item wdt:P137 ?operator . } OPTIONAL { ?item wdt:P127 ?owner . } OPTIONAL { ?item wdt:P17 ?country . ?country wdt:P297 ?iso2 . } OPTIONAL { ?item wdt:P856 ?website . } OPTIONAL { ?item wdt:P131 ?located . } OPTIONAL { ?item wdt:P31 ?class . } OPTIONAL { ?item skos:altLabel ?altLabel . FILTER(LANG(?altLabel) = "en") } OPTIONAL { ?enwiki schema:about ?item ; schema:isPartOf . } SERVICE wikibase:label { bd:serviceParam wikibase:language "${this.languages()}". ?item rdfs:label ?itemLabel . ?item schema:description ?itemDescription . ?operator rdfs:label ?operatorLabel . ?owner rdfs:label ?ownerLabel . ?located rdfs:label ?locatedLabel . ?class rdfs:label ?classLabel . } } GROUP BY ?item ?itemLabel ?itemDescription`; } async discover(ctx: ConnectorContext): Promise { const pageSize = Math.max(100, Math.min(5000, this.param("pageSize", 2000))); const countDoc = await ctx.fetch(this.sparqlUrl(this.countQuery()), { group: "dataset", accept: ACCEPT, timeoutMs: 90_000 }); let total: number | null = null; if (!countDoc.error && countDoc.status === 200) { try { total = num((JSON.parse(countDoc.text) as SparqlResult).results.bindings[0]?.n?.value); } catch { total = null; } } if (total == null) { ctx.log("warn", `Wikidata COUNT query failed (${countDoc.error?.code ?? countDoc.status}); falling back to a single page`); total = pageSize; } const pages = Math.max(1, Math.ceil(total / pageSize)); ctx.log("info", `Wikidata: ${total} items in class tree of ${this.classQid()} → ${pages} page(s) of ${pageSize}`); return Array.from({ length: pages }, (_, i) => ({ url: this.sparqlUrl(this.pageQuery(pageSize, i * pageSize)), group: "dataset", pageType: "dataset" as const, priority: 100 - i, meta: { page: i, pageSize, total } })); } async fetch(ctx: ConnectorContext, u: DiscoveredUrl): Promise { const doc = await ctx.fetch(u.url, { group: u.group, accept: ACCEPT, timeoutMs: 90_000 }); doc.group = u.group; doc.pageType = "dataset"; doc.meta = { ...(doc.meta ?? {}), ...(u.meta ?? {}) }; if (doc.status === 429) ctx.log("warn", `Wikidata SPARQL rate limited (retry-after ${doc.headers["retry-after"] ?? "?"}s)`); return doc; } async extract(ctx: ConnectorContext, doc: RawDocument): Promise { if (doc.error || doc.notModified || doc.status >= 400 || !doc.body.length) return []; let res: SparqlResult; try { res = JSON.parse(doc.text) as SparqlResult; } catch { ctx.log("warn", `Wikidata page ${String(doc.meta?.page)}: invalid JSON`); return []; } const out: ExtractedRecord[] = []; let unlabeled = 0; for (const b of res.results?.bindings ?? []) { const qid = b.item?.value.split("/").pop(); if (!qid || !/^Q\d+$/.test(qid)) continue; const label = str(b.itemLabel?.value); if (!label || label === qid) { unlabeled++; continue; } // no label in any configured language → not a usable name const data: Record = { qid }; for (const [k, v] of Object.entries(b)) data[k] = v.value; out.push({ kind: "facility", key: `wikidata:${qid}`, data, methods: { _all: "sparql", geo: "wikidata:P625" }, certainty: 1, url: `https://www.wikidata.org/wiki/${qid}`, pageType: "dataset" }); } if (unlabeled) ctx.log("info", `Wikidata page ${String(doc.meta?.page)}: ${unlabeled} item(s) without a label skipped`); return out; } async normalize(ctx: ConnectorContext, records: ExtractedRecord[]): Promise { const out: NormalizedEntity[] = []; const nowYear = new Date().getUTCFullYear(); for (const r of records) { const d = r.data as Record; const name = str(d.itemLabel); if (!name) continue; const prov = (method: string, extra: Partial = {}): Provenance => ctx.provenance(r.url, { method, extractorVersion: this.parserVersion, ...extra }); const split = (s: string | undefined) => (s ?? "").split("|").map((x) => x.trim()).filter(Boolean); const coord = d.coord?.match(/Point\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i); const lng = coord ? Number(coord[1]) : null, lat = coord ? Number(coord[2]) : null; const isoList = split(d.iso2s).map((x) => iso2(x)).filter((x): x is string => Boolean(x)); const operators = split(d.operators), owners = split(d.owners), located = split(d.located), classes = split(d.classes); let openedOn = partialFromWikidata(d.inception, num(d.incPrec)); // P571 on a building/organisation item often predates the data center use (e.g. 1930s telephone exchanges) → not a DC opening date if (openedOn && Number(openedOn.slice(0, 4)) < 1950) { ctx.log("debug", `${d.qid}: ignoring inception ${openedOn} (< 1950)`); openedOn = null; } const dissolved = partialFromWikidata(d.dissolved, null); let status: FacilityStatus | null = null; if (dissolved) status = "closed"; else if (openedOn && Number(openedOn.slice(0, 4)) > nowYear) status = "announced"; const classText = classes.join(" "); const facilityType: FacilityType | null = /high performance computing|supercomputing/i.test(classText) ? "hpc" : /\bAI data center/i.test(classText) ? "ai" : /colocation/i.test(classText) ? "colocation" : inferFacilityType(`${name} ${d.itemDescription ?? ""} ${classText}`); const facts: Fact[] = []; if (openedOn) facts.push({ field: "openedOn", value: openedOn, provenance: prov("wikidata:P571") }); if (lat != null && lng != null) facts.push({ field: "geo", value: { lat, lng }, provenance: prov("wikidata:P625") }); const externalIds: Record = { wikidata: d.qid! }; if (d.enwiki) externalIds.wikipedia_en = d.enwiki; const ent: NormalizedFacility = { entityType: "facility", key: r.key, name, aliases: uniqNames(split(d.altLabels), [name]), operatorName: operators[0] ?? null, ownerName: owners[0] ?? null, city: located[0] ?? null, // P131 = located in the administrative territorial entity (municipality most of the time, sometimes a county/district) countryIso2: isoList.length === 1 ? isoList[0]! : null, geo: validLatLng(lat, lng) ? { lat: lat!, lng: lng!, precision: "street", source: "dataset:wikidata" } : null, status, facilityType, openedOn, website: httpUrl(d.website), externalIds, description: str(d.itemDescription), facts, provenance: prov("sparql:wikidata_dc_v1"), }; // multiple operators / countries: the first operator is kept as operatorName, the country is left null when ambiguous; the full lists are kept in the description if (operators.length > 1 || isoList.length > 1) ent.description = [ent.description, operators.length > 1 ? `Operators (Wikidata P137): ${operators.join(", ")}` : null, isoList.length > 1 ? `Countries (Wikidata P17): ${isoList.join(", ")}` : null].filter(Boolean).join(" — ") || null; out.push(ent); } return out; } } /** Wikidata time value + precision (9 = year, 10 = month, 11 = day; anything coarser → year only) → partial date string. */ function partialFromWikidata(value: string | undefined, precision: number | null): string | null { if (!value) return null; const m = value.match(/^([+-]?\d{4,})-(\d{2})-(\d{2})/); if (!m) return null; const year = Number(m[1]); if (!Number.isFinite(year) || year < 1000 || year > 2200) return null; const y = String(year).padStart(4, "0"); if (precision != null && precision >= 11) return `${y}-${m[2]}-${m[3]}`; if (precision != null && precision === 10) return `${y}-${m[2]}`; return y; // unknown precision (simple value) or year precision: never claim a day Wikidata did not assert }