SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
10.3 KB · 253 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# poi.py : commodités de proximité par immeuble via Overpass (OpenStreetMap)5#   Pour chaque immeuble géolocalisé, une requête Overpass unique récupère6#   les points d'intérêt utiles à un locataire (épicerie, pharmacie, école,7#   garderie, parc, arrêt de bus, gym, clinique…) ; on retient le PLUS PROCHE8#   de chaque catégorie avec sa distance. Cache permanent par coordonnées9#   (table poi_cache, clé arrondie à 4 décimales ≈ 11 m : les unités d'un10#   même immeuble partagent la même entrée). Politesse : 1 requête/seconde.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import json15import math16import time1718import requests1920from . import db2122# Miroirs Overpass (rotation en cas d'erreur/limitation) — kumi.systems23# tolère mieux les gros volumes que l'instance officielle24OVERPASS_URLS = [25    "https://overpass.kumi.systems/api/interpreter",26    "https://overpass-api.de/api/interpreter",27    # replis vérifiés couvrant le Québec (panne simultanée kumi+officiel28    # constatée le 2026-08-25) ; overpass.osm.ch exclu (extrait suisse)29    "https://maps.mail.ru/osm/tools/overpass/api/interpreter",30    "https://overpass.openstreetmap.fr/api/interpreter",31]32USER_AGENT = "RentKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"33REQUEST_DELAY = 2.034REFRESH_AFTER = 90 * 86400      # les POI bougent peu : rafraîchir aux ~3 mois3536# Stratégie « par tuiles » : plutôt qu'une requête par immeuble (l'union37# d'around() est très coûteuse côté Overpass), on télécharge TOUS les POI38# des catégories par tuile de 0,5° couvrant nos immeubles (~10 tuiles pour39# Québec/Lévis + Grand Montréal), puis on calcule les plus proches en local.40TILE = 0.541TILE_MARGIN = 0.04              # ~4 km > plus grand rayon de catégorie (3 km)4243# Catégories : (clé, libellé FR, sélecteur Overpass, rayon m)44CATEGORIES: list[tuple[str, str, str, int]] = [45    ("epicerie",   "Épicerie",        '["shop"="supermarket"]',                    1500),46    ("depanneur",  "Dépanneur",       '["shop"="convenience"]',                     800),47    ("pharmacie",  "Pharmacie",       '["amenity"="pharmacy"]',                    1500),48    ("ecole",      "École",           '["amenity"="school"]',                      1500),49    ("garderie",   "Garderie",        '["amenity"~"^(kindergarten|childcare)$"]',  1500),50    ("parc",       "Parc",            '["leisure"="park"]',                        1200),51    ("bus",        "Arrêt de bus",    '["highway"="bus_stop"]',                     600),52    ("metro",      "Métro",           '["railway"="station"]["station"="subway"]', 1500),53    ("gym",        "Gym",             '["leisure"="fitness_centre"]',              1500),54    ("cafe",       "Café",            '["amenity"="cafe"]',                        1000),55    ("clinique",   "Clinique / CLSC", '["amenity"~"^(clinic|doctors)$"]',          2000),56    ("hopital",    "Hôpital",         '["amenity"="hospital"]',                    3000),57    ("bibliotheque", "Bibliothèque",  '["amenity"="library"]',                     2000),58]5960LABELS = {cat: label for cat, label, _, _ in CATEGORIES}616263def coord_key(lat: float, lng: float) -> str:64    return f"{round(lat, 4)},{round(lng, 4)}"656667def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:68    r = 6371000.069    p1, p2 = math.radians(lat1), math.radians(lat2)70    dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)71    a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 272    return 2 * r * math.asin(math.sqrt(a))737475def _tile_of(lat: float, lng: float) -> tuple[int, int]:76    return (math.floor(lat / TILE), math.floor(lng / TILE))777879def _build_tile_query(ty: int, tx: int) -> str:80    """Tous les POI des catégories dans la tuile (bbox élargie de la marge)."""81    s = ty * TILE - TILE_MARGIN82    n = (ty + 1) * TILE + TILE_MARGIN83    w = tx * TILE - TILE_MARGIN84    e = (tx + 1) * TILE + TILE_MARGIN85    bbox = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}"86    parts = [f"nwr{sel}({bbox});" for _c, _l, sel, _r in CATEGORIES]87    return f'[out:json][timeout:180];({"".join(parts)});out center tags;'888990def _match_category(tags: dict) -> str | None:91    """Retrouve la catégorie Rent-Ka d'un élément OSM retourné."""92    shop = tags.get("shop")93    amenity = tags.get("amenity")94    leisure = tags.get("leisure")95    if shop == "supermarket":96        return "epicerie"97    if shop == "convenience":98        return "depanneur"99    if amenity == "pharmacy":100        return "pharmacie"101    if amenity == "school":102        return "ecole"103    if amenity in ("kindergarten", "childcare"):104        return "garderie"105    if leisure == "park":106        return "parc"107    if tags.get("highway") == "bus_stop":108        return "bus"109    if tags.get("railway") == "station" and tags.get("station") == "subway":110        return "metro"111    if leisure == "fitness_centre":112        return "gym"113    if amenity == "cafe":114        return "cafe"115    if amenity in ("clinic", "doctors"):116        return "clinique"117    if amenity == "hospital":118        return "hopital"119    if amenity == "library":120        return "bibliotheque"121    return None122123124_RADII = {cat: radius for cat, _l, _s, radius in CATEGORIES}125126127class PoiClient:128    def __init__(self) -> None:129        self.session = requests.Session()130        self.session.headers["User-Agent"] = USER_AGENT131        self._last = 0.0132        self._url_idx = 0133134    def _post(self, query: str) -> list | None:135        """POST Overpass avec throttling et rotation de miroir sur erreur."""136        wait = REQUEST_DELAY - (time.time() - self._last)137        if wait > 0:138            time.sleep(wait)139        for essai in range(len(OVERPASS_URLS)):140            url = OVERPASS_URLS[(self._url_idx + essai) % len(OVERPASS_URLS)]141            try:142                resp = self.session.post(url, data={"data": query}, timeout=90)143                self._last = time.time()144                resp.raise_for_status()145                self._url_idx = (self._url_idx + essai) % len(OVERPASS_URLS)146                return resp.json().get("elements") or []147            except Exception:148                self._last = time.time()149                continue150        return None151152    def fetch_tile(self, ty: int, tx: int) -> list[dict] | None:153        """Tous les POI catégorisés d'une tuile : [{cat, name, lat, lng}]."""154        elements = self._post(_build_tile_query(ty, tx))155        if elements is None:156            return None157        pois = []158        for el in elements:159            tags = el.get("tags") or {}160            cat = _match_category(tags)161            if cat is None:162                continue163            elat = el.get("lat") or (el.get("center") or {}).get("lat")164            elng = el.get("lon") or (el.get("center") or {}).get("lon")165            if elat is None or elng is None:166                continue167            pois.append({"cat": cat, "name": (tags.get("name") or LABELS[cat])[:60],168                         "lat": elat, "lng": elng})169        return pois170171172def _nearest_by_cat(lat: float, lng: float, pois_by_cat: dict[str, list[dict]]) -> list[dict]:173    """Plus proche POI de chaque catégorie (dans son rayon), trié par distance."""174    out = []175    for cat, pois in pois_by_cat.items():176        radius = _RADII[cat]177        # préfiltre rectangulaire bon marché avant l'haversine178        dlat_max = radius / 111000.0179        dlng_max = radius / (111000.0 * max(0.2, math.cos(math.radians(lat))))180        best = None181        for p in pois:182            if abs(p["lat"] - lat) > dlat_max or abs(p["lng"] - lng) > dlng_max:183                continue184            d = _haversine_m(lat, lng, p["lat"], p["lng"])185            if d <= radius and (best is None or d < best["dist_m"]):186                best = {"cat": cat, "name": p["name"], "dist_m": round(d)}187        if best:188            out.append(best)189    return sorted(out, key=lambda p: p["dist_m"])190191192def run(limit: int | None = None) -> dict:193    """Remplit poi_cache pour les immeubles géolocalisés qui n'y sont pas.194195    `limit` borne le nombre de requêtes Overpass de cette exécution196    (les entrées déjà en cache ne coûtent rien).197    """198    con = db.connect()199    client = PoiClient()200    rows = con.execute(201        """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings202           WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall()203204    now = time.time()205    a_faire: list[tuple[float, float]] = []206    skipped = 0207    for r in rows:208        cached = con.execute(209            "SELECT ts FROM poi_cache WHERE coord_key=?",210            (f"{r['la']},{r['ln']}",)).fetchone()211        if cached and now - (cached["ts"] or 0) < REFRESH_AFTER:212            skipped += 1213        else:214            a_faire.append((r["la"], r["ln"]))215    if limit is not None:216        a_faire = a_faire[:limit]217218    # 1) télécharger les POI des tuiles nécessaires (une requête par tuile)219    tuiles = sorted({_tile_of(la, ln) for la, ln in a_faire})220    pois_by_tile: dict[tuple[int, int], dict[str, list[dict]]] = {}221    tile_errors = []222    for t in tuiles:223        res = client.fetch_tile(*t)224        if res is None:225            tile_errors.append(t)226        else:227            by_cat: dict[str, list[dict]] = {}228            for p in res:229                by_cat.setdefault(p["cat"], []).append(p)230            pois_by_tile[t] = by_cat231232    # 2) calcul local du plus proche par catégorie pour chaque immeuble233    done = errors = 0234    for la, ln in a_faire:235        t = _tile_of(la, ln)236        if t not in pois_by_tile:237            errors += 1             # tuile en échec : re-tentée au prochain run238            continue239        pois = _nearest_by_cat(la, ln, pois_by_tile[t])240        con.execute(241            "INSERT INTO poi_cache (coord_key, lat, lng, pois, ts) VALUES (?,?,?,?,?)"242            " ON CONFLICT(coord_key) DO UPDATE SET pois=excluded.pois, ts=excluded.ts",243            (coord_key(la, ln), la, ln, json.dumps(pois, ensure_ascii=False), now))244        done += 1245    con.commit()246247    con.close()248    stats = {"fetched": done, "cached": skipped, "errors": errors,249             "tiles": len(tuiles), "tile_errors": len(tile_errors),250             "total_coords": len(rows)}251    print(f"[rent-ka] poi {stats}")252    return stats253