SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
7 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
8.7 KB · 121 lines typescript
Raw Blame History
1/**2 * World Bank Indicators API (https://api.worldbank.org/v2) → NormalizedCountryStat per ISO-2 country.3 * License: CC BY 4.0 (World Bank open data terms). Indicators:4 *   SP.POP.TOTL        population, total5 *   NY.GDP.MKTP.CD     GDP (current US$)6 *   EG.ELC.RNEW.ZS     renewable electricity output (% of total electricity output)   → renewableShare (percent, 0–100)7 *   EG.USE.ELEC.KH.PC  electric power consumption (kWh per capita)                     → electricityTwh = kWh/capita × population(same year) / 1e9 (derived, flagged estimate)8 *9 * One composed document: the country list (to drop aggregates) + the four indicator series (mrnev=1 = most10 * recent non-empty value) + a population time series so the derived electricity figure uses the population of11 * the same year as the per-capita value. All calls go through ctx.fetch, memoized per run.12 */13import type { ConnectorContext, DiscoveredUrl, ExtractedRecord, RawDocument } from "@dci/connectors";14import type { NormalizedCountryStat, NormalizedEntity, Provenance } from "@dci/core";15import { round } from "@dci/core";16import { DatasetConnector, fetchJsonOnce, iso2, num, str } from "./shared.js";1718interface WbCountry { id: string; iso2Code: string; name: string; region: { id: string; value: string }; incomeLevel?: { id: string; value: string } }19interface WbRow { indicator: { id: string; value: string }; country: { id: string; value: string }; countryiso3code: string; date: string; value: number | null }20type WbResponse<T> = [{ page: number; pages: number; per_page: number | string; total: number; lastupdated?: string }, T[] | null];2122const INDICATORS = { population: "SP.POP.TOTL", gdpUsd: "NY.GDP.MKTP.CD", renewableShare: "EG.ELC.RNEW.ZS", kwhPerCapita: "EG.USE.ELEC.KH.PC" } as const;2324export class WorldBankConnector extends DatasetConnector {25  private api(): string { return String(this.param("apiBase", "https://api.worldbank.org/v2")).replace(/\/+$/, ""); }26  private countriesUrl(): string { return `${this.api()}/country?format=json&per_page=400`; }27  private indicatorUrl(code: string): string { return `${this.api()}/country/all/indicator/${code}?format=json&per_page=20000&mrnev=1`; }28  private populationSeriesUrl(): string {29    const years = Math.max(3, this.param<number>("populationYears", 15));30    const to = new Date().getUTCFullYear();31    return `${this.api()}/country/all/indicator/${INDICATORS.population}?format=json&per_page=20000&date=${to - years}:${to}`;32  }33  private async json<T>(ctx: ConnectorContext, url: string): Promise<{ doc: RawDocument; rows: T[] | null; meta: WbResponse<T>[0] | null }> {34    const { doc, json } = await fetchJsonOnce<WbResponse<T>>(ctx, url, { pauseMs: this.param<number>("minDelayMs", 1500), timeoutMs: 90_000 });35    if (!Array.isArray(json) || json.length < 2 || !Array.isArray(json[1])) {36      if (json && !Array.isArray(json[1])) ctx.log("warn", `World Bank ${url}: no data (${JSON.stringify(json[0]).slice(0, 200)})`);37      return { doc, rows: null, meta: Array.isArray(json) ? json[0] : null };38    }39    if (json[0].pages > 1) ctx.log("warn", `World Bank ${url}: ${json[0].pages} pages, only the first was read — raise per_page`);40    return { doc, rows: json[1], meta: json[0] };41  }4243  async discover(_ctx: ConnectorContext): Promise<DiscoveredUrl[]> {44    return [{ url: this.countriesUrl(), group: "dataset", pageType: "dataset", priority: 100, meta: { object: "country-stats" } }];45  }4647  async fetch(ctx: ConnectorContext, u: DiscoveredUrl): Promise<RawDocument> {48    const { doc } = await this.json<WbCountry>(ctx, u.url);49    doc.group = u.group; doc.pageType = "dataset"; doc.meta = { ...(doc.meta ?? {}), ...(u.meta ?? {}) };50    if (doc.error || doc.status >= 400) return doc;51    for (const code of Object.values(INDICATORS)) await this.json(ctx, this.indicatorUrl(code));52    await this.json(ctx, this.populationSeriesUrl());53    return doc;54  }5556  async extract(ctx: ConnectorContext, doc: RawDocument): Promise<ExtractedRecord[]> {57    if (doc.error || doc.notModified || doc.status >= 400 || !doc.body.length) return [];58    let countries: WbCountry[];59    try { const j = JSON.parse(doc.text) as WbResponse<WbCountry>; countries = Array.isArray(j[1]) ? j[1] : []; } catch { ctx.log("warn", "World Bank country list: invalid JSON"); return []; }60    const real = new Map<string, WbCountry>();61    for (const c of countries) { const code = iso2(c.iso2Code); if (!code || c.region?.value === "Aggregates" || c.region?.id === "NA") continue; real.set(code, c); }62    ctx.log("info", `World Bank: ${countries.length} entries, ${real.size} countries/economies after dropping aggregates`);63    const series: Record<string, { rows: WbRow[]; url: string; updated: string | null }> = {};64    for (const [field, code] of Object.entries(INDICATORS)) {65      const url = this.indicatorUrl(code);66      const { rows, meta } = await this.json<WbRow>(ctx, url);67      if (!rows) { ctx.log("error", `World Bank ${code} unavailable — aborting document (no partial country stats)`); return []; }68      series[field] = { rows, url, updated: meta?.lastupdated ?? null };69    }70    const popSeriesUrl = this.populationSeriesUrl();71    const popSeries = await this.json<WbRow>(ctx, popSeriesUrl);72    const popByCountryYear = new Map<string, number>();73    for (const r of popSeries.rows ?? []) { const c = iso2(r.country?.id); if (c && r.value != null) popByCountryYear.set(`${c}:${r.date}`, r.value); }74    if (!popSeries.rows) ctx.log("warn", "World Bank population series unavailable — electricityTwh will not be derived");7576    const byCountry = new Map<string, Record<string, unknown>>();77    for (const [field, s] of Object.entries(series)) {78      for (const r of s.rows) {79        const c = iso2(r.country?.id);80        if (!c || !real.has(c) || r.value == null) continue;81        const d = byCountry.get(c) ?? byCountry.set(c, { iso2: c, name: real.get(c)!.name, iso3: real.get(c)!.id }).get(c)!;82        d[field] = r.value; d[`${field}Year`] = num(r.date); d[`${field}Url`] = s.url; d[`${field}Updated`] = s.updated;83        if (field === "kwhPerCapita") d.populationSameYear = popByCountryYear.get(`${c}:${r.date}`) ?? null;84      }85    }86    return [...byCountry.values()].map((d) => ({ kind: "country" as const, key: String(d.iso2), data: d, methods: { _all: "dataset-field:worldbank" }, certainty: 1, url: doc.finalUrl, pageType: "dataset" as const }));87  }8889  async normalize(ctx: ConnectorContext, records: ExtractedRecord[]): Promise<NormalizedEntity[]> {90    const out: NormalizedEntity[] = [];91    for (const r of records) {92      const d = r.data as Record<string, unknown>;93      const code = iso2(d.iso2); if (!code) continue;94      const prov = (url: string, method: string, extra: Partial<Provenance> = {}): Provenance => ctx.provenance(url, { method, extractorVersion: this.parserVersion, ...extra });95      const population = num(d.population), gdpUsd = num(d.gdpUsd), renewableShare = num(d.renewableShare), kwhPc = num(d.kwhPerCapita), popSameYear = num(d.populationSameYear);96      const electricityTwh = kwhPc != null && popSameYear != null ? round((kwhPc * popSameYear) / 1e9, 3) : null;97      if (population == null && gdpUsd == null && renewableShare == null && electricityTwh == null) continue;98      const years = [d.populationYear, d.gdpUsdYear, d.renewableShareYear, d.kwhPerCapitaYear].map((y) => num(y)).filter((y): y is number => y != null);99      // NormalizedCountryStat has no per-field `facts` channel: the per-indicator years and the derivation of100      // electricityTwh are recorded in the provenance note (entity-level provenance URL = country list document).101      const note = [102        `${str(d.name) ?? code} (${str(d.iso3) ?? "?"})`,103        population != null ? `population ${INDICATORS.population} year=${num(d.populationYear) ?? "?"}` : null,104        gdpUsd != null ? `gdpUsd ${INDICATORS.gdpUsd} year=${num(d.gdpUsdYear) ?? "?"}` : null,105        renewableShare != null ? `renewableShare ${INDICATORS.renewableShare} (% of electricity output) year=${num(d.renewableShareYear) ?? "?"}` : null,106        electricityTwh != null ? `electricityTwh DERIVED (estimate) = ${INDICATORS.kwhPerCapita} × ${INDICATORS.population} same year / 1e9, year=${num(d.kwhPerCapitaYear) ?? "?"}` : null,107      ].filter(Boolean).join("; ");108      const ent: NormalizedCountryStat = {109        entityType: "country", key: code, iso2: code,110        population, gdpUsd, electricityTwh, renewableShare,111        year: years.length ? Math.max(...years) : null,112        // isEstimate stays unset at entity level (population/GDP are measured); the derived field is flagged in method + note113        provenance: prov(r.url, electricityTwh != null ? "dataset-field:worldbank+derived-estimate:electricityTwh" : "dataset-field:worldbank", { note }),114      };115      out.push(ent);116    }117    ctx.log("info", `World Bank: ${out.length} country stats`);118    return out;119  }120}121