trails/infrastructure/brouter-host/poi-extract/to-ndjson.py
Ullrich Schäfer e703843b9b
poi-index: extract pipeline (BRouter host) + import job (flagship) + e2e
- poi-extract.sh: PBF download -> osmium tags-filter -> centroid NDJSON +
  manifest, published via the Caddy sidecar at vSwitch-only /poi/*
- osmium-filters.txt + poi-selectors.sql generated from map-core selectors,
  guarded by sync tests so poiCategories stays the single source of truth
- poi-import.sh: checksum verify -> classify into category rows ->
  70% guard (--bootstrap override) -> atomic rename swap; node_exporter
  textfile metric for import outcome
- systemd service+timer units for both halves (monthly, offset)
- cd-infra.yml: ship infrastructure/scripts to the flagship
- e2e: mock /api/pois instead of /api/overpass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:48:32 +02:00

93 lines
3 KiB
Python
Executable file

#!/usr/bin/env python3
"""Transform osmium's GeoJSONSeq output into the compact POI artifact.
Reads a GeoJSON Feature per line on stdin (as produced by
`osmium export -f geojsonseq --add-unique-id=type_id`) and writes one NDJSON
object per line on stdout:
{"osm_type": "n", "osm_id": "123", "name": "Brunnen",
"lat": 52.52, "lon": 13.40, "tags": {"amenity": "drinking_water", ...}}
Ways and relations are reduced to the centre of their bounding box — the same
representative point Overpass returned for `out center`, so markers land where
they used to. Category classification is intentionally NOT done here: the
flagship importer derives categories from the tags using the map-core
selectors (the single source of truth), so this host stays Node-free and the
selector logic is never duplicated.
"""
import json
import sys
def coords(geometry):
"""Yield every (lon, lat) pair in a GeoJSON geometry, any type."""
def walk(node):
if (
isinstance(node, list)
and len(node) == 2
and all(isinstance(n, (int, float)) for n in node)
):
yield node
elif isinstance(node, list):
for child in node:
yield from walk(child)
yield from walk(geometry.get("coordinates", []))
def bbox_center(geometry):
"""Bounding-box centre of a geometry, matching Overpass `out center`."""
pts = list(coords(geometry))
if not pts:
return None
lons = [p[0] for p in pts]
lats = [p[1] for p in pts]
return (min(lons) + max(lons)) / 2, (min(lats) + max(lats)) / 2
def main():
written = 0
for line in sys.stdin:
line = line.strip()
if not line:
continue
feature = json.loads(line)
geometry = feature.get("geometry") or {}
center = bbox_center(geometry)
if center is None:
continue
lon, lat = center
props = feature.get("properties") or {}
# osmium `--add-unique-id=type_id` emits ids like "n123", "w456",
# "r789" under the `id` / `@id` key depending on version.
raw_id = str(feature.get("id") or props.get("@id") or props.get("id") or "")
if not raw_id or raw_id[0] not in "nwr":
continue
osm_type, osm_id = raw_id[0], raw_id[1:]
# Tags are the feature properties minus osmium's synthetic id keys.
tags = {k: v for k, v in props.items() if k not in ("@id", "id")}
json.dump(
{
"osm_type": osm_type,
"osm_id": osm_id,
"name": tags.get("name"),
"lat": round(lat, 7),
"lon": round(lon, 7),
"tags": tags,
},
sys.stdout,
ensure_ascii=False,
separators=(",", ":"),
)
sys.stdout.write("\n")
written += 1
# Row count to stderr so the caller can record it in the manifest.
print(written, file=sys.stderr)
if __name__ == "__main__":
main()