SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

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

Idem lou-ka : les adresses Immo-Ka sans code postal obtiennent leur CP par
géocodage inverse Mapbox du lat/lng ; cache par uid pour affichage cache-only ;
precompute + auto sur nouvelles annonces. Testé : 4 adresses MTL estimées.

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

5 changed files +205 −36

modified frontend/src/api.ts +13 −3
@@ -369,9 +369,19 @@ export const fetchCommerces = (lat: number, lng: number) =>
369 369
370 370 export interface HydroEstimate {
371 371 disponible: boolean; raison?: string; adresse?: string;
372 − cout_annuel?: number; cout_mensuel?: number; kwh_annuel?: number; cache?: boolean;
372 + cout_annuel?: number; cout_mensuel?: number; kwh_annuel?: number; cache?: boolean; en_attente?: boolean;
373 373 }
374 374
375 375 /** Coût d'électricité estimé à l'adresse (outil public Hydro-Québec). */
376 −export const fetchHydro = (adresse: string) =>
377 − get<HydroEstimate>(`/api/hydro?adresse=${encodeURIComponent(adresse)}`);
376 +export const fetchHydro = (
377 + adresse: string,
378 + opts: { uid?: string; lat?: number | null; lng?: number | null } = {},
379 + solve = false,
380 +) => {
381 + const q = new URLSearchParams({ adresse });
382 + if (opts.uid) q.set("uid", opts.uid);
383 + if (opts.lat != null) q.set("lat", String(opts.lat));
384 + if (opts.lng != null) q.set("lng", String(opts.lng));
385 + if (solve) q.set("solve", "1");
386 + return get<HydroEstimate>(`/api/hydro?${q.toString()}`);
387 +};
modified frontend/src/components/HydroEstimation.tsx +32 −19
@@ -2,26 +2,41 @@
2 2 // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 // components/HydroEstimation.tsx : bloc « Coût d'électricité » (fiche)
5 −// Estimation du coût annuel d'électricité à l'adresse, via l'outil public
6 −// d'Hydro-Québec (louka/hydro.py). À la demande (bouton) : chaque appel
7 −// déclenche une résolution de captcha côté serveur, donc on n'estime que
8 −// sur clic. Le bloc reste masqué si le service n'est pas configuré.
5 +// Estimation Hydro-Québec du coût annuel d'électricité à l'adresse
6 +// (louka/hydro.py). Les annonces récentes sont pré-calculées et mises en
7 +// cache : le bloc s'affiche alors automatiquement. Sinon un bouton permet
8 +// de lancer le calcul à la demande (une résolution de captcha par appel).
9 9 // -----------------------------------------------------------------------------
10 −import { useState } from "react";
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);
18 − if (masque || !adresse) return null;
19 +
20 + // au montage : lecture du cache uniquement (aucun captcha, aucun coût)
21 + useEffect(() => {
22 + setD(null);
23 + if (!adresse) return;
24 + fetchHydro(adresse, { uid, lat, lng }, false)
25 + .then((r) => {
26 + if (r.disponible || r.en_attente) setD(r);
27 + else if (/captcha|configuré|incomplète/.test(r.raison || ""))
28 + setMasque(true); // service off ou adresse inexploitable
29 + else setD(r);
30 + })
31 + .catch(() => setMasque(true));
32 + }, [adresse, uid, lat, lng]);
33 +
34 + if (masque || !adresse || !d) return null;
19 35
20 36 const lancer = () => {
21 37 setLoading(true);
22 − fetchHydro(adresse)
38 + fetchHydro(adresse, { uid, lat, lng }, true)
23 39 .then((r) => {
24 − // service non configuré : on masque le bloc plutôt qu'afficher une erreur
25 40 if (!r.disponible && /captcha|configuré/.test(r.raison || ""))
26 41 setMasque(true);
27 42 else setD(r);
@@ -33,13 +48,11 @@ export default function HydroEstimation({ adresse }:
33 48 return (
34 49 <section className="f-bloc f-hydro" id="hydro">
35 50 <h2>Coût d'électricité</h2>
36 − {d?.disponible ? (
51 + {d.disponible ? (
37 52 <>
38 53 <div className="hydro-montant">
39 54 {fmtPrice(d.cout_mensuel!)} <small>/ mois</small>
40 − <span className="hydro-an">
41 − soit ~{fmtPrice(d.cout_annuel!)} / an
42 − </span>
55 + <span className="hydro-an">soit ~{fmtPrice(d.cout_annuel!)} / an</span>
43 56 </div>
44 57 <p className="fine">
45 58 Estimation Hydro-Québec pour {d.adresse}
@@ -48,11 +61,7 @@ export default function HydroEstimation({ adresse }:
48 61 varie selon l'occupation et les habitudes.
49 62 </p>
50 63 </>
51 − ) : d && !d.disponible ? (
52 − <p className="hydro-vide">
53 − Hydro-Québec n'a pas d'estimation pour cette adresse.
54 − </p>
55 − ) : (
64 + ) : d.en_attente ? (
56 65 <>
57 66 <p className="hydro-intro">
58 67 Obtenez une estimation du coût annuel d'électricité pour ce
@@ -62,6 +71,10 @@ export default function HydroEstimation({ adresse }:
62 71 {loading ? "Estimation en cours…" : "Estimer le coût d'électricité"}
63 72 </button>
64 73 </>
74 + ) : (
75 + <p className="hydro-vide">
76 + Hydro-Québec n'a pas d'estimation pour cette adresse.
77 + </p>
65 78 )}
66 79 </section>
67 80 );
modified frontend/src/pages/Listing.tsx +2 −0
@@ -464,6 +464,8 @@ export default function ListingPage() {
464 464
465 465 <EssenceProche lat={l.lat} lng={l.lng} />
466 466
467 + <HydroEstimation adresse={l.address || l.title} uid={l.uid} lat={l.lat} lng={l.lng} />
468 +
467 469 {l.quartier && <QuartierBlock q={l.quartier} />}
468 470
469 471 <div className="fine f-foot">
modified immoka/hydro.py +150 −11
@@ -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,15 +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) -> 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 − Renvoie {"disponible": bool, ...}. disponible=False si le solveur de
172 − captcha n'est pas configuré ou si Hydro-Québec ne connaît pas l'adresse.
217 + `solve=False` : ne consulte QUE le cache (aucune résolution de captcha,
218 + aucun coût) — utilisé pour l'affichage automatique des fiches.
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.
173 224 """
174 − if not civic or not postal:
175 − if adresse and (pa := _parse_addr(adresse)):
176 − 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
177 254 if not civic or not postal:
178 255 return {"disponible": False, "raison": "adresse incomplète"}
179 256 postal = postal.upper().replace(" ", "")
@@ -188,7 +265,8 @@ def estimate(civic: str | None = None, postal: str | None = None,
188 265 con.close()
189 266 return {"disponible": True, "adresse": row["adresse"],
190 267 "cout_annuel": row["cout_annuel"],
191 − "cout_mensuel": row["cout_mensuel"], "cache": True}
268 + "cout_mensuel": row["cout_mensuel"],
269 + "kwh_annuel": row["kwh"], "cache": True}
192 270
193 271 if not os.environ.get("HQ_CAPTCHA_KEY"):
194 272 con.close()
@@ -223,8 +301,69 @@ def estimate(civic: str | None = None, postal: str | None = None,
223 301 con.close()
224 302 return {"disponible": False, "raison": "montant introuvable"}
225 303 with con:
226 − con.execute("INSERT OR REPLACE INTO hydro_cache VALUES (?,?,?,?,?,?)",
227 − (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))
228 309 con.close()
229 310 return {"disponible": True, "adresse": adr, "cout_annuel": an,
230 311 "cout_mensuel": mens, "kwh_annuel": kwh, "cache": False}
312 +
313 +
314 +def _main_db() -> Path | None:
315 + d = DB_PATH.parent
316 + for n in ("louka.db", "immoka.db", "immo.db"):
317 + if (d / n).exists():
318 + return d / n
319 + return None
320 +
321 +
322 +def precompute(limit: int = 100, budget: int = 40) -> dict:
323 + """Pré-calcule l'estimation Hydro des `limit` annonces les plus récentes
324 + non encore en cache. `budget` = nb max de résolutions captcha par appel
325 + (borne le coût ; le reliquat sera comblé aux cycles suivants).
326 +
327 + Appelé au démarrage (100 dernières) et à chaque cycle de `watch` pour les
328 + nouvelles annonces — le cache persiste entre les releases (data/hydro.db).
329 + """
330 + db = _main_db()
331 + if db is None:
332 + return {"erreur": "base des annonces introuvable"}
333 + src = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
334 + src.row_factory = sqlite3.Row
335 + rows = src.execute(
336 + "SELECT uid, address, lat, lng FROM listings "
337 + "WHERE address IS NOT NULL AND address != '' "
338 + "ORDER BY first_seen DESC LIMIT ?", (limit,)).fetchall()
339 + src.close()
340 +
341 + con = _connect()
342 + fait = calcules = saut = echec = 0
343 + for r in rows:
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:
349 + fait += 1
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
355 + if calcules >= budget:
356 + break
357 + con.close() # estimate() ouvre sa propre connexion
358 + res = estimate(adresse=r["address"], lat=r["lat"], lng=r["lng"],
359 + uid=r["uid"])
360 + con = _connect()
361 + calcules += 1
362 + if not res.get("disponible"):
363 + echec += 1
364 + con.close()
365 + stats = {"vises": len(rows), "deja_en_cache": fait,
366 + "nouveaux_calcules": calcules, "sans_adresse": saut,
367 + "echecs": echec, "budget": budget}
368 + print(f"[hydro] précalcul : {stats}")
369 + return stats
modified immoka/web.py +8 −3
@@ -134,10 +134,15 @@ def list_listings(
134 134
135 135
136 136 @app.get("/api/hydro")
137 −def hydro_at(adresse: str):
138 − """Estimation du coût d'électricité à une adresse (Hydro-Québec)."""
137 +def hydro_at(adresse: str, solve: int = 0, uid: str | None = None,
138 + lat: float | None = None, lng: float | None = None):
139 + """Estimation du coût d'électricité à une adresse (Hydro-Québec).
140 + Par défaut cache seulement (aucun captcha) ; solve=1 force le calcul.
141 + uid/lat/lng permettent la lecture cache par annonce et la dérivation du
142 + code postal (adresses sans CP)."""
139 143 from . import hydro
140 − return hydro.estimate(adresse=adresse)
144 + return hydro.estimate(adresse=adresse, uid=uid, lat=lat, lng=lng,
145 + solve=bool(solve))
141 146
142 147
143 148 @app.get("/api/commerces")
144 149