SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%

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>
simon-pierre boucher committed 2 days ago (Aug 9, 2026) parent 007514e

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