#!/usr/bin/env python3 """Phase 2 — géocodage massif des boutiques via leur RTA (postal_prefix). Le registre porte un préfixe postal (RTA, ex. « J0V ») pour 86,6 % des boutiques mais aucune n'était géocodée. On géocode chaque RTA UNIQUE (≈ 360) via Nominatim — politesse 1 req/s, cache disque data/geocode_cache/fsa.json (seedé une fois, rejouable sans réseau) — puis on propage ville approx. + lat/lng à toutes les boutiques porteuses. Précision : centroïde de la RTA (quartier/secteur), pas l'adresse civique — suffisant pour une carte régionale et le tri par proximité. La ville n'est écrite que si Nominatim la fournit (aucune donnée inventée). Écrit : stores.city (si vide) / stores.lat / stores.lng en DB + champ "geo" {lat,lng,city,precision:"fsa"} dans data/stores.json. Usage : .venv/bin/python scripts/geocode_stores.py [--dry-run] [--max-requests N] """ import argparse import json import os import sys import time import requests ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) CACHE_DIR = os.path.join(ROOT, "data", "geocode_cache") os.makedirs(CACHE_DIR, exist_ok=True) CACHE = os.path.join(CACHE_DIR, "fsa.json") from fabrika import db as fdb # noqa: E402 # Nominatim ne résout PAS les RTA seules (postalcode=H2X -> []), constaté le # 2026-08-19. zippopotam.us les résout nativement pour le Canada (centroïde + # nom du secteur). Même politesse : 1 req/s, cache disque. ZIPPO = "https://api.zippopotam.us/CA/{fsa}" HDRS = {"User-Agent": "FabriKaBot/1.0 (+https://www.fabri-ka.com/bot; contact@spboucher.ai)"} def geocode_fsa(fsa: str) -> dict | None: """RTA -> {lat, lng, city} via zippopotam.us (données GeoNames).""" r = requests.get(ZIPPO.format(fsa=fsa), headers=HDRS, timeout=20) if r.status_code == 404: return None r.raise_for_status() data = r.json() places = data.get("places") or [] if not places: return None p = places[0] if (p.get("state abbreviation") or "") not in ("QC", ""): return None # garde-fou : rester au Québec return {"lat": round(float(p["latitude"]), 5), "lng": round(float(p["longitude"]), 5), "city": (p.get("place name") or "").strip()} def main(): ap = argparse.ArgumentParser() ap.add_argument("--dry-run", action="store_true") ap.add_argument("--max-requests", type=int, default=400) args = ap.parse_args() reg_path = os.path.join(ROOT, "data", "stores.json") reg = json.load(open(reg_path)) fsas = sorted({(s.get("postal_prefix") or "").strip().upper()[:3] for s in reg["stores"] if s.get("postal_prefix")}) cache = json.load(open(CACHE)) if os.path.exists(CACHE) else {} todo = [f for f in fsas if f not in cache][:args.max_requests] print(f"[geocode] {len(fsas)} RTA uniques, {len(todo)} à résoudre " f"(cache: {len(cache)})", flush=True) n_req = 0 for fsa in todo: try: cache[fsa] = geocode_fsa(fsa) except Exception as exc: print(f" ! {fsa}: {exc}", flush=True) n_req += 1 if n_req % 25 == 0: print(f" {n_req}/{len(todo)}", flush=True) json.dump(cache, open(CACHE, "w")) time.sleep(1.1) # politesse Nominatim : 1 req/s max json.dump(cache, open(CACHE, "w")) resolved = {k: v for k, v in cache.items() if v} print(f"[geocode] RTA résolues : {len(resolved)}/{len(fsas)} " f"({n_req} requêtes réseau)") if args.dry_run: return n_store = 0 con = fdb.connect() for s in reg["stores"]: fsa = (s.get("postal_prefix") or "").strip().upper()[:3] geo = resolved.get(fsa) if not geo: continue s["geo"] = {"lat": geo["lat"], "lng": geo["lng"], "city": geo["city"], "precision": "fsa"} con.execute("UPDATE stores SET lat=?, lng=?, " "city=CASE WHEN COALESCE(city,'')='' THEN ? ELSE city END " "WHERE id=?", (geo["lat"], geo["lng"], geo["city"], s["id"])) n_store += 1 con.commit(); con.close() json.dump(reg, open(reg_path, "w"), ensure_ascii=False, indent=1) print(f"[geocode] boutiques géocodées : {n_store}") if __name__ == "__main__": main()