Commodités de proximité par immeuble (Overpass/OSM) sur chaque fiche
- louka/poi.py : une requête Overpass par immeuble (13 catégories : épicerie,
dépanneur, pharmacie, école, garderie, parc, bus, métro, gym, café,
clinique, hôpital, bibliothèque), plus proche par catégorie avec distance,
cache permanent par coordonnées arrondies (~11 m), politesse 1 req/s,
rafraîchissement aux 3 mois
- run.py poi [n] + intégration à la boucle watch (80 nouveaux immeubles/cycle)
- /api/listings/{uid} : champ poi[] joint depuis le cache
- fiche : section « À proximité » (icône, nom réel, distance formatée),
mention données OpenStreetMap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 8 changed files with +277 and −3
modified
frontend/src/api.ts
+11 −0
@@ -21,6 +21,12 @@ export interface ListingDetails { | ||
| 21 | 21 | price_from?: boolean; |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | +export interface Poi { | |
| 25 | + cat: string; // epicerie, pharmacie, ecole, parc, bus… | |
| 26 | + name: string; | |
| 27 | + dist_m: number; | |
| 28 | +} | |
| 29 | + | |
| 24 | 30 | export interface Listing { |
| 25 | 31 | uid: string; |
| 26 | 32 | source: string; |
@@ -44,11 +50,16 @@ export interface Listing { | ||
| 44 | 50 | images: string[]; |
| 45 | 51 | lat: number | null; |
| 46 | 52 | lng: number | null; |
| 53 | + poi?: Poi[]; // commodités de proximité (fiche seulement) | |
| 47 | 54 | last_seen: number; |
| 48 | 55 | updated_at: number; |
| 49 | 56 | active: number; |
| 50 | 57 | } |
| 51 | 58 | |
| 59 | +/** 250 -> « 250 m », 1240 -> « 1,2 km » */ | |
| 60 | +export const fmtDist = (m: number): string => | |
| 61 | + m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`; | |
| 62 | + | |
| 52 | 63 | export interface Facets { |
| 53 | 64 | cities: string[]; |
| 54 | 65 | sectors: string[]; |
modified
frontend/src/pages/Listing.tsx
+41 −1
@@ -5,7 +5,24 @@ | ||
| 5 | 5 | // ----------------------------------------------------------------------------- |
| 6 | 6 | import { useEffect, useState } from "react"; |
| 7 | 7 | import { Link, useParams } from "react-router-dom"; |
| 8 | −import { Listing, fetchListing, fetchSources, fmtAvailability, fmtPrice, registerSourceNames, sourceName } from "../api"; | |
| 8 | +import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api"; | |
| 9 | + | |
| 10 | +// Icônes et libellés des commodités de proximité (louka/poi.py) | |
| 11 | +const POI_META: Record<string, { icon: string; label: string }> = { | |
| 12 | + epicerie: { icon: "🛒", label: "Épicerie" }, | |
| 13 | + depanneur: { icon: "🏪", label: "Dépanneur" }, | |
| 14 | + pharmacie: { icon: "💊", label: "Pharmacie" }, | |
| 15 | + ecole: { icon: "🏫", label: "École" }, | |
| 16 | + garderie: { icon: "🧸", label: "Garderie" }, | |
| 17 | + parc: { icon: "🌳", label: "Parc" }, | |
| 18 | + bus: { icon: "🚌", label: "Bus" }, | |
| 19 | + metro: { icon: "🚇", label: "Métro" }, | |
| 20 | + gym: { icon: "🏋️", label: "Gym" }, | |
| 21 | + cafe: { icon: "☕", label: "Café" }, | |
| 22 | + clinique: { icon: "🩺", label: "Clinique" }, | |
| 23 | + hopital: { icon: "🏥", label: "Hôpital" }, | |
| 24 | + bibliotheque: { icon: "📚", label: "Bibliothèque" }, | |
| 25 | +}; | |
| 9 | 26 | |
| 10 | 27 | const PETS_LABEL: Record<string, string> = { |
| 11 | 28 | oui: "Acceptés", non: "Refusés", conditions: "Sous conditions", |
@@ -178,6 +195,29 @@ export default function ListingPage() { | ||
| 178 | 195 | </> |
| 179 | 196 | )} |
| 180 | 197 | |
| 198 | + {(l.poi?.length ?? 0) > 0 && ( | |
| 199 | + <> | |
| 200 | + <div className="k" style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: "0.07em", color: "var(--ink-3)", fontWeight: 700, margin: "16px 0 8px" }}> | |
| 201 | + À proximité | |
| 202 | + </div> | |
| 203 | + <ul className="poi-list"> | |
| 204 | + {l.poi!.map((p) => { | |
| 205 | + const meta = POI_META[p.cat] ?? { icon: "📍", label: p.cat }; | |
| 206 | + return ( | |
| 207 | + <li key={p.cat} title={meta.label}> | |
| 208 | + <span className="poi-ico" aria-hidden="true">{meta.icon}</span> | |
| 209 | + <span className="poi-name">{p.name}</span> | |
| 210 | + <span className="poi-dist">{fmtDist(p.dist_m)}</span> | |
| 211 | + </li> | |
| 212 | + ); | |
| 213 | + })} | |
| 214 | + </ul> | |
| 215 | + <div className="fine" style={{ marginTop: 6 }}> | |
| 216 | + Distances à vol d'oiseau — données OpenStreetMap. | |
| 217 | + </div> | |
| 218 | + </> | |
| 219 | + )} | |
| 220 | + | |
| 181 | 221 | <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer"> |
| 182 | 222 | Voir l'annonce chez {sourceName(l.source)} ↗ |
| 183 | 223 | </a> |
modified
frontend/src/styles.css
+15 −0
@@ -571,3 +571,18 @@ img { display: block; } | ||
| 571 | 571 | font-weight: 600; font-size: 13px; |
| 572 | 572 | } |
| 573 | 573 | .mv-pop-cta:hover { background: var(--green-deep); } |
| 574 | + | |
| 575 | +/* --- Commodités de proximité (fiche) -------------------------------------- */ | |
| 576 | +.poi-list { | |
| 577 | + list-style: none; margin: 0; padding: 0; | |
| 578 | + display: flex; flex-direction: column; gap: 2px; | |
| 579 | +} | |
| 580 | +.poi-list li { | |
| 581 | + display: flex; align-items: center; gap: 9px; | |
| 582 | + padding: 5px 2px; border-bottom: 1px dashed var(--line); | |
| 583 | + font-size: 13px; | |
| 584 | +} | |
| 585 | +.poi-list li:last-child { border-bottom: 0; } | |
| 586 | +.poi-ico { width: 20px; text-align: center; flex: 0 0 auto; } | |
| 587 | +.poi-name { flex: 1; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 588 | +.poi-dist { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); font-weight: 600; flex: 0 0 auto; } | |
modified
louka/db.py
+8 −0
@@ -85,6 +85,14 @@ CREATE TABLE IF NOT EXISTS detail_cache ( | ||
| 85 | 85 | PRIMARY KEY (source, external_id) |
| 86 | 86 | ); |
| 87 | 87 | |
| 88 | +CREATE TABLE IF NOT EXISTS poi_cache ( | |
| 89 | + coord_key TEXT PRIMARY KEY, -- "lat,lng" arrondi à 4 décimales (~11 m) | |
| 90 | + lat REAL, | |
| 91 | + lng REAL, | |
| 92 | + pois TEXT, -- JSON : [{cat, name, dist_m}] (plus proche/catégorie) | |
| 93 | + ts REAL | |
| 94 | +); | |
| 95 | + | |
| 88 | 96 | CREATE TABLE IF NOT EXISTS geocode_cache ( |
| 89 | 97 | address TEXT PRIMARY KEY, -- adresse normalisée (clé de cache) |
| 90 | 98 | lat REAL, |
modified
louka/ingest.py
+5 −0
@@ -59,6 +59,11 @@ def watch(interval_seconds: int = 3600) -> None: | ||
| 59 | 59 | geocode.run(limit=120) |
| 60 | 60 | except Exception as exc: |
| 61 | 61 | print(f"[lou-ka] geocode: erreur non bloquante: {exc}", file=sys.stderr) |
| 62 | + try: # commodités de proximité des nouveaux immeubles (cache aussi) | |
| 63 | + from . import poi | |
| 64 | + poi.run(limit=80) | |
| 65 | + except Exception as exc: | |
| 66 | + print(f"[lou-ka] poi: erreur non bloquante: {exc}", file=sys.stderr) | |
| 62 | 67 | print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s") |
| 63 | 68 | time.sleep(interval_seconds) |
| 64 | 69 | |
added
louka/poi.py
+179 −0
@@ -0,0 +1,179 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# poi.py : commodités de proximité par immeuble via Overpass (OpenStreetMap) | |
| 5 | +# Pour chaque immeuble géolocalisé, une requête Overpass unique récupère | |
| 6 | +# 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 PROCHE | |
| 8 | +# de chaque catégorie avec sa distance. Cache permanent par coordonnées | |
| 9 | +# (table poi_cache, clé arrondie à 4 décimales ≈ 11 m : les unités d'un | |
| 10 | +# même immeuble partagent la même entrée). Politesse : 1 requête/seconde. | |
| 11 | +# ----------------------------------------------------------------------------- | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import json | |
| 15 | +import math | |
| 16 | +import time | |
| 17 | + | |
| 18 | +import requests | |
| 19 | + | |
| 20 | +from . import db | |
| 21 | + | |
| 22 | +OVERPASS_URL = "https://overpass-api.de/api/interpreter" | |
| 23 | +USER_AGENT = "LouKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)" | |
| 24 | +REQUEST_DELAY = 1.1 | |
| 25 | +REFRESH_AFTER = 90 * 86400 # les POI bougent peu : rafraîchir aux ~3 mois | |
| 26 | + | |
| 27 | +# Catégories : (clé, libellé FR, sélecteur Overpass, rayon m) | |
| 28 | +CATEGORIES: list[tuple[str, str, str, int]] = [ | |
| 29 | + ("epicerie", "Épicerie", '["shop"="supermarket"]', 1500), | |
| 30 | + ("depanneur", "Dépanneur", '["shop"="convenience"]', 800), | |
| 31 | + ("pharmacie", "Pharmacie", '["amenity"="pharmacy"]', 1500), | |
| 32 | + ("ecole", "École", '["amenity"="school"]', 1500), | |
| 33 | + ("garderie", "Garderie", '["amenity"~"^(kindergarten|childcare)$"]', 1500), | |
| 34 | + ("parc", "Parc", '["leisure"="park"]', 1200), | |
| 35 | + ("bus", "Arrêt de bus", '["highway"="bus_stop"]', 600), | |
| 36 | + ("metro", "Métro", '["railway"="station"]["station"="subway"]', 1500), | |
| 37 | + ("gym", "Gym", '["leisure"="fitness_centre"]', 1500), | |
| 38 | + ("cafe", "Café", '["amenity"="cafe"]', 1000), | |
| 39 | + ("clinique", "Clinique / CLSC", '["amenity"~"^(clinic|doctors)$"]', 2000), | |
| 40 | + ("hopital", "Hôpital", '["amenity"="hospital"]', 3000), | |
| 41 | + ("bibliotheque", "Bibliothèque", '["amenity"="library"]', 2000), | |
| 42 | +] | |
| 43 | + | |
| 44 | +LABELS = {cat: label for cat, label, _, _ in CATEGORIES} | |
| 45 | + | |
| 46 | + | |
| 47 | +def coord_key(lat: float, lng: float) -> str: | |
| 48 | + return f"{round(lat, 4)},{round(lng, 4)}" | |
| 49 | + | |
| 50 | + | |
| 51 | +def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: | |
| 52 | + r = 6371000.0 | |
| 53 | + p1, p2 = math.radians(lat1), math.radians(lat2) | |
| 54 | + dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) | |
| 55 | + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 | |
| 56 | + return 2 * r * math.asin(math.sqrt(a)) | |
| 57 | + | |
| 58 | + | |
| 59 | +def _build_query(lat: float, lng: float) -> str: | |
| 60 | + """Une seule requête Overpass couvrant toutes les catégories.""" | |
| 61 | + parts = [] | |
| 62 | + for _cat, _label, sel, radius in CATEGORIES: | |
| 63 | + parts.append(f"nwr(around:{radius},{lat:.5f},{lng:.5f}){sel};") | |
| 64 | + return f'[out:json][timeout:25];({"".join(parts)});out center tags 200;' | |
| 65 | + | |
| 66 | + | |
| 67 | +def _match_category(tags: dict) -> str | None: | |
| 68 | + """Retrouve la catégorie Lou-Ka d'un élément OSM retourné.""" | |
| 69 | + shop = tags.get("shop") | |
| 70 | + amenity = tags.get("amenity") | |
| 71 | + leisure = tags.get("leisure") | |
| 72 | + if shop == "supermarket": | |
| 73 | + return "epicerie" | |
| 74 | + if shop == "convenience": | |
| 75 | + return "depanneur" | |
| 76 | + if amenity == "pharmacy": | |
| 77 | + return "pharmacie" | |
| 78 | + if amenity == "school": | |
| 79 | + return "ecole" | |
| 80 | + if amenity in ("kindergarten", "childcare"): | |
| 81 | + return "garderie" | |
| 82 | + if leisure == "park": | |
| 83 | + return "parc" | |
| 84 | + if tags.get("highway") == "bus_stop": | |
| 85 | + return "bus" | |
| 86 | + if tags.get("railway") == "station" and tags.get("station") == "subway": | |
| 87 | + return "metro" | |
| 88 | + if leisure == "fitness_centre": | |
| 89 | + return "gym" | |
| 90 | + if amenity == "cafe": | |
| 91 | + return "cafe" | |
| 92 | + if amenity in ("clinic", "doctors"): | |
| 93 | + return "clinique" | |
| 94 | + if amenity == "hospital": | |
| 95 | + return "hopital" | |
| 96 | + if amenity == "library": | |
| 97 | + return "bibliotheque" | |
| 98 | + return None | |
| 99 | + | |
| 100 | + | |
| 101 | +class PoiClient: | |
| 102 | + def __init__(self) -> None: | |
| 103 | + self.session = requests.Session() | |
| 104 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 105 | + self._last = 0.0 | |
| 106 | + | |
| 107 | + def fetch(self, lat: float, lng: float) -> list[dict] | None: | |
| 108 | + """POI les plus proches par catégorie. None = erreur réseau (re-tenter).""" | |
| 109 | + wait = REQUEST_DELAY - (time.time() - self._last) | |
| 110 | + if wait > 0: | |
| 111 | + time.sleep(wait) | |
| 112 | + try: | |
| 113 | + resp = self.session.post(OVERPASS_URL, | |
| 114 | + data={"data": _build_query(lat, lng)}, | |
| 115 | + timeout=40) | |
| 116 | + self._last = time.time() | |
| 117 | + resp.raise_for_status() | |
| 118 | + elements = resp.json().get("elements") or [] | |
| 119 | + except Exception: | |
| 120 | + self._last = time.time() | |
| 121 | + return None | |
| 122 | + | |
| 123 | + meilleurs: dict[str, dict] = {} | |
| 124 | + for el in elements: | |
| 125 | + tags = el.get("tags") or {} | |
| 126 | + cat = _match_category(tags) | |
| 127 | + if cat is None: | |
| 128 | + continue | |
| 129 | + elat = el.get("lat") or (el.get("center") or {}).get("lat") | |
| 130 | + elng = el.get("lon") or (el.get("center") or {}).get("lon") | |
| 131 | + if elat is None or elng is None: | |
| 132 | + continue | |
| 133 | + dist = _haversine_m(lat, lng, elat, elng) | |
| 134 | + if cat not in meilleurs or dist < meilleurs[cat]["dist_m"]: | |
| 135 | + nom = tags.get("name") or LABELS[cat] | |
| 136 | + meilleurs[cat] = {"cat": cat, "name": nom[:60], | |
| 137 | + "dist_m": round(dist)} | |
| 138 | + return sorted(meilleurs.values(), key=lambda p: p["dist_m"]) | |
| 139 | + | |
| 140 | + | |
| 141 | +def run(limit: int | None = None) -> dict: | |
| 142 | + """Remplit poi_cache pour les immeubles géolocalisés qui n'y sont pas. | |
| 143 | + | |
| 144 | + `limit` borne le nombre de requêtes Overpass de cette exécution | |
| 145 | + (les entrées déjà en cache ne coûtent rien). | |
| 146 | + """ | |
| 147 | + con = db.connect() | |
| 148 | + client = PoiClient() | |
| 149 | + rows = con.execute( | |
| 150 | + """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings | |
| 151 | + WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall() | |
| 152 | + | |
| 153 | + done = errors = skipped = 0 | |
| 154 | + now = time.time() | |
| 155 | + for r in rows: | |
| 156 | + key = f"{r['la']},{r['ln']}" | |
| 157 | + cached = con.execute( | |
| 158 | + "SELECT ts FROM poi_cache WHERE coord_key=?", (key,)).fetchone() | |
| 159 | + if cached and now - (cached["ts"] or 0) < REFRESH_AFTER: | |
| 160 | + skipped += 1 | |
| 161 | + continue | |
| 162 | + if limit is not None and done + errors >= limit: | |
| 163 | + continue | |
| 164 | + pois = client.fetch(r["la"], r["ln"]) | |
| 165 | + if pois is None: | |
| 166 | + errors += 1 | |
| 167 | + continue # erreur réseau : pas de cache, re-tentée au prochain run | |
| 168 | + con.execute( | |
| 169 | + "INSERT INTO poi_cache (coord_key, lat, lng, pois, ts) VALUES (?,?,?,?,?)" | |
| 170 | + " ON CONFLICT(coord_key) DO UPDATE SET pois=excluded.pois, ts=excluded.ts", | |
| 171 | + (key, r["la"], r["ln"], json.dumps(pois, ensure_ascii=False), now)) | |
| 172 | + con.commit() | |
| 173 | + done += 1 | |
| 174 | + | |
| 175 | + con.close() | |
| 176 | + stats = {"fetched": done, "cached": skipped, "errors": errors, | |
| 177 | + "total_coords": len(rows)} | |
| 178 | + print(f"[lou-ka] poi {stats}") | |
| 179 | + return stats | |
modified
louka/web.py
+13 −2
@@ -171,10 +171,21 @@ def listings_geojson( | ||
| 171 | 171 | def get_listing(uid: str): |
| 172 | 172 | con = db.connect() |
| 173 | 173 | row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() |
| 174 | + d = None | |
| 175 | + if row is not None: | |
| 176 | + d = _row_to_dict(row) | |
| 177 | + # commodités de proximité (cache par immeuble, voir louka/poi.py) | |
| 178 | + if d.get("lat") is not None and d.get("lng") is not None: | |
| 179 | + key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}" | |
| 180 | + poi_row = con.execute( | |
| 181 | + "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone() | |
| 182 | + d["poi"] = json.loads(poi_row["pois"]) if poi_row else [] | |
| 183 | + else: | |
| 184 | + d["poi"] = [] | |
| 174 | 185 | con.close() |
| 175 | − if row is None: | |
| 186 | + if d is None: | |
| 176 | 187 | raise HTTPException(404, "Annonce introuvable") |
| 177 | − return _row_to_dict(row) | |
| 188 | + return d | |
| 178 | 189 | |
| 179 | 190 | |
| 180 | 191 | @app.get("/api/facets") |
modified
run.py
+5 −0
@@ -10,6 +10,7 @@ | ||
| 10 | 10 | python run.py serve [port] # démarre l'API + le frontend (défaut 8080) |
| 11 | 11 | python run.py record <source ...> # enregistre les fixtures de test d'une source |
| 12 | 12 | python run.py geocode [n] # géocode les annonces sans coordonnées (max n requêtes) |
| 13 | + python run.py poi [n] # commodités de proximité par immeuble (max n requêtes) | |
| 13 | 14 | """ |
| 14 | 15 | from __future__ import annotations |
| 15 | 16 | |
@@ -40,6 +41,10 @@ def main() -> None: | ||
| 40 | 41 | from louka import geocode |
| 41 | 42 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None |
| 42 | 43 | geocode.run(limit) |
| 44 | + elif cmd == "poi": | |
| 45 | + from louka import poi | |
| 46 | + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None | |
| 47 | + poi.run(limit) | |
| 43 | 48 | elif cmd == "record": |
| 44 | 49 | from louka import fixtures |
| 45 | 50 | from louka.connectors import CONNECTORS |
| 46 | 51 | |