POI-Karte: Offline-Import aus OSM statt Live-Overpass-Scan (Build 4)

- osm.py: Live-Scanner deaktiviert — /pois liest nur noch aus DB,
  /analyze ist No-Op. Behebt wiederholte OSM-Banns (Tile-Load + Scanning).
- tools/osm-extract: Extraktion (pyosmium) + Loader (schützt user_edited)
  + Docker-Refresh-Job mit osmium-tags-filter-Vorstufe (RAM-schonend).
- docker-compose.osm.yml: Refresh-Service (mem_limit 4g), monatlich via
  DSM-Aufgabenplaner.
This commit is contained in:
rene 2026-06-03 20:44:32 +02:00
parent 214543559c
commit 4bc7454258
9 changed files with 457 additions and 26 deletions

5
tools/osm-extract/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Große/temporäre Datendateien — nie committen
*.osm.pbf
*.sqlite
*.db
*.log

View file

@ -0,0 +1,14 @@
FROM python:3.12-slim-bookworm
# osmium-tool = RAM-schonende tags-filter-Vorstufe (C++, streaming),
# pyosmium = Extraktion + Schwerpunktberechnung.
RUN apt-get update && apt-get install -y --no-install-recommends \
osmium-tool curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir osmium
WORKDIR /app
COPY extract_osm_pois.py load_into_prod.py refresh.sh /app/
RUN chmod +x /app/refresh.sh
ENTRYPOINT ["/app/refresh.sh"]

View file

@ -0,0 +1,74 @@
# Build 4 — POI-Offline-Umstellung auf der DiskStation (DSM-Upload)
Ziel: Live-Overpass-Scanner abschalten + die 1,45 Mio DACH-POIs in die
Produktiv-DB migrieren. Ohne 5,7-GB-Download (die fertige `dach.sqlite` wird
mit hochgeladen).
App-Verzeichnis auf der DS: **`/volume1/docker/banyaro/`** (im File Station:
`docker``banyaro`).
---
## Schritt 1 — Code hochladen (File Station)
1. `build4-osm-code.zip` in den Ordner **`docker/banyaro`** hochladen.
2. Rechtsklick → **Entpacken → Hierher entpacken**. Das überschreibt
`backend/routes/osm.py` (Scanner aus) und legt `tools/osm-extract/` +
`docker-compose.osm.yml` an. Andere Dateien bleiben unberührt.
## Schritt 2 — Vorbereiteten POI-Extrakt hochladen
3. `dach.sqlite` (181 MB) in den Ordner **`docker/banyaro/data`** hochladen.
(Liegt dann im Container als `/data/dach.sqlite`.)
## Schritt 3 — Migration + Deploy (SSH-Terminal: `ssh ds`)
```sh
cd /volume1/docker/banyaro
# Refresh-Image bauen (einmalig)
docker compose -f docker-compose.osm.yml build
# MIGRATION: lädt dach.sqlite in die Produktiv-DB (Backup wird vorher angelegt,
# user_edited-POIs + Community-Marker bleiben geschützt)
docker compose -f docker-compose.osm.yml run --rm \
-e PREBUILT_SQLITE=/data/dach.sqlite osm-refresh
# DEPLOY: baut die App neu → scanner-lose osm.py geht live
docker compose up -d --build
```
> Reihenfolge bewusst: erst Daten laden, dann App neu bauen → kein Fenster mit
> leerer Karte.
## Schritt 4 — Prüfen
```sh
docker compose -f docker-compose.osm.yml run --rm --entrypoint python3 \
osm-refresh -c "import sqlite3; c=sqlite3.connect('/data/banyaro.db'); \
print('osm_pois:', c.execute('select count(*) from osm_pois').fetchone()[0]); \
print(c.execute('select type,count(*) from osm_pois group by type order by 2 desc').fetchall())"
```
Erwartung: ~1.452.675 POIs (bank ~1,0 Mio, restaurant ~60k …). In der App die
Karte öffnen → Marker laden ohne Overpass.
## Aufräumen (optional)
`dach.sqlite` aus `docker/banyaro/data` kann nach erfolgreicher Migration weg.
## Monatlicher Auto-Refresh (DSM-Aufgabenplaner)
Systemsteuerung → Aufgabenplaner → Erstellen → Geplante Aufgabe →
Benutzerdefiniertes Skript. Benutzer `root`, monatlich z. B. 1. um 04:00:
```sh
cd /volume1/docker/banyaro && \
docker compose -f docker-compose.osm.yml run --rm osm-refresh \
>> /volume1/docker/banyaro/data/osm-refresh.log 2>&1
```
(ohne `PREBUILT_SQLITE` → holt frisch von Geofabrik, ~12 GB RAM dank tags-filter)
## Rollback
Vor jedem Lauf entsteht `data/banyaro.pre-osm-JJJJMMTT.db`. Im Notfall:
App stoppen, diese Datei auf `banyaro.db` zurückkopieren, App starten.

View file

@ -0,0 +1,55 @@
# OSM-POI Offline-Refresh (Build 4)
Ersetzt das Live-Scannen gegen `overpass-api.de` (war wiederholt OSM-Bann-Quelle)
durch einen **monatlichen Offline-Batch**: POIs werden aus den Geofabrik-OSM-Daten
extrahiert und in die Produktiv-DB geladen. Danach keine OSM-Live-Last mehr.
## Bestandteile
| Datei | Zweck |
|---|---|
| `extract_osm_pois.py` | pbf → `osm_pois`-Schema (pyosmium). Kanonisches Kategorie→Tag-Mapping. |
| `load_into_prod.py` | Extrakt → Produktiv-DB. Schützt `user_edited=1`, ersetzt den Rest. |
| `refresh.sh` | Orchestrierung im Container: download → tags-filter → extract → load. |
| `Dockerfile` | Image mit osmium-tool + pyosmium. |
| `../../docker-compose.osm.yml` | Eigener Service, **nicht** im Default-Stack. |
## Was es tut
- Lädt CH/AT/DE von Geofabrik (~5,7 GB), dampft sie mit `osmium tags-filter`
(streaming, <500 MB RAM) auf die relevanten Objekte ein, extrahiert die 9
Ban-Yaro-Kategorien und lädt sie in `/data/banyaro.db`.
- **Sicherheitskopie** der DB vor jedem Lauf (letzte 3 bleiben).
- `user_edited=1`-POIs und `user_map_pois` (Community-Marker) bleiben unberührt.
- Peak-RAM ~12 GB, hartes `mem_limit: 4g` als Schutzschranke.
- Aktuelle Größenordnung: DACH ~1,45 Mio POIs, ~180 MB.
## Manuell ausführen (Test)
```sh
cd /pfad/zu/banyaro # dort, wo docker-compose.yml liegt
docker compose -f docker-compose.osm.yml build
docker compose -f docker-compose.osm.yml run --rm osm-refresh
```
## Monatlich per DSM-Aufgabenplaner
1. **Systemsteuerung → Aufgabenplaner → Erstellen → Geplante Aufgabe → Benutzerdefiniertes Skript**
2. Benutzer: `root` (für Docker-Zugriff)
3. Zeitplan: **monatlich**, z. B. am 1. um 04:00 (lastarm)
4. Aufgabeneinstellungen → Benutzerdefiniertes Skript:
```sh
cd /volume1/docker/banyaro && \
/usr/local/bin/docker compose -f docker-compose.osm.yml run --rm osm-refresh \
>> /volume1/docker/banyaro/data/osm-refresh.log 2>&1
```
> Pfad `/volume1/docker/banyaro` an euer Compose-Verzeichnis anpassen.
> Docker-Binary auf DSM ist meist `/usr/local/bin/docker` (`docker compose`),
> bei älteren DSM ggf. `docker-compose`.
## Rollback
Vor jedem Lauf wird `banyaro.pre-osm-JJJJMMTT.db` neben der DB abgelegt. Im
Notfall App stoppen, diese Datei auf `banyaro.db` zurückkopieren, App starten.

View file

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
Offline-Extraktion der Ban-Yaro-POIs aus einer OSM-.pbf-Datei.
Ersetzt das Live-Scannen gegen overpass-api.de (backend/routes/osm.py) durch
einen einmaligen/periodischen Batch-Lauf keine OSM-Live-Last, kein Bann mehr.
Das KategorieTag-Mapping ist 1:1 aus OSM_QUERIES in backend/routes/osm.py
übernommen. Schreibt ins selbe Schema wie die Produktiv-Tabelle `osm_pois`
(osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at).
Aufruf:
python3 extract_osm_pois.py <input.osm.pbf> <output.sqlite>
"""
import sys
import sqlite3
import osmium
# --- Kategorie-Klassifikation (1:1 aus OSM_QUERIES, backend/routes/osm.py) ---
def classify(t) -> list[str]:
"""Gibt alle Ban-Yaro-Typen zurück, die auf dieses OSM-Objekt passen."""
types: list[str] = []
a = t.get("amenity")
shop = t.get("shop")
craft = t.get("craft")
leisure = t.get("leisure")
tourism = t.get("tourism")
dog = t.get("dog")
outdoor = t.get("outdoor_seating")
# "hundefreundlich, breiter gefasst": explizit erlaubt ODER Terrasse
dog_ok = dog in ("yes", "allowed", "leashed")
if a == "waste_basket":
types.append("waste_basket")
if a == "drinking_water":
types.append("drinking_water")
if a == "veterinary":
types.append("tierarzt")
if a == "bench":
types.append("bank")
if leisure == "dog_park" or (leisure == "park" and dog == "yes"):
types.append("dog_park")
if shop == "pet":
types.append("shop")
if shop == "pet_grooming" or craft == "pet_grooming":
types.append("hundesalon")
if (a in ("restaurant", "cafe") and (dog_ok or outdoor == "yes")) or a == "biergarten":
types.append("restaurant")
if tourism in ("hotel", "guest_house", "hostel") and dog_ok:
types.append("hotel")
return types
SCHEMA = """
CREATE TABLE IF NOT EXISTS osm_pois (
osm_id INTEGER NOT NULL,
type TEXT NOT NULL,
lat REAL NOT NULL,
lon REAL NOT NULL,
name TEXT,
opening_hours TEXT,
phone TEXT,
website TEXT,
user_edited INTEGER NOT NULL DEFAULT 0,
cached_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (osm_id, type)
);
CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon);
"""
class PoiHandler(osmium.SimpleHandler):
def __init__(self, conn):
super().__init__()
self.conn = conn
self.rows = 0
self.objs = 0
def _save(self, osm_id, tags, lat, lon):
types = classify(tags)
if not types:
return
self.objs += 1
name = tags.get("name")
oh = tags.get("opening_hours")
phone = tags.get("phone") or tags.get("contact:phone")
web = tags.get("website") or tags.get("contact:website")
for ty in types:
self.conn.execute(
"INSERT OR REPLACE INTO osm_pois "
"(osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at) "
"VALUES (?,?,?,?,?,?,?,?, datetime('now'))",
(osm_id, ty, lat, lon, name, oh, phone, web),
)
self.rows += 1
def node(self, n):
if n.tags:
self._save(n.id, n.tags, n.location.lat, n.location.lon)
def way(self, w):
# Wege (z. B. Tierarzt im Gebäude) → Schwerpunkt aus Knoten ("out center")
if not w.tags:
return
lats, lons = [], []
for nd in w.nodes:
if nd.location.valid():
lats.append(nd.location.lat)
lons.append(nd.location.lon)
if lats:
self._save(w.id, w.tags, sum(lats) / len(lats), sum(lons) / len(lons))
def main():
if len(sys.argv) != 3:
print(__doc__)
sys.exit(1)
src, dst = sys.argv[1], sys.argv[2]
conn = sqlite3.connect(dst)
conn.executescript(SCHEMA)
h = PoiHandler(conn)
# locations=True: Knoten-Koordinaten im Speicher halten, damit Wege einen
# Schwerpunkt bekommen. flex_mem skaliert bis Länder-Extrakte.
h.apply_file(src, locations=True, idx="flex_mem")
conn.commit()
print(f"\nObjekte mit Treffer: {h.objs:,} eingefügte Zeilen: {h.rows:,}")
print("\nPro Typ:")
for ty, cnt in conn.execute(
"SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC"
):
print(f" {ty:16s} {cnt:>8,}")
conn.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""
Lädt die offline extrahierten POIs (dach.sqlite) in die Produktiv-DB.
Semantik des Monats-Refresh:
* Alle nicht-editierten OSM-POIs (user_edited=0) werden ersetzt POIs, die
aus OSM verschwunden sind, fallen sauber raus.
* Von Nutzern korrigierte POIs (user_edited=1, via Moderation) bleiben
UNANGETASTET INSERT OR IGNORE überspringt sie bei Kollision.
* Community-Marker (Tabelle user_map_pois) sind separat und werden nie
berührt.
Läuft in EINER Transaktion. Bei Fehler bleibt die alte DB unverändert.
Aufruf:
python3 load_into_prod.py <extract.sqlite> <ziel/banyaro.db>
"""
import sys
import sqlite3
COLS = "osm_id, type, lat, lon, name, opening_hours, phone, website, user_edited, cached_at"
def main():
if len(sys.argv) != 3:
print(__doc__)
sys.exit(1)
extract_path, prod_path = sys.argv[1], sys.argv[2]
# timeout/busy_timeout: die App schreibt evtl. parallel — auf Lock warten,
# statt sofort zu scheitern. Der Load läuft in EINER Transaktion.
conn = sqlite3.connect(prod_path, timeout=120)
conn.execute("PRAGMA busy_timeout=120000")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute(f"ATTACH DATABASE ? AS ext", (extract_path,))
before = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0]
edited = conn.execute("SELECT COUNT(*) FROM osm_pois WHERE user_edited=1").fetchone()[0]
incoming = conn.execute("SELECT COUNT(*) FROM ext.osm_pois").fetchone()[0]
try:
conn.execute("BEGIN")
# 1) nicht-editierte OSM-POIs verwerfen (editierte bleiben stehen)
conn.execute("DELETE FROM osm_pois WHERE user_edited=0")
# 2) frische Extraktion einspielen; editierte Survivor nicht überschreiben
conn.execute(
f"INSERT OR IGNORE INTO osm_pois ({COLS}) "
f"SELECT {COLS} FROM ext.osm_pois"
)
conn.execute("COMMIT")
except Exception:
conn.execute("ROLLBACK")
raise
after = conn.execute("SELECT COUNT(*) FROM osm_pois").fetchone()[0]
print(f"Vorher: {before:>10,}")
print(f"davon user_edited:{edited:>10,} (geschützt)")
print(f"Eingespielt: {incoming:>10,}")
print(f"Nachher: {after:>10,}")
print("\nPro Typ (nachher):")
for ty, cnt in conn.execute(
"SELECT type, COUNT(*) FROM osm_pois GROUP BY type ORDER BY 2 DESC"
):
print(f" {ty:16s} {cnt:>9,}")
conn.execute("DETACH DATABASE ext")
conn.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Monatlicher POI-Refresh (Build 4) — läuft im Docker-Container auf der Synology.
# download → tags-filter (RAM-schonend) → extract → load in die Produktiv-DB.
# Ersetzt das Live-Overpass-Scannen (war Bann-Quelle).
#
# Erst-Migration ohne 5,7-GB-Download: vorab gebaute dach.sqlite mitliefern und
# PREBUILT_SQLITE=/data/dach.sqlite setzen → überspringt download/extract.
#
set -euo pipefail
DB="${DB_PATH:-/data/banyaro.db}"
WORK="${WORK_DIR:-/work}"
COUNTRIES="${COUNTRIES:-switzerland austria germany}"
GEOFABRIK="${GEOFABRIK_BASE:-https://download.geofabrik.de/europe}"
KEEP_BACKUPS="${KEEP_BACKUPS:-3}"
PREBUILT_SQLITE="${PREBUILT_SQLITE:-}"
# OSM-Tags für die 9 Ban-Yaro-Kategorien (Superset; finale Klassifikation macht
# extract_osm_pois.py). nw/ = node+way, referenzierte Knoten bleiben für die
# Weg-Geometrie automatisch erhalten.
FILTER=(
"nw/amenity=waste_basket,drinking_water,veterinary,bench,biergarten,restaurant,cafe"
"nw/leisure=dog_park,park"
"nw/shop=pet,pet_grooming"
"nw/craft=pet_grooming"
"nw/tourism=hotel,guest_house,hostel"
)
mkdir -p "$WORK"; cd "$WORK"
echo "[$(date -u)] POI-Refresh start → $DB"
# 1) Sicherheitskopie der Produktiv-DB (nur die letzten N behalten)
if [ -f "$DB" ]; then
bak="${DB%.db}.pre-osm-$(date -u +%Y%m%d).db"
cp -p "$DB" "$bak"
echo "Backup: $bak"
ls -1t "${DB%.db}".pre-osm-*.db 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f
fi
# 2a) Schnellweg: vorab gebauten Extrakt direkt laden (kein Download/Extract)
if [ -n "$PREBUILT_SQLITE" ]; then
[ -f "$PREBUILT_SQLITE" ] || { echo "FEHLER: $PREBUILT_SQLITE nicht gefunden"; exit 1; }
echo "[$(date -u)] PREBUILT_SQLITE=$PREBUILT_SQLITE → überspringe download/extract"
python3 /app/load_into_prod.py "$PREBUILT_SQLITE" "$DB"
echo "[$(date -u)] POI-Refresh (prebuilt) fertig."
exit 0
fi
# 2b) Regulärer Monatslauf: frisch holen + extrahieren
rm -f dach.sqlite
for c in $COUNTRIES; do
echo "[$(date -u)] $c: download"
curl -fSL --retry 3 -o "$c.osm.pbf" "$GEOFABRIK/$c-latest.osm.pbf"
echo "[$(date -u)] $c: tags-filter"
osmium tags-filter --overwrite -o "$c.f.osm.pbf" "$c.osm.pbf" "${FILTER[@]}"
rm -f "$c.osm.pbf"
echo "[$(date -u)] $c: extract"
python3 /app/extract_osm_pois.py "$c.f.osm.pbf" dach.sqlite
rm -f "$c.f.osm.pbf"
done
echo "[$(date -u)] load → Produktiv-DB"
python3 /app/load_into_prod.py dach.sqlite "$DB"
rm -f dach.sqlite
echo "[$(date -u)] POI-Refresh fertig."