/** * @copyright Sister Software * @license AGPL-3.0 * @author Teffen Ellis, et al. * * Downloads the `Matrikkelen - Adresse` CSV extracts Kartverket publishes through Geonorge. * * ## Two areas, because the adapter emits two jurisdictions * * `#no/adapters/matrikkelen/adapter` reads `kommunenummer` to decide a row's country, and * municipality 2100 is Svalbard rather than the mainland. Geonorge publishes the dataset per area * from one 375-entry area list, and the two entries this repository reads are `0000` `Hele landet` * and `2100` `Svalbard`. The mainland extract carries no Svalbard row, so taking only `0000` would * leave `SJ` with no rows while the adapter still claimed to cover it. * * Each area is a separate archive at the dataset's conventional download path, so no download * order has to be placed through Geonorge's order API: * * …/MatrikkelenAdresse/CSV/Basisdata___4258_MatrikkelenAdresse_CSV.zip * * ## Why the member is extracted * * The adapter opens `opts.inputPath` with `CSVSpliterator.fromAsync`, which reads a delimited file * rather than an archive, so the member is written out beside the archive it came from. The member * is named `matrikkelenAdresse.csv` in every area's archive, inside a directory named after the * archive, so each area is written under its own area-code directory and the publisher's own file * name is kept. * * The archive is kept rather than removed, because its sha256 is the value the address-source * register records for the publication and the member's is not. * * ## Freshness * * Geonorge serves `last-modified` and `content-length` on the archive, and a regenerated extract * changes both. A skip requires a stated `last-modified`: where the service states none, both * sides read `null`, an equality test would hold, and an archive of unknown age would be kept for * as long as the service stayed silent. * * ## The header is checked on arrival * * A renamed column reaches the adapter as an empty string on every row rather than as an error, so * the extract's header is read through the same `CSVSpliterator` the adapter uses and checked * against the nine columns the adapter indexes by name. The file's first column name carries a * byte-order mark, and none of the nine is that column, so the check does not depend on the mark * being stripped. */ import { APIClient } from "@mailwoman/core/api" import { ByteFormatter } from "@mailwoman/core/fs/formatters" import { tryStat } from "@mailwoman/core/fs/readers" import { makeDirectories } from "@mailwoman/core/fs/writers" import { extractZipEntry } from "@mailwoman/core/fs/zip" import { sha256File } from "@mailwoman/core/hash" import { PathBuilder, type PathBuilderLike } from "path-ts" import { MATRIKKELEN_ADAPTER_ID } from "#no/adapters/matrikkelen/adapter" import type { BaseFetchOptions, FetchSummary, SourceCollectionManifest, SourceManifest } from "#tools/fetch/download" import { downloadToFile, loadCollectionFiles, writeManifest } from "#tools/fetch/download" import { assertHeaderColumns, readDelimitedHeader } from "#tools/fetch/header" /** * One area of the dataset, as Geonorge's own area list for it spells the two parts of its file name. */ export interface MatrikkelenArea { /** * The area code, which is also the directory each area is written under. */ code: string /** * The area name as the download path spells it, which is not always the list's `name`: * area `0000` is named `Hele landet` in the list and `Norge` in the path. */ pathName: string } /** * The areas a fetch takes when a caller names none. * * `0000` is the mainland and `2100` is Svalbard, which are the two areas the * address-source register carries rows for. * Svalbard is also published as `fylke` 21, which holds the same rows as kommune 2100. */ export const MATRIKKELEN_AREAS: readonly MatrikkelenArea[] = [ { code: "0000", pathName: "Norge" }, { code: "2100", pathName: "Svalbard" }, ] /** * The area code of the whole-country extract, which carries every mainland municipality. */ export const MATRIKKELEN_MAINLAND_AREA = "0000" /** * The projection the CSV extracts are taken in. * * EPSG:4258 is the geographic projection, and it is the one the register measured. * The same extract is published in EPSG:25833 and differs only in its coordinates. */ export const MATRIKKELEN_PROJECTION = "4258" /** * The directory the dataset's per-area archives sit in. */ export const MATRIKKELEN_DOWNLOAD_ROOT = "https://nedlasting.geonorge.no/geonorge/Basisdata/MatrikkelenAdresse/CSV" /** * The dataset's metadata record, which is where the elected license is stated. */ export const MATRIKKELEN_METADATA_URL = "https://kartkatalog.geonorge.no/api/getdata/f7df7a18-b30f-4745-bd64-d0863812350c" /** * The name of the member inside every area's archive, and of the file the adapter reads. */ export const MATRIKKELEN_MEMBER_FILENAME = "matrikkelenAdresse.csv" /** * The column delimiter the publisher writes. */ export const MATRIKKELEN_DELIMITER = ";" /** * The columns `#no/adapters/matrikkelen/adapter` reads by name. * * Checked against the extract's header on arrival, because a renamed column reaches * the adapter as an empty string on every row rather than as an error. */ export const MATRIKKELEN_REQUIRED_COLUMNS: readonly string[] = [ "kommunenummer", "adressetype", "adressenavn", "nummer", "bokstav", "adresseTekst", "postnummer", "poststed", "adresseId", ] /** * The attribution CC BY 4.0 §3(a)(1) requires on a publication derived from these rows. */ export const MATRIKKELEN_ATTRIBUTION = "Kartverket" /** * The license the address-source register elected for this publisher. * * Kartverket's metadata record states CC BY 4.0 across five agreeing fields, * and the adapter stamps the same identifier on every row. */ export const MATRIKKELEN_LICENSE = "CC-BY-4.0" const SLUG = MATRIKKELEN_ADAPTER_ID /** * The archive's name for one area, which is also the name it is written under. */ export function matrikkelenArchiveFilename(area: MatrikkelenArea): string { return `Basisdata_${area.code}_${area.pathName}_${MATRIKKELEN_PROJECTION}_MatrikkelenAdresse_CSV.zip` } /** * The archive's URL for one area. */ export function matrikkelenArchiveURL(area: MatrikkelenArea): string { return `${MATRIKKELEN_DOWNLOAD_ROOT}/${matrikkelenArchiveFilename(area)}` } /** * What one area's run recorded. * * `last_modified` is the service's own header and the one freshness signal it offers, * so a changed value is the one reason to download that area again. * `bytes` and `sha256` describe the archive, which is the artifact the * address-source register records a digest for. */ export interface MatrikkelenFileManifest extends SourceManifest { area_code: string last_modified: string | null member_filename: string member_bytes: number member_sha256: string } /** * The recorded entry for one area, or `undefined` where the manifest holds none that can decide a skip. * * `loadCollectionFiles` reads the shared collection shape, which states what every * source's manifest states and not this source's area fields. * An entry written before those fields existed, or written with no stated `last_modified`, * cannot answer whether the archive on disk is current, and this reports that as * no recorded entry rather than as an entry that disagrees. */ export function recordedMatrikkelenArea(entry: SourceManifest | undefined): MatrikkelenFileManifest | undefined { const candidate = entry as (Partial & SourceManifest) | undefined if (!candidate) return undefined const complete = typeof candidate.area_code === "string" && typeof candidate.last_modified === "string" && typeof candidate.member_filename === "string" && typeof candidate.member_bytes === "number" && typeof candidate.member_sha256 === "string" return complete ? (candidate as MatrikkelenFileManifest) : undefined } /** * What the service's HEAD response states about one area's archive. * * Both fields read `null` where the header is absent, rather than an empty string or zero. * An absent `last-modified` is the service declining to state a version, which is a * different fact from a version that happens to match the one on disk. */ export interface MatrikkelenPublication { lastModified: string | null reportedBytes: number | null } /** * Reads the service's HEAD response for one area. * * Separate from {@linkcode downloadMatrikkelen} because this one request carries the whole freshness * decision, and the download itself runs on global `fetch`, which a unit test cannot intercept. */ export async function readMatrikkelenPublication( client: Pick, area: MatrikkelenArea, options: { signal?: AbortSignal } = {} ): Promise { const head = await client.fetch({ method: "HEAD", url: matrikkelenArchiveURL(area), timeout: 120_000, signal: options.signal, }) const reported = Number(head.headers?.["content-length"] ?? Number.NaN) return { lastModified: String(head.headers?.["last-modified"] ?? "") || null, reportedBytes: Number.isFinite(reported) ? reported : null, } } /** * Whether one area's archive and extract on disk are the ones the service currently serves. * * A skip requires the service to state a `last-modified` value. * Where it states none, both sides read `null` and an equality test would hold, * which would keep an archive of unknown age for as long as the service stayed silent. */ export function matrikkelenPublicationIsRecorded( recorded: MatrikkelenFileManifest, bytesOnDisk: number, memberBytesOnDisk: number, publication: MatrikkelenPublication ): boolean { if (publication.lastModified === null) return false return ( recorded.last_modified === publication.lastModified && recorded.bytes === bytesOnDisk && recorded.member_bytes === memberBytesOnDisk ) } /** * The areas named by their codes, or every area in {@linkcode MATRIKKELEN_AREAS}. * * @throws Naming the codes that the area list does not carry, so a typed code * reports itself rather than reading as a fetch of no areas. */ export function matrikkelenAreasFor(codes: readonly string[] | undefined): readonly MatrikkelenArea[] { if (!codes?.length) return MATRIKKELEN_AREAS const wanted = codes.map((code) => code.trim()).filter((code) => code.length > 0) const absent = wanted.filter((code) => !MATRIKKELEN_AREAS.some((area) => area.code === code)) if (absent.length) { throw new Error( `${SLUG}: ${absent.join(", ")} is not an area this fetcher carries a download path for — it carries ${MATRIKKELEN_AREAS.map((area) => area.code).join(", ")}` ) } return MATRIKKELEN_AREAS.filter((area) => wanted.includes(area.code)) } /** * Per-invocation options. */ export interface DownloadMatrikkelenOptions { outputDir: PathBuilderLike /** * The areas to take, defaulting to {@linkcode MATRIKKELEN_AREAS}. */ areas?: readonly MatrikkelenArea[] /** * Downloads each area even where the manifest's `last_modified` and byte counts still match. */ force?: boolean retries?: number retryDelayMs?: number signal?: AbortSignal report?: (line: string) => void } /** * Downloads one area's archive, extracts the member the adapter reads and checks its header. * * @returns The manifest entry for the area, which the collection manifest carries. */ export async function downloadMatrikkelenArea( client: Pick, area: MatrikkelenArea, options: { outputDir: PathBuilderLike recorded?: MatrikkelenFileManifest force?: boolean retries?: number retryDelayMs?: number signal?: AbortSignal report?: (line: string) => void } ): Promise<{ entry: MatrikkelenFileManifest; downloaded: boolean }> { const { report } = options const archiveFilename = matrikkelenArchiveFilename(area) const areaDir = PathBuilder.from(options.outputDir)(area.code) const archivePath = areaDir(archiveFilename) const memberPath = areaDir(MATRIKKELEN_MEMBER_FILENAME) report?.(`=== ${SLUG} / ${archiveFilename}`) const publication = await readMatrikkelenPublication(client, area, { signal: options.signal }) report?.( ` HEAD: ${publication.reportedBytes === null ? "no content-length" : ByteFormatter.formatIEC(publication.reportedBytes)}` + `, last-modified ${publication.lastModified ?? "unstated"}` ) await makeDirectories(areaDir) const archiveStat = await tryStat(archivePath) const memberStat = await tryStat(memberPath) if ( !options.force && options.recorded && archiveStat && memberStat && matrikkelenPublicationIsRecorded(options.recorded, archiveStat.size, memberStat.size, publication) ) { report?.(` present, and the service's last-modified is unchanged`) return { entry: options.recorded, downloaded: false } } const { bytes } = await downloadToFile({ url: matrikkelenArchiveURL(area), dest: archivePath, retries: options.retries, retryDelayMs: options.retryDelayMs, report, }) const sha256 = await sha256File(archivePath) report?.(` ✓ ${ByteFormatter.formatIEC(bytes)} sha256=${sha256}`) // The member sits inside a directory named after the archive, so the selector matches // the name's tail rather than the whole archive-internal path. const memberBytes = await extractZipEntry(archivePath, /(?:^|\/)matrikkelenAdresse\.csv$/u, memberPath) const header = await readDelimitedHeader(memberPath, MATRIKKELEN_DELIMITER) assertHeaderColumns(header, MATRIKKELEN_REQUIRED_COLUMNS, `${SLUG} ${area.code} ${MATRIKKELEN_MEMBER_FILENAME}`) const memberSHA256 = await sha256File(memberPath) report?.( ` ${MATRIKKELEN_MEMBER_FILENAME}: ${ByteFormatter.formatIEC(memberBytes)} over ${header.length} columns sha256=${memberSHA256}` ) return { entry: { source_url: matrikkelenArchiveURL(area), filename: archiveFilename, area_code: area.code, bytes, sha256, last_modified: publication.lastModified, member_filename: MATRIKKELEN_MEMBER_FILENAME, member_bytes: memberBytes, member_sha256: memberSHA256, downloaded_at: new Date().toISOString(), }, downloaded: true, } } /** * Downloads every requested area and writes the collection manifest beside them. * * An area that fails is counted and the rest are still taken, because the two areas are * separate publications and Svalbard's 57 KiB does not depend on the mainland's 145 MiB. */ export async function downloadMatrikkelen( client: Pick, options: DownloadMatrikkelenOptions ): Promise { const { report } = options const destDir = PathBuilder.from(options.outputDir) const manifestPath = destDir("MANIFEST.json") const areas = options.areas ?? MATRIKKELEN_AREAS await makeDirectories(destDir) const entries = await loadCollectionFiles(manifestPath) let fetched = 0 let skipped = 0 const failedCodes: string[] = [] for (const area of areas) { if (options.signal?.aborted) break try { const { entry, downloaded } = await downloadMatrikkelenArea(client, area, { outputDir: destDir, recorded: recordedMatrikkelenArea(entries.get(matrikkelenArchiveFilename(area))), force: options.force, retries: options.retries, retryDelayMs: options.retryDelayMs, signal: options.signal, report, }) entries.set(entry.filename, entry) if (downloaded) { fetched += 1 } else { skipped += 1 } } catch (error) { report?.(` ✗ ${area.code}: ${error instanceof Error ? error.message : String(error)}`) failedCodes.push(area.code) } } const manifest: SourceCollectionManifest = { source: SLUG, source_url: MATRIKKELEN_METADATA_URL, license: MATRIKKELEN_LICENSE, attribution: MATRIKKELEN_ATTRIBUTION, downloaded_at: new Date().toISOString(), // Sorted by area code, so the manifest's order is the area list's rather than the run's. files: [...entries.values()].toSorted((left, right) => left.filename.localeCompare(right.filename)), } await writeManifest(manifestPath, manifest) report?.(` MANIFEST written to ${manifestPath}`) return { fetched, skipped, failed: failedCodes.length, failedCodes } } /** * The path `#no/adapters/matrikkelen/adapter` reads for one area, given the root a fetch wrote under. * * The area is a parameter because the adapter covers two jurisdictions and reads one * file at a time: `0000` carries Norway's rows and `2100` carries Svalbard's. */ export function matrikkelenInputPath( outRoot: BaseFetchOptions["outRoot"], areaCode: string = MATRIKKELEN_MAINLAND_AREA ): PathBuilderLike { return outRoot(SLUG, areaCode, MATRIKKELEN_MEMBER_FILENAME) } /** * Per-invocation options for the registry entry. */ export interface FetchMatrikkelenOptions extends BaseFetchOptions { /** * Geonorge area codes, defaulting to every area in {@linkcode MATRIKKELEN_AREAS}. */ areas?: readonly string[] force?: boolean retries?: number retryDelayMs?: number signal?: AbortSignal } /** * The registry entry. */ export async function fetchMatrikkelen( options: FetchMatrikkelenOptions, report?: (line: string) => void ): Promise { const areas = matrikkelenAreasFor(options.areas) await using client = new APIClient({ displayName: SLUG, retry: true }) // Awaited rather than returned: `await using` disposes the client when this scope exits, // and a disposed `APIClient` refuses every later request. return await downloadMatrikkelen(client, { outputDir: options.outRoot(SLUG), areas, force: options.force, retries: options.retries, retryDelayMs: options.retryDelayMs, signal: options.signal, report, }) }