- apps/api : API REST Node.js/Fastify + PostgreSQL/PostGIS - apps/geocoder : Worker Python géocodage Nominatim - apps/worker : Scraper Python Metal-Archives - apps/web : Frontend statique Leaflet/clustering/heatmap - infra/ : docker-compose + init.sql Déployé via Coolify + Traefik sur VPS OVH.
200 lines
7.1 KiB
Python
200 lines
7.1 KiB
Python
import os
|
|
import time
|
|
import random
|
|
import json
|
|
import requests
|
|
import psycopg2
|
|
|
|
NOMINATIM_BASE = os.environ.get("NOMINATIM_BASE", "https://nominatim.openstreetmap.org").rstrip("/")
|
|
NOMINATIM_EMAIL = os.environ.get("NOMINATIM_EMAIL", "").strip()
|
|
USER_AGENT = os.environ.get("NOMINATIM_USER_AGENT", "bm-geocoder/0.1 (contact: you@example.com)").strip()
|
|
|
|
MIN_DELAY = float(os.environ.get("NOMINATIM_MIN_DELAY", "1.05")) # >= 1s
|
|
JITTER = float(os.environ.get("NOMINATIM_JITTER", "0.35"))
|
|
MAX_PER_RUN = int(os.environ.get("GEOCODE_MAX_PER_RUN", "100000"))
|
|
|
|
_last = 0.0
|
|
|
|
def polite_sleep():
|
|
global _last
|
|
elapsed = time.monotonic() - _last
|
|
wait = max(0.0, MIN_DELAY - elapsed) + random.uniform(0.0, JITTER)
|
|
time.sleep(wait)
|
|
_last = time.monotonic()
|
|
|
|
def nominatim_search(query: str) -> dict | None:
|
|
# doc: /search + email conseillé pour gros volume :contentReference[oaicite:1]{index=1}
|
|
params = {
|
|
"q": query,
|
|
"format": "jsonv2",
|
|
"limit": 1,
|
|
"addressdetails": 1,
|
|
}
|
|
if NOMINATIM_EMAIL:
|
|
params["email"] = NOMINATIM_EMAIL
|
|
|
|
headers = {"User-Agent": USER_AGENT} # requis par policy :contentReference[oaicite:2]{index=2}
|
|
|
|
polite_sleep()
|
|
r = requests.get(f"{NOMINATIM_BASE}/search", params=params, headers=headers, timeout=30)
|
|
if r.status_code in (403, 429, 503):
|
|
raise RuntimeError(f"nominatim_throttle status={r.status_code} body={r.text[:200]}")
|
|
r.raise_for_status()
|
|
arr = r.json()
|
|
if not arr:
|
|
return None
|
|
return arr[0]
|
|
|
|
def main():
|
|
dsn = os.environ["DATABASE_URL"]
|
|
conn = psycopg2.connect(dsn)
|
|
conn.autocommit = True
|
|
|
|
processed = 0
|
|
with conn.cursor() as cur:
|
|
while processed < MAX_PER_RUN:
|
|
# prend 1 job à faire
|
|
cur.execute(
|
|
"""
|
|
SELECT ma_id, query, country, tries
|
|
FROM geocode_queue
|
|
WHERE status IN ('queued','error')
|
|
AND next_run_at <= now()
|
|
ORDER BY next_run_at ASC, ma_id ASC
|
|
LIMIT 1
|
|
FOR UPDATE SKIP LOCKED
|
|
"""
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
print("[worker] nothing to do. sleeping 60s")
|
|
time.sleep(60)
|
|
continue
|
|
|
|
ma_id, query, country, tries = row
|
|
|
|
# marque processing
|
|
cur.execute(
|
|
"""
|
|
UPDATE geocode_queue
|
|
SET status='processing', updated_at=now()
|
|
WHERE ma_id=%s
|
|
""",
|
|
(ma_id,),
|
|
)
|
|
|
|
# 1) cache ?
|
|
cur.execute("SELECT lat, lon, raw FROM geocode_cache WHERE query=%s", (query,))
|
|
cached = cur.fetchone()
|
|
if cached and cached[0] is not None and cached[1] is not None:
|
|
lat, lon, raw = cached
|
|
cur.execute(
|
|
"""
|
|
UPDATE bands
|
|
SET lat=%s, lon=%s,
|
|
geom=ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography,
|
|
geocoded_at=now(),
|
|
geocode_provider='nominatim-cache',
|
|
geocode_query=%s,
|
|
geocode_raw=%s,
|
|
geocode_error=NULL,
|
|
geocode_error_at=NULL
|
|
WHERE ma_id=%s
|
|
""",
|
|
(lat, lon, lon, lat, query, json.dumps(raw), ma_id),
|
|
)
|
|
cur.execute("UPDATE geocode_queue SET status='done', updated_at=now() WHERE ma_id=%s", (ma_id,))
|
|
processed += 1
|
|
print(f"[worker] ma_id={ma_id} cache HIT -> done")
|
|
continue
|
|
|
|
# 2) requête nominatim
|
|
try:
|
|
res = nominatim_search(query)
|
|
if not res:
|
|
cur.execute(
|
|
"""
|
|
UPDATE bands
|
|
SET geocode_error=%s, geocode_error_at=now()
|
|
WHERE ma_id=%s
|
|
""",
|
|
("no_result", ma_id),
|
|
)
|
|
cur.execute(
|
|
"""
|
|
UPDATE geocode_queue
|
|
SET status='error', tries=tries+1, last_error=%s,
|
|
next_run_at=now() + interval '7 days',
|
|
updated_at=now()
|
|
WHERE ma_id=%s
|
|
""",
|
|
("no_result", ma_id),
|
|
)
|
|
processed += 1
|
|
print(f"[worker] ma_id={ma_id} no_result -> postpone")
|
|
continue
|
|
|
|
lat = float(res["lat"])
|
|
lon = float(res["lon"])
|
|
|
|
# écrit cache
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO geocode_cache(query, provider, lat, lon, geom, raw, updated_at)
|
|
VALUES (%s,'nominatim',%s,%s,ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography,%s,now())
|
|
ON CONFLICT (query) DO UPDATE
|
|
SET lat=EXCLUDED.lat, lon=EXCLUDED.lon, geom=EXCLUDED.geom,
|
|
raw=EXCLUDED.raw, updated_at=now()
|
|
""",
|
|
(query, lat, lon, lon, lat, json.dumps(res)),
|
|
)
|
|
|
|
# écrit bands
|
|
cur.execute(
|
|
"""
|
|
UPDATE bands
|
|
SET lat=%s, lon=%s,
|
|
geom=ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography,
|
|
geocoded_at=now(),
|
|
geocode_provider='nominatim',
|
|
geocode_query=%s,
|
|
geocode_raw=%s,
|
|
geocode_error=NULL,
|
|
geocode_error_at=NULL
|
|
WHERE ma_id=%s
|
|
""",
|
|
(lat, lon, lon, lat, query, json.dumps(res), ma_id),
|
|
)
|
|
|
|
cur.execute("UPDATE geocode_queue SET status='done', updated_at=now() WHERE ma_id=%s", (ma_id,))
|
|
processed += 1
|
|
print(f"[worker] ma_id={ma_id} OK lat={lat} lon={lon}")
|
|
|
|
except Exception as e:
|
|
# backoff progressif
|
|
backoff_minutes = min(60, 5 * (tries + 1))
|
|
cur.execute(
|
|
"""
|
|
UPDATE bands
|
|
SET geocode_error=%s, geocode_error_at=now()
|
|
WHERE ma_id=%s
|
|
""",
|
|
(str(e)[:400], ma_id),
|
|
)
|
|
cur.execute(
|
|
"""
|
|
UPDATE geocode_queue
|
|
SET status='error', tries=tries+1, last_error=%s,
|
|
next_run_at=now() + (%s || ' minutes')::interval,
|
|
updated_at=now()
|
|
WHERE ma_id=%s
|
|
""",
|
|
(str(e)[:400], backoff_minutes, ma_id),
|
|
)
|
|
processed += 1
|
|
print(f"[worker] ma_id={ma_id} ERROR {e} -> retry in {backoff_minutes}m")
|
|
|
|
conn.close()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|