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
4 days agolast push
HTML 98.9% Python 0.6%

hydro : cache par uid + dérivation du code postal via lat/lng (Mapbox)

- cache indexé aussi par uid (clé stable annonce) : affichage cache-only sur
  la fiche sans code postal ni appel Mapbox
- code postal dérivé du lat/lng par géocodage inverse Mapbox quand l adresse
  n en porte pas (cas Immo-Ka) — au moment du calcul seulement
- estimate(uid, lat, lng) ; endpoint et fiche passent uid+lat+lng ;
  cache-only renvoie en_attente (bouton) si un calcul est faisable
- precompute sélectionne lat/lng/uid, saute ce qui est déjà en cache par uid

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

5 changed files +128 −37

modified frontend/src/api.ts +12 −2
@@ -644,5 +644,15 @@ export interface HydroEstimate {
644 644 }
645 645
646 646 /** Coût d'électricité estimé à l'adresse (outil public Hydro-Québec). */
647 −export const fetchHydro = (adresse: string, solve = false) =>
648 − get<HydroEstimate>(`/api/hydro?adresse=${encodeURIComponent(adresse)}` + (solve ? "&solve=1" : ""));
647 +export const fetchHydro = (
648 + adresse: string,
649 + opts: { uid?: string; lat?: number | null; lng?: number | null } = {},
650 + solve = false,
651 +) => {
652 + const q = new URLSearchParams({ adresse });
653 + if (opts.uid) q.set("uid", opts.uid);
654 + if (opts.lat != null) q.set("lat", String(opts.lat));
655 + if (opts.lng != null) q.set("lng", String(opts.lng));
656 + if (solve) q.set("solve", "1");
657 + return get<HydroEstimate>(`/api/hydro?${q.toString()}`);
658 +};
modified frontend/src/components/HydroEstimation.tsx +6 −5
@@ -10,8 +10,9 @@
10 10 import { useEffect, useState } from "react";
11 11 import { fetchHydro, fmtPrice, HydroEstimate } from "../api";
12 12
13 −export default function HydroEstimation({ adresse }:
14 − { adresse: string | null }) {
13 +export default function HydroEstimation({ adresse, uid, lat, lng }:
14 + { adresse: string | null; uid?: string;
15 + lat?: number | null; lng?: number | null }) {
15 16 const [d, setD] = useState<HydroEstimate | null>(null);
16 17 const [loading, setLoading] = useState(false);
17 18 const [masque, setMasque] = useState(false);
@@ -20,7 +21,7 @@ export default function HydroEstimation({ adresse }:
20 21 useEffect(() => {
21 22 setD(null);
22 23 if (!adresse) return;
23 − fetchHydro(adresse, false)
24 + fetchHydro(adresse, { uid, lat, lng }, false)
24 25 .then((r) => {
25 26 if (r.disponible || r.en_attente) setD(r);
26 27 else if (/captcha|configuré|incomplète/.test(r.raison || ""))
@@ -28,13 +29,13 @@ export default function HydroEstimation({ adresse }:
28 29 else setD(r);
29 30 })
30 31 .catch(() => setMasque(true));
31 − }, [adresse]);
32 + }, [adresse, uid, lat, lng]);
32 33
33 34 if (masque || !adresse || !d) return null;
34 35
35 36 const lancer = () => {
36 37 setLoading(true);
37 − fetchHydro(adresse, true)
38 + fetchHydro(adresse, { uid, lat, lng }, true)
38 39 .then((r) => {
39 40 if (!r.disponible && /captcha|configuré/.test(r.raison || ""))
40 41 setMasque(true);
modified frontend/src/pages/Listing.tsx +1 −1
@@ -487,7 +487,7 @@ export default function ListingPage() {
487 487
488 488 <EssenceProche lat={l.lat} lng={l.lng} />
489 489
490 − <HydroEstimation adresse={l.address || l.title} />
490 + <HydroEstimation adresse={l.address || l.title} uid={l.uid} lat={l.lat} lng={l.lng} />
491 491
492 492 {l.kascores && <KaScoresBlock ks={l.kascores} />}
493 493
modified louka/hydro.py +102 −26
@@ -139,16 +139,60 @@ def _connect() -> sqlite3.Connection:
139 139 con.execute("""CREATE TABLE IF NOT EXISTS hydro_cache (
140 140 cle TEXT PRIMARY KEY, adresse TEXT, cout_annuel REAL,
141 141 cout_mensuel REAL, payload TEXT, fetched_at REAL)""")
142 + cols = {r[1] for r in con.execute("PRAGMA table_info(hydro_cache)")}
143 + if "uid" not in cols:
144 + con.execute("ALTER TABLE hydro_cache ADD COLUMN uid TEXT")
145 + con.execute("CREATE INDEX IF NOT EXISTS idx_hydro_uid "
146 + "ON hydro_cache(uid)")
147 + if "kwh" not in cols:
148 + con.execute("ALTER TABLE hydro_cache ADD COLUMN kwh INTEGER")
142 149 return con
143 150
144 151
152 +def _civic(adresse: str) -> str | None:
153 + m = re.match(r"\s*(\d+[A-Za-z]?)", adresse or "")
154 + return m.group(1) if m else None
155 +
156 +
145 157 def _parse_addr(adresse: str) -> tuple[str, str] | None:
146 158 """Extrait (numéro civique, code postal) d'une adresse de fiche."""
147 159 cp = re.search(r"([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)", adresse or "")
148 − civ = re.match(r"\s*(\d+[A-Za-z]?)", adresse or "")
160 + civ = _civic(adresse or "")
149 161 if not cp or not civ:
150 162 return None
151 − return civ.group(1), cp.group(1).upper().replace(" ", "")
163 + return civ, cp.group(1).upper().replace(" ", "")
164 +
165 +
166 +_MAPBOX_TOKEN: list[str] = []
167 +
168 +
169 +def _mapbox_token() -> str | None:
170 + if not _MAPBOX_TOKEN:
171 + cfg = (Path(__file__).resolve().parent.parent / "frontend" / "src"
172 + / "kamaps" / "config.ts")
173 + if cfg.exists():
174 + m = re.search(r'"(pk\.[A-Za-z0-9._-]+)"', cfg.read_text())
175 + _MAPBOX_TOKEN.append(m.group(1) if m else "")
176 + else:
177 + _MAPBOX_TOKEN.append("")
178 + return _MAPBOX_TOKEN[0] or None
179 +
180 +
181 +def _postal_from_latlng(lat: float, lng: float) -> str | None:
182 + """Code postal via géocodage inverse Mapbox (adresses sans CP)."""
183 + tok = _mapbox_token()
184 + if not tok:
185 + return None
186 + url = (f"https://api.mapbox.com/geocoding/v5/mapbox.places/{lng},{lat}.json"
187 + f"?types=postcode&country=CA&access_token={tok}")
188 + try:
189 + with urllib.request.urlopen(url, timeout=15) as r:
190 + feats = json.load(r).get("features") or []
191 + if feats:
192 + return (feats[0].get("text") or "").upper().replace(" ", "")
193 + except Exception:
194 + return None
195 + return None
152 196
153 197
154 198 def _montant(d: dict) -> tuple[float | None, float | None, int | None]:
@@ -165,16 +209,48 @@ def _montant(d: dict) -> tuple[float | None, float | None, int | None]:
165 209
166 210
167 211 def estimate(civic: str | None = None, postal: str | None = None,
168 − adresse: str | None = None, solve: bool = True) -> dict:
212 + adresse: str | None = None, lat: float | None = None,
213 + lng: float | None = None, uid: str | None = None,
214 + solve: bool = True) -> dict:
169 215 """Coût d'électricité annuel estimé à une adresse (cache 6 mois).
170 216
171 217 `solve=False` : ne consulte QUE le cache (aucune résolution de captcha,
172 218 aucun coût) — utilisé pour l'affichage automatique des fiches.
173 − Renvoie {"disponible": bool, ...}.
219 + Le code postal est extrait de l'adresse, ou dérivé du couple lat/lng par
220 + géocodage inverse Mapbox si l'adresse n'en contient pas (cas d'Immo-Ka).
221 + Le cache est indexé par `uid` (clé stable de l'annonce) ET par
222 + civique|code postal, pour que l'affichage cache-only fonctionne même
223 + quand l'adresse ne porte pas de code postal.
174 224 """
175 − if not civic or not postal:
176 − if adresse and (pa := _parse_addr(adresse)):
177 − civic, postal = pa
225 + # lecture cache par uid d'abord (ne nécessite ni CP ni Mapbox)
226 + if uid:
227 + con = _connect()
228 + row = con.execute(
229 + "SELECT * FROM hydro_cache WHERE uid=? AND cout_annuel IS NOT NULL "
230 + "AND fetched_at>?", (uid, time.time() - TTL)).fetchone()
231 + con.close()
232 + if row:
233 + return {"disponible": True, "adresse": row["adresse"],
234 + "cout_annuel": row["cout_annuel"],
235 + "cout_mensuel": row["cout_mensuel"],
236 + "kwh_annuel": row["kwh"], "cache": True}
237 +
238 + if not civic and adresse:
239 + civic = _civic(adresse)
240 + if not postal and adresse:
241 + cp = re.search(r"([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)", adresse)
242 + if cp:
243 + postal = cp.group(1)
244 + # cache-only : ne pas déclencher Mapbox ; proposer le bouton si un calcul
245 + # est faisable (civique connu + CP OU coordonnées disponibles)
246 + if not solve:
247 + faisable = bool(civic) and (bool(postal) or
248 + (lat is not None and lng is not None))
249 + return {"disponible": False,
250 + "raison": "non calculé" if faisable else "adresse incomplète",
251 + "en_attente": faisable}
252 + if not postal and lat is not None and lng is not None:
253 + postal = _postal_from_latlng(lat, lng) # dérivation du CP au calcul
178 254 if not civic or not postal:
179 255 return {"disponible": False, "raison": "adresse incomplète"}
180 256 postal = postal.upper().replace(" ", "")
@@ -189,11 +265,8 @@ def estimate(civic: str | None = None, postal: str | None = None,
189 265 con.close()
190 266 return {"disponible": True, "adresse": row["adresse"],
191 267 "cout_annuel": row["cout_annuel"],
192 − "cout_mensuel": row["cout_mensuel"], "cache": True}
193 −
194 − if not solve:
195 − con.close()
196 − return {"disponible": False, "raison": "non calculé", "en_attente": True}
268 + "cout_mensuel": row["cout_mensuel"],
269 + "kwh_annuel": row["kwh"], "cache": True}
197 270
198 271 if not os.environ.get("HQ_CAPTCHA_KEY"):
199 272 con.close()
@@ -228,8 +301,11 @@ def estimate(civic: str | None = None, postal: str | None = None,
228 301 con.close()
229 302 return {"disponible": False, "raison": "montant introuvable"}
230 303 with con:
231 − con.execute("INSERT OR REPLACE INTO hydro_cache VALUES (?,?,?,?,?,?)",
232 − (cle, adr, an, mens, json.dumps(est), time.time()))
304 + con.execute(
305 + "INSERT OR REPLACE INTO hydro_cache "
306 + "(cle, adresse, cout_annuel, cout_mensuel, payload, fetched_at, "
307 + "uid, kwh) VALUES (?,?,?,?,?,?,?,?)",
308 + (cle, adr, an, mens, json.dumps(est), time.time(), uid, kwh))
233 309 con.close()
234 310 return {"disponible": True, "adresse": adr, "cout_annuel": an,
235 311 "cout_mensuel": mens, "kwh_annuel": kwh, "cache": False}
@@ -257,7 +333,7 @@ def precompute(limit: int = 100, budget: int = 40) -> dict:
257 333 src = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
258 334 src.row_factory = sqlite3.Row
259 335 rows = src.execute(
260 − "SELECT uid, address FROM listings "
336 + "SELECT uid, address, lat, lng FROM listings "
261 337 "WHERE address IS NOT NULL AND address != '' "
262 338 "ORDER BY first_seen DESC LIMIT ?", (limit,)).fetchall()
263 339 src.close()
@@ -265,22 +341,22 @@ def precompute(limit: int = 100, budget: int = 40) -> dict:
265 341 con = _connect()
266 342 fait = calcules = saut = echec = 0
267 343 for r in rows:
268 − pa = _parse_addr(r["address"])
269 − if not pa:
270 − saut += 1
271 − continue
272 − civic, postal = pa
273 − cache_row = con.execute(
274 − "SELECT 1 FROM hydro_cache WHERE cle=? AND fetched_at>?",
275 − (f"{civic}|{postal.upper().replace(' ', '')}",
276 − time.time() - TTL)).fetchone()
277 − if cache_row:
344 + # déjà en cache pour cet uid ? (aucun coût)
345 + cached = con.execute(
346 + "SELECT 1 FROM hydro_cache WHERE uid=? AND cout_annuel IS NOT NULL "
347 + "AND fetched_at>?", (r["uid"], time.time() - TTL)).fetchone()
348 + if cached:
278 349 fait += 1
279 350 continue
351 + civic = _civic(r["address"])
352 + if not civic or (r["lat"] is None and not _parse_addr(r["address"])):
353 + saut += 1
354 + continue
280 355 if calcules >= budget:
281 356 break
282 357 con.close() # estimate() ouvre sa propre connexion
283 − res = estimate(civic=civic, postal=postal)
358 + res = estimate(adresse=r["address"], lat=r["lat"], lng=r["lng"],
359 + uid=r["uid"])
284 360 con = _connect()
285 361 calcules += 1
286 362 if not res.get("disponible"):
modified louka/web.py +7 −3
@@ -543,11 +543,15 @@ def fairvalue_detail(uid: str):
543 543
544 544
545 545 @app.get("/api/hydro")
546 −def hydro_at(adresse: str, solve: int = 0):
546 +def hydro_at(adresse: str, solve: int = 0, uid: str | None = None,
547 + lat: float | None = None, lng: float | None = None):
547 548 """Estimation du coût d'électricité à une adresse (Hydro-Québec).
548 − Par défaut cache seulement (aucun captcha) ; solve=1 force le calcul."""
549 + Par défaut cache seulement (aucun captcha) ; solve=1 force le calcul.
550 + uid/lat/lng permettent la lecture cache par annonce et la dérivation du
551 + code postal (adresses sans CP)."""
549 552 from . import hydro
550 − return hydro.estimate(adresse=adresse, solve=bool(solve))
553 + return hydro.estimate(adresse=adresse, uid=uid, lat=lat, lng=lng,
554 + solve=bool(solve))
551 555
552 556
553 557 @app.get("/api/commerces")
554 558