- README rewritten as a BLUF-first vision cast for vibe coders (key-terms glossary, origin story, features, requirements, data caveat, developer's note). - library/components/WARNING.md: prototype in your app-profile; only proven, parameterized components graduate into the shared library. - generate-tree.mjs: inject Subresource Integrity (sha384) + crossorigin into the standalone page <script> tags and the copy-paste embed snippets (manifest = registry). - Scrubbed identity-tied phrasing from comments/docs, keeping the technical rationale and the mission content.
129 lines
5.5 KiB
JavaScript
129 lines
5.5 KiB
JavaScript
// fetch-connectivity.mjs — COUNTRY-level connectivity, from a citable public API.
|
|
//
|
|
// node fetch-connectivity.mjs -> out/connectivity-by-country.json
|
|
//
|
|
// SOURCE: World Bank Open Data (api.worldbank.org, v2). Public, no key, no account
|
|
// — which satisfies the no-account privacy constraint (no signups).
|
|
// IT.NET.USER.ZS individuals using the internet, % of population
|
|
// IT.CEL.SETS.P2 mobile cellular subscriptions per 100 people
|
|
// SP.POP.TOTL population
|
|
//
|
|
// ═══ WHY THIS IS COUNTRY-LEVEL AND NOT CITY-LEVEL ═══════════════════════════
|
|
// The brief asks for internet access PER CITY. There is no free, public, citable
|
|
// per-city connectivity dataset covering 175 countries — and inventing one by
|
|
// assuming "the city has whatever the country has" would be a guess wearing a
|
|
// citation. So this file records what the source ACTUALLY supports: a national
|
|
// figure, with its year, attached to each country.
|
|
//
|
|
// Each location cluster inherits its country's figure as CONTEXT, explicitly
|
|
// labelled `resolution: "country"`. A consumer can see it is not a city
|
|
// measurement. When per-city data is later sourced, it overwrites with
|
|
// `resolution: "city"` and its own citation.
|
|
// design note constraint: "cite sources; mark unknowns as unknown, never guess."
|
|
//
|
|
// NOT FETCHED HERE: censorship / shutdown history. Those live in reports
|
|
// (OONI, Access Now #KeepItOn, Freedom on the Net) that are not machine-readable
|
|
// per-country APIs, so they are left `unknown` rather than approximated. Filling
|
|
// them is a per-country human-cited task, not something to automate badly.
|
|
|
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const OUT = join(__dirname, 'out');
|
|
|
|
const INDICATORS = {
|
|
internet_pct: 'IT.NET.USER.ZS',
|
|
mobile_per_100: 'IT.CEL.SETS.P2',
|
|
population: 'SP.POP.TOTL',
|
|
};
|
|
const SOURCE = {
|
|
name: 'World Bank Open Data',
|
|
url: 'https://api.worldbank.org/v2',
|
|
licence: 'CC BY 4.0',
|
|
accessed: '2026-08-01',
|
|
};
|
|
|
|
const spine = JSON.parse(readFileSync(join(OUT, 'city-candidates.json'), 'utf8'));
|
|
const isos = [...new Set(spine.clusters.map((c) => c.iso3))].sort();
|
|
|
|
// Most-recent non-null observation in the window, with its YEAR — a figure without
|
|
// a year is not citable.
|
|
async function latest(iso3, indicator) {
|
|
const url = `${SOURCE.url}/country/${iso3}/indicator/${indicator}?format=json&date=2018:2024&per_page=20`;
|
|
try {
|
|
const res = await fetch(url);
|
|
if (!res.ok) return null;
|
|
const body = await res.json();
|
|
const obs = Array.isArray(body) && Array.isArray(body[1]) ? body[1] : [];
|
|
for (const o of obs) {
|
|
if (o && o.value !== null && o.value !== undefined) {
|
|
return { value: o.value, year: o.date };
|
|
}
|
|
}
|
|
} catch (_) { /* network failure is an UNKNOWN, never a zero */ }
|
|
return null;
|
|
}
|
|
|
|
if (!existsSync(OUT)) mkdirSync(OUT, { recursive: true });
|
|
const OUTFILE = join(OUT, 'connectivity-by-country.json');
|
|
|
|
// RESUMABLE + INCREMENTAL. The first run of this script fetched serially and only
|
|
// wrote at the end — it was killed at a 600s timeout and lost EVERY row. Two fixes:
|
|
// countries run in parallel batches, and the file is flushed as it goes, so a kill
|
|
// costs one batch rather than the whole run.
|
|
const out = existsSync(OUTFILE)
|
|
? (JSON.parse(readFileSync(OUTFILE, 'utf8')).countries || {})
|
|
: {};
|
|
let ok = 0, unknown = 0;
|
|
|
|
const flush = () => writeFileSync(OUTFILE, JSON.stringify({
|
|
stats: { countries: isos.length, resolved: ok, unknown, partial: Object.keys(out).length < isos.length, generated: '2026-08-01' },
|
|
source: SOURCE,
|
|
countries: out,
|
|
}, null, 1));
|
|
|
|
const BATCH = 8; // polite to a public API; ~22 batches for 175 countries
|
|
const todo = isos.filter((i) => !out[i]);
|
|
console.log(`[connectivity] ${todo.length} to fetch (${isos.length - todo.length} already cached)`);
|
|
|
|
for (let b = 0; b < todo.length; b += BATCH) {
|
|
const slice = todo.slice(b, b + BATCH);
|
|
await Promise.all(slice.map(async (iso) => {
|
|
const [net, mob, pop] = await Promise.all([
|
|
latest(iso, INDICATORS.internet_pct),
|
|
latest(iso, INDICATORS.mobile_per_100),
|
|
latest(iso, INDICATORS.population),
|
|
]);
|
|
|
|
// The yes/no/partial call is a THRESHOLD ON A CITED NUMBER, not a judgement.
|
|
// Bands are stated here so a reader can disagree with them explicitly.
|
|
let access = 'unknown';
|
|
if (net) access = net.value >= 60 ? 'yes' : net.value >= 25 ? 'partial' : 'no';
|
|
|
|
out[iso] = {
|
|
iso3: iso,
|
|
internet_access: access,
|
|
internet_pct: net ? +net.value.toFixed(1) : null,
|
|
internet_year: net ? net.year : null,
|
|
mobile_per_100: mob ? +mob.value.toFixed(1) : null,
|
|
mobile_year: mob ? mob.year : null,
|
|
national_population: pop ? Math.round(pop.value) : null,
|
|
population_year: pop ? pop.year : null,
|
|
// Deliberately unfilled — see the header note.
|
|
censorship: 'unknown',
|
|
censorship_note: 'not machine-readable from this source; requires a cited report (OONI / Access Now / Freedom on the Net)',
|
|
resolution: 'country',
|
|
banding: 'yes >=60% · partial 25-59% · no <25% of population using the internet',
|
|
source: SOURCE,
|
|
};
|
|
|
|
if (access === 'unknown') unknown++; else ok++;
|
|
}));
|
|
flush(); // survive a kill
|
|
console.log(`[connectivity] ${Math.min(b + BATCH, todo.length)}/${todo.length} …`);
|
|
}
|
|
|
|
flush();
|
|
console.log(`[connectivity] ${ok} resolved, ${unknown} unknown -> out/connectivity-by-country.json`);
|