/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * `geonames-postal`: GeoNames postal-code dump consumer (https://www.geonames.org/, CC-BY-4.0). * * The GeoNames postal export (`https://download.geonames.org/export/zip/.zip`) is a clean, * per-country `postcode → place → admin1` table with the place + region NAMES inline (no aux-file * join needed). It broadens the corpus's postcode→locality→region coverage to ~80 countries, well * beyond `wof-postalcode`/the coordinate-first table — forward coverage for the multi-locale * goal. * * Input: a per-country postal dump (`.txt`, 12 tab-separated columns, no header): country, * postcode, place, admin1_name, admin1_code, admin2__, admin3__, lat, lon, accuracy. * * Output: per row, postcode-FIRST (international) variants — the common order for the non-US * locales this fills (US postcodes are already covered by TIGER/WOF, which use postcode-LAST): * * 1. `{ postcode, locality }` → "AD100 Canillo" * 2. `{ postcode, locality, region }` → "AD100 Canillo, Canillo" Prefer configuring this adapter for * non-US countries; for US, the postcode-last sources are the right order. License: * `"CC-BY-4.0"` per row (attribute "GeoNames"). */ import { stableSourceID } from "@mailwoman/corpus/adapters/utils" import type { AdapterOptions, CanonicalRow, CorpusAdapter } from "@mailwoman/corpus/types" import { reconcileComponents } from "@mailwoman/formatter" import { TSVSpliterator } from "spliterator" /** * Registry id for this adapter. Stamped into every row it emits, so a corpus record can be traced back to the dataset * it came from. */ export const GEONAMES_POSTAL_ADAPTER_ID = "geonames-postal" /** * License carried by this source (CC-BY-4.0), attached to each row so downstream consumers inherit the terms rather * than having to look them up. */ export const GEONAMES_POSTAL_DEFAULT_LICENSE = "CC-BY-4.0" /** * GeoNames postal-dump columns (0-based). */ const COL = { country: 0, postcode: 1, place: 2, admin1Name: 3 } as const export function createGeonamesPostalAdapter(): CorpusAdapter { return { id: GEONAMES_POSTAL_ADAPTER_ID, defaultLicense: GEONAMES_POSTAL_DEFAULT_LICENSE, description: "GeoNames postal codes (CC-BY-4.0) — multi-locale postcode→locality→region, names inline; international postcode-first order.", async *rows(opts: AdapterOptions): AsyncIterable { // `header: false` — the GeoNames postal dump is headerless, and the spliterator would // otherwise consume row 1 as column names and lose its first postcode. const rows = TSVSpliterator.fromAsync(opts.inputPath, { header: false }) let emitted = 0 for await (const rec of rows as AsyncIterable) { if (opts.signal?.aborted) break if (opts.limit !== undefined && emitted >= opts.limit) break const cc = (rec[COL.country] ?? "").trim() if (!cc) continue if (opts.country && cc !== opts.country) continue const postcode = (rec[COL.postcode] ?? "").trim() const locality = (rec[COL.place] ?? "").trim() if (!postcode || !locality) continue const region = (rec[COL.admin1Name] ?? "").trim() // Postcode-first (international) variants. Skip the region variant when admin1 just // repeats the place (common for city-states / micro-admin) to avoid "X X" noise. const variants: Array<{ slot: string; comp: CanonicalRow["components"]; raw: string }> = [ { slot: "pl", comp: { postcode, locality }, raw: `${postcode} ${locality}` }, ] if (region && region.toLowerCase() !== locality.toLowerCase()) { variants.push({ slot: "plr", comp: { postcode, locality, region }, raw: `${postcode} ${locality}, ${region}`, }) } for (const v of variants) { if (opts.limit !== undefined && emitted >= opts.limit) break const aligned = reconcileComponents(v.comp, v.raw) if (Object.keys(aligned).length < 2) continue yield { raw: v.raw, components: aligned, country: cc, source: GEONAMES_POSTAL_ADAPTER_ID, source_id: `${stableSourceID(GEONAMES_POSTAL_ADAPTER_ID, aligned)}-${v.slot}`, corpus_version: "", license: GEONAMES_POSTAL_DEFAULT_LICENSE, } emitted++ } } }, } } /** * The configured adapter instance registered with the corpus builder. */ export const geonamesPostalAdapter = createGeonamesPostalAdapter()