SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

Fiche : bloc Commerces et transport (Mapbox Search Box + cache POI/OSM)

- louka/commerces.py : point de vente le plus proche de 14 grandes bannières
  (Costco, Walmart, Metro, IGA, Maxi, Super C, Provigo, Canadian Tire,
  Dollarama, SAQ, Pharmaprix, Jean Coutu, Home Depot, RONA) via l API Mapbox
  Search Box (jeton public kamaps, validation du nom retourné, anti
  « Station Métro »), cache par cellule ~1 km TTL 30 j ; station de métro et
  arrêt de bus les plus proches repris du cache POI du projet (repli
  Overpass) ; GET /api/commerces
- bloc fiche : pastilles SVG monogrammes aux couleurs des bannières,
  nom du point de vente et distance — métro/bus en tête

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent f57db10

7 changed files +381 −0

modified .gitignore +1 −0
@@ -31,3 +31,4 @@ data/rdl.db
31 31 data/inondation.db
32 32 data/air.db
33 33 data/gaz.db
34 +data/commerces.db
modified frontend/src/api.ts +13 −0
@@ -624,3 +624,16 @@ export interface GazNearby {
624 624 /** Stations-service à proximité et prix courants (gazquebec.ca). */
625 625 export const fetchGaz = (lat: number, lng: number) =>
626 626 get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}`);
627 +
628 +export interface CommerceItem {
629 + id: string; commerce: string; nom: string; adresse: string;
630 + dist_m: number; lat: number; lng: number;
631 +}
632 +
633 +export interface CommercesNearby {
634 + n: number; commerces: CommerceItem[]; transit?: CommerceItem[];
635 +}
636 +
637 +/** Grands commerces + métro/bus les plus proches (Mapbox / OSM). */
638 +export const fetchCommerces = (lat: number, lng: number) =>
639 + get<CommercesNearby>(`/api/commerces?lat=${lat}&lng=${lng}`);
added frontend/src/components/CommercesProches.tsx +83 −0
@@ -0,0 +1,83 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/CommercesProches.tsx : bloc « Commerces et transport » (fiche)
5 +// Distance au point de vente le plus proche de chaque grande bannière
6 +// (Costco, Metro, IGA, Walmart… via l'API Mapbox Search Box) + station de
7 +// métro et arrêt de bus les plus proches. Pastilles SVG monogrammes aux
8 +// couleurs des bannières (pas de logos déposés).
9 +// -----------------------------------------------------------------------------
10 +import { useEffect, useState } from "react";
11 +import { CommercesNearby, fetchCommerces, fmtDist } from "../api";
12 +
13 +// id -> [couleur de fond, monogramme, couleur du texte]
14 +const ICONES: Record<string, [string, string, string?]> = {
15 + metro_station: ["#0083C9", "M"],
16 + arret_bus: ["#4E5357", "B"],
17 + costco: ["#005DAA", "C"],
18 + walmart: ["#0071CE", "W"],
19 + metro: ["#EF3E42", "M"],
20 + iga: ["#D50032", "IGA"],
21 + maxi: ["#0079C1", "Mx"],
22 + superc: ["#E4002B", "SC"],
23 + provigo: ["#DA291C", "P"],
24 + canadiantire: ["#D6001C", "CT"],
25 + dollarama: ["#00B140", "D", "#FFDD00"],
26 + saq: ["#892034", "SAQ"],
27 + pharmaprix: ["#E11B22", "Ph"],
28 + jeancoutu: ["#003DA5", "JC"],
29 + homedepot: ["#F96302", "HD"],
30 + rona: ["#1B4298", "R"],
31 +};
32 +
33 +function Pastille({ id }: { id: string }) {
34 + const [bg, mono, fg] = ICONES[id] ?? ["#777", "•"];
35 + const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14;
36 + return (
37 + <svg className="cm-ico" viewBox="0 0 28 28" width="28" height="28"
38 + aria-hidden="true">
39 + {id === "metro_station" || id === "arret_bus"
40 + ? <circle cx="14" cy="14" r="13" fill={bg} />
41 + : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />}
42 + <text x="14" y="14" textAnchor="middle" dominantBaseline="central"
43 + fontSize={fs} fontWeight="800" fontFamily="inherit"
44 + fill={fg ?? "#fff"}>{mono}</text>
45 + </svg>
46 + );
47 +}
48 +
49 +export default function CommercesProches({ lat, lng }:
50 + { lat: number | null; lng: number | null }) {
51 + const [d, setD] = useState<CommercesNearby | null>(null);
52 + useEffect(() => {
53 + setD(null);
54 + if (lat == null || lng == null) return;
55 + fetchCommerces(lat, lng).then(setD).catch(() => setD(null));
56 + }, [lat, lng]);
57 + if (lat == null || lng == null || !d) return null;
58 + const tous = [...(d.transit ?? []), ...(d.commerces ?? [])];
59 + if (tous.length === 0) return null;
60 +
61 + return (
62 + <section className="f-bloc f-commerces" id="commerces">
63 + <h2>Commerces et transport</h2>
64 + <ul className="cm-grille">
65 + {tous.map((c) => (
66 + <li key={c.id} className="cm-item"
67 + title={c.adresse || undefined}>
68 + <Pastille id={c.id} />
69 + <span className="cm-txt">
70 + <span className="cm-nom">{c.commerce}</span>
71 + <span className="cm-poi">{c.nom}</span>
72 + </span>
73 + <span className="cm-dist">{fmtDist(c.dist_m)}</span>
74 + </li>
75 + ))}
76 + </ul>
77 + <p className="fine">
78 + Point de vente le plus proche de chaque bannière — distances à vol
79 + d'oiseau (recherche Mapbox ; métro et bus : OpenStreetMap).
80 + </p>
81 + </section>
82 + );
83 +}
modified frontend/src/pages/Listing.tsx +3 −0
@@ -21,6 +21,7 @@ import RegistreLoyers from "../components/RegistreLoyers";
21 21 import RisqueInondation from "../components/RisqueInondation";
22 22 import QualiteAir from "../components/QualiteAir";
23 23 import EssenceProche from "../components/EssenceProche";
24 +import CommercesProches from "../components/CommercesProches";
24 25 import { IcoAlert, IcoDoc } from "../components/Icons";
25 26 import KaScoresBlock from "../components/KaScoresBlock";
26 27 import { markSeen } from "../search/seen";
@@ -481,6 +482,8 @@ export default function ListingPage() {
481 482
482 483 <QualiteAir lat={l.lat} lng={l.lng} />
483 484
485 + <CommercesProches lat={l.lat} lng={l.lng} />
486 +
484 487 <EssenceProche lat={l.lat} lng={l.lng} />
485 488
486 489 {l.kascores && <KaScoresBlock ks={l.kascores} />}
modified frontend/src/styles.css +16 −0
@@ -1956,3 +1956,19 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1956 1956 border-radius: 999px; background: #e7f4ea; color: #1e6b34;
1957 1957 font-size: 10.5px; font-weight: 700; }
1958 1958 .gaz-adr { display: block; font-size: 11px; color: var(--ink-3); }
1959 +
1960 +
1961 +/* ---- Commerces et transport (fiche) ---- */
1962 +.cm-grille { list-style: none; margin: 6px 0 0; padding: 0;
1963 + display: grid; grid-template-columns: 1fr 1fr; gap: 4px 18px; }
1964 +@media (max-width: 560px) { .cm-grille { grid-template-columns: 1fr; } }
1965 +.cm-item { display: flex; align-items: center; gap: 9px; padding: 5px 0;
1966 + border-bottom: 1px solid var(--line, #eeece7); min-width: 0; }
1967 +.cm-ico { flex: 0 0 28px; }
1968 +.cm-txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
1969 +.cm-nom { font-size: 13px; font-weight: 600; color: var(--ink, #222);
1970 + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1971 +.cm-poi { font-size: 11px; color: var(--ink-3, #8a877f);
1972 + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1973 +.cm-dist { flex: 0 0 auto; font-size: 13px; font-weight: 700;
1974 + color: var(--ink-2, #4c4a45); white-space: nowrap; }
added louka/commerces.py +258 −0
@@ -0,0 +1,258 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# commerces.py : grands commerces à proximité — API Mapbox Search Box
5 +#
6 +# Pour chaque grande bannière (Costco, Metro, IGA, Walmart…), on interroge
7 +# l'API Search Box de Mapbox (jeton PUBLIC pk.… lu dans
8 +# frontend/src/kamaps/config.ts — source de vérité du projet) avec la
9 +# position de l'annonce en `proximity`, et on retient le point de vente le
10 +# plus proche. Cache par cellule d'environ 1 km (data/commerces.db,
11 +# TTL 30 jours) : les fiches d'un même secteur ne recoûtent rien.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +import json
16 +import math
17 +import re
18 +import sqlite3
19 +import time
20 +import urllib.parse
21 +import urllib.request
22 +from concurrent.futures import ThreadPoolExecutor
23 +from pathlib import Path
24 +
25 +ROOT = Path(__file__).resolve().parent.parent
26 +DB_PATH = ROOT / "data" / "commerces.db"
27 +UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)"
28 +TTL = 30 * 86400
29 +API = "https://api.mapbox.com/search/searchbox/v1/forward"
30 +
31 +# id, libellé, requête Mapbox, mot-clé de validation (le nom du POI doit le
32 +# contenir, sans accents ni casse — écarte « Station Métro », « Super Qualité »…)
33 +BRANDS = [
34 + ("costco", "Costco", "Costco Wholesale", "costco"),
35 + ("walmart", "Walmart", "Walmart Supercentre", "walmart"),
36 + ("metro", "Metro", "Metro", "metro"),
37 + ("iga", "IGA", "IGA", "iga"),
38 + ("maxi", "Maxi", "Maxi", "maxi"),
39 + ("superc", "Super C", "Super C", "super c"),
40 + ("provigo", "Provigo", "Provigo", "provigo"),
41 + ("canadiantire", "Canadian Tire", "Canadian Tire", "canadian tire"),
42 + ("dollarama", "Dollarama", "Dollarama", "dollarama"),
43 + ("saq", "SAQ", "SAQ", "saq"),
44 + ("pharmaprix", "Pharmaprix", "Pharmaprix", "pharmaprix"),
45 + ("jeancoutu", "Jean Coutu", "Jean Coutu pharmacie", "jean coutu"),
46 + ("homedepot", "Home Depot", "Home Depot", "home depot"),
47 + ("rona", "RONA", "RONA", "rona"),
48 +]
49 +
50 +_BAN = ("station", "stationnement")
51 +
52 +
53 +def _norm(s: str) -> str:
54 + import unicodedata
55 + s = unicodedata.normalize("NFD", s or "")
56 + return "".join(c for c in s if unicodedata.category(c) != "Mn").lower()
57 +
58 +_token_cache: list[str] = []
59 +
60 +
61 +def _token() -> str:
62 + if not _token_cache:
63 + cfg = (ROOT / "frontend" / "src" / "kamaps" / "config.ts").read_text()
64 + m = re.search(r'"(pk\.[A-Za-z0-9._-]+)"', cfg)
65 + if not m:
66 + raise RuntimeError("jeton Mapbox introuvable (kamaps/config.ts)")
67 + _token_cache.append(m.group(1))
68 + return _token_cache[0]
69 +
70 +
71 +def _connect() -> sqlite3.Connection:
72 + DB_PATH.parent.mkdir(parents=True, exist_ok=True)
73 + con = sqlite3.connect(DB_PATH, timeout=15)
74 + con.row_factory = sqlite3.Row
75 + con.execute("""CREATE TABLE IF NOT EXISTS commerces_cache (
76 + cellule TEXT, brand TEXT, nom TEXT, adresse TEXT,
77 + lat REAL, lng REAL, fetched_at REAL,
78 + PRIMARY KEY (cellule, brand))""")
79 + return con
80 +
81 +
82 +def _dist_m(lat1, lng1, lat2, lng2) -> float:
83 + dlat = math.radians(lat2 - lat1)
84 + dlng = math.radians(lng2 - lng1)
85 + a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1))
86 + * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2)
87 + return 6371000 * 2 * math.asin(math.sqrt(a))
88 +
89 +
90 +def _fetch_brand(brand_q: str, lat: float, lng: float,
91 + match: str = "") -> dict | None:
92 + params = urllib.parse.urlencode({
93 + "q": brand_q, "proximity": f"{lng},{lat}", "limit": 5,
94 + "types": "poi", "language": "fr", "country": "CA",
95 + "access_token": _token()})
96 + req = urllib.request.Request(f"{API}?{params}",
97 + headers={"User-Agent": UA})
98 + try:
99 + with urllib.request.urlopen(req, timeout=12) as r:
100 + feats = json.load(r).get("features") or []
101 + except Exception:
102 + return None
103 + for f in feats:
104 + p = f.get("properties") or {}
105 + nom = _norm(p.get("name") or "")
106 + if match and match not in nom:
107 + continue
108 + if any(b in nom for b in _BAN):
109 + continue
110 + lng2, lat2 = f["geometry"]["coordinates"][:2]
111 + return {"nom": p.get("name") or brand_q,
112 + "adresse": p.get("full_address")
113 + or p.get("place_formatted") or "",
114 + "lat": lat2, "lng": lng2}
115 + return None
116 +
117 +
118 +OVERPASS = ["https://overpass.kumi.systems/api/interpreter",
119 + "https://overpass-api.de/api/interpreter"]
120 +
121 +
122 +def _fetch_transit(lat: float, lng: float) -> list[tuple[str, dict]]:
123 + """Station de métro et arrêt de bus les plus proches (OpenStreetMap)."""
124 + q = f"""[out:json][timeout:20];
125 +(
126 + node["railway"="station"]["station"="subway"](around:3000,{lat},{lng});
127 + node["highway"="bus_stop"](around:1000,{lat},{lng});
128 +);
129 +out body;"""
130 + data = None
131 + for url in OVERPASS:
132 + try:
133 + req = urllib.request.Request(
134 + url, data=urllib.parse.urlencode({"data": q}).encode(),
135 + headers={"User-Agent": UA})
136 + with urllib.request.urlopen(req, timeout=25) as r:
137 + data = json.load(r)
138 + break
139 + except Exception:
140 + continue
141 + if not data:
142 + return []
143 + best: dict[str, tuple[float, dict]] = {}
144 + for el in data.get("elements", []):
145 + tags = el.get("tags") or {}
146 + kind = ("metro_station" if tags.get("railway") == "station"
147 + else "arret_bus")
148 + d = _dist_m(lat, lng, el["lat"], el["lon"])
149 + if kind not in best or d < best[kind][0]:
150 + best[kind] = (d, {"nom": tags.get("name")
151 + or ("Station de métro" if kind == "metro_station"
152 + else "Arrêt de bus"),
153 + "adresse": "", "lat": el["lat"],
154 + "lng": el["lon"]})
155 + return [(k, v[1]) for k, v in best.items()]
156 +
157 +
158 +TRANSIT = [("metro_station", "Station de métro"), ("arret_bus", "Arrêt de bus")]
159 +
160 +_POI_CAT = {"metro": "metro_station", "bus": "arret_bus"}
161 +
162 +
163 +def _transit_from_poi(lat: float, lng: float) -> list[tuple[str, dict]]:
164 + """Métro/bus depuis le cache POI du projet (louka.db, déjà calculé par
165 + immeuble) — la fiche interroge avec les mêmes coordonnées que poi.py."""
166 + db_main = ROOT / "data" / next(
167 + (n for n in ("louka.db", "immoka.db", "immo.db")
168 + if (ROOT / "data" / n).exists()), "louka.db")
169 + if not db_main.exists():
170 + return []
171 + try:
172 + con = sqlite3.connect(f"file:{db_main}?mode=ro", uri=True)
173 + con.row_factory = sqlite3.Row
174 + d = 300 / 111320.0
175 + row = con.execute(
176 + "SELECT pois, lat, lng FROM poi_cache WHERE lat BETWEEN ? AND ? "
177 + "AND lng BETWEEN ? AND ? ORDER BY (lat-?)*(lat-?)+(lng-?)*(lng-?) "
178 + "LIMIT 1", (lat - d, lat + d, lng - d, lng + d,
179 + lat, lat, lng, lng)).fetchone()
180 + con.close()
181 + except sqlite3.Error:
182 + return []
183 + if row is None:
184 + return []
185 + out = []
186 + for e in json.loads(row["pois"] or "[]"):
187 + k = _POI_CAT.get(e.get("cat"))
188 + if k:
189 + out.append((k, {"nom": e.get("name") or "", "adresse": "",
190 + "lat": lat, "lng": lng,
191 + "_dist": e.get("dist_m")}))
192 + return out
193 +
194 +
195 +def nearby(lat: float, lng: float) -> dict:
196 + """Grand commerce le plus proche par bannière (cache ~1 km, TTL 30 j)."""
197 + cell = f"{round(lat, 2)},{round(lng, 2)}"
198 + con = _connect()
199 + now = time.time()
200 + cached = {r["brand"]: r for r in con.execute(
201 + "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?",
202 + (cell, now - TTL))}
203 + manquants = [(bid, q, m) for bid, _, q, m in BRANDS
204 + if bid not in cached]
205 + transit_manquant = any(k not in cached for k, _ in TRANSIT)
206 + if manquants or transit_manquant:
207 + res: list[tuple[str, dict | None]] = []
208 + if manquants:
209 + with ThreadPoolExecutor(max_workers=6) as ex:
210 + res = list(ex.map(
211 + lambda b: (b[0], _fetch_brand(b[1], lat, lng, b[2])),
212 + manquants))
213 + if transit_manquant:
214 + tr = _transit_from_poi(lat, lng) or _fetch_transit(lat, lng)
215 + res.extend(tr)
216 + with con:
217 + for bid, hit in res:
218 + if hit is None:
219 + continue
220 + con.execute(
221 + "INSERT OR REPLACE INTO commerces_cache VALUES "
222 + "(?,?,?,?,?,?,?)",
223 + (cell, bid, hit["nom"],
224 + hit.get("adresse") or (str(hit["_dist"])
225 + if hit.get("_dist") is not None
226 + else ""),
227 + hit["lat"], hit["lng"], now))
228 + cached = {r["brand"]: r for r in con.execute(
229 + "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?",
230 + (cell, now - TTL))}
231 + con.close()
232 +
233 + items = []
234 + transit = []
235 + for bid, label in TRANSIT:
236 + r = cached.get(bid)
237 + if r is not None:
238 + # distance : celle du cache POI si disponible (adresse numérique)
239 + d = (float(r["adresse"]) if (r["adresse"] or "").replace(
240 + ".", "").isdigit() else _dist_m(lat, lng, r["lat"], r["lng"]))
241 + if d <= 5000:
242 + transit.append({"id": bid, "commerce": label,
243 + "nom": r["nom"], "adresse": "",
244 + "dist_m": round(d),
245 + "lat": r["lat"], "lng": r["lng"]})
246 + for bid, label, _q, _m in BRANDS:
247 + r = cached.get(bid)
248 + if r is None:
249 + continue
250 + d = _dist_m(lat, lng, r["lat"], r["lng"])
251 + if d > 40000: # au-delà de 40 km : non pertinent
252 + continue
253 + items.append({"id": bid, "commerce": label, "nom": r["nom"],
254 + "adresse": r["adresse"], "dist_m": round(d),
255 + "lat": r["lat"], "lng": r["lng"]})
256 + items.sort(key=lambda x: x["dist_m"])
257 + transit.sort(key=lambda x: x["dist_m"])
258 + return {"n": len(items), "commerces": items, "transit": transit}
modified louka/web.py +7 −0
@@ -542,6 +542,13 @@ def fairvalue_detail(uid: str):
542 542 return d
543 543
544 544
545 +@app.get("/api/commerces")
546 +def commerces_at(lat: float, lng: float):
547 + """Grands commerces + métro/bus les plus proches (Mapbox / OSM)."""
548 + from . import commerces
549 + return commerces.nearby(lat, lng)
550 +
551 +
545 552 @app.get("/api/air")
546 553 def air_at(lat: float, lng: float):
547 554 """Qualité de l'air : station RSQAQ la plus proche (MELCCFP)."""
548 555