#!/usr/bin/env node import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; const [inputPath, outputPath] = process.argv.slice(2); if (!inputPath || !outputPath) { console.error("usage: generate-reference-station-seed.mjs "); process.exit(2); } const source = JSON.parse(await readFile(resolve(inputPath), "utf8")); if (source?.type !== "FeatureCollection" || !Array.isArray(source.features)) { throw new Error("station_seed_feature_collection_required"); } const generatedAt = isIso(source.timestamp) ? new Date(source.timestamp).toISOString() : new Date(0).toISOString(); const facts = source.features.map((feature, index) => normalizeFeature(feature, generatedAt, index)); facts.sort((left, right) => left.sourceId.localeCompare(right.sourceId)); const contentDigest = createHash("sha256") .update(JSON.stringify(facts)) .digest("hex"); const counts = Object.fromEntries(["metro", "railway_station", "railway_terminal"].map((category) => [ category, facts.filter((fact) => fact.attributes.category === category).length, ])); const document = { schemaVersion: "nodedc.map-reference-seed/v1", profileId: "transport-stations.v1", sourceRevision: `seed-${contentDigest.slice(0, 16)}`, generatedAt, contentDigest, complete: true, facts, metadata: { factCount: facts.length, categoryCounts: counts, }, }; await mkdir(dirname(resolve(outputPath)), { recursive: true }); await writeFile(resolve(outputPath), `${JSON.stringify(document)}\n`, "utf8"); console.log(JSON.stringify({ output: resolve(outputPath), ...document.metadata, contentDigest })); function normalizeFeature(feature, generatedAt, index) { if (feature?.type !== "Feature" || feature?.geometry?.type !== "Point") { throw new Error(`station_seed_feature_${index}_invalid`); } const coordinates = feature.geometry.coordinates; if (!Array.isArray(coordinates) || coordinates.length < 2) { throw new Error(`station_seed_feature_${index}_coordinates_invalid`); } const longitude = Number(coordinates[0]); const latitude = Number(coordinates[1]); if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180 || !Number.isFinite(latitude) || latitude < -90 || latitude > 90) { throw new Error(`station_seed_feature_${index}_coordinates_invalid`); } const properties = feature.properties && typeof feature.properties === "object" ? feature.properties : {}; const category = normalizeCategory(properties.sort); const nativeId = String(feature.id || properties["@id"] || "").trim(); const identity = nativeId.match(/^(node|way|relation)\/([1-9]\d*)$/); if (!identity) throw new Error(`station_seed_feature_${index}_identity_invalid`); const attributes = compact({ name: firstString(properties.name, properties["name:ru"], properties.official_name, properties.loc_name), category, network: optionalString(properties.network), operator: optionalString(properties.operator), official_name: optionalString(properties.official_name), local_name: optionalString(properties.loc_name), uic_ref: optionalString(properties.uic_ref), wheelchair: optionalString(properties.wheelchair), }); if (!attributes.name) throw new Error(`station_seed_feature_${index}_name_required`); return { sourceId: `osm.${identity[1]}.${identity[2]}`, semanticType: category === "railway_terminal" ? "map.terminal" : "map.station", observedAt: generatedAt, receivedAt: generatedAt, attributes, geometry: { type: "Point", coordinates: [longitude, latitude] }, presentationStatus: "active", }; } function normalizeCategory(value) { if (value === "metro") return "metro"; if (value === "terminal") return "railway_terminal"; if (value === "rzd_station") return "railway_station"; throw new Error("station_seed_category_invalid"); } function firstString(...values) { return values.map(optionalString).find(Boolean); } function optionalString(value) { if (typeof value !== "string") return undefined; const normalized = value.trim(); return normalized && normalized.length <= 256 ? normalized : undefined; } function compact(value) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); } function isIso(value) { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }