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%

hydro : pré-calcul des 100 annonces récentes + auto pour les nouvelles

- hydro.precompute(limit, budget) : calcule l estimation des annonces les plus
  récentes non encore en cache, plafond de résolutions captcha par appel
  (coût borné) ; run.py hydro-precompute [N] [budget]
- watch : precompute(100, budget=30) à chaque cycle -> nouvelles annonces
  estimées automatiquement ; le cache data/hydro.db persiste entre releases
- estimate(solve=False) = lecture cache seule (aucun captcha) ;
  GET /api/hydro cache-only par défaut, solve=1 pour forcer
- fiche : le bloc s affiche AUTOMATIQUEMENT quand l estimation est en cache,
  sinon bouton de calcul à la demande

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

6 changed files +112 −26

modified frontend/src/api.ts +3 −3
@@ -640,9 +640,9 @@ export const fetchCommerces = (lat: number, lng: number) =>
640 640
641 641 export interface HydroEstimate {
642 642 disponible: boolean; raison?: string; adresse?: string;
643 − cout_annuel?: number; cout_mensuel?: number; kwh_annuel?: number; cache?: boolean;
643 + cout_annuel?: number; cout_mensuel?: number; kwh_annuel?: number; cache?: boolean; en_attente?: boolean;
644 644 }
645 645
646 646 /** Coût d'électricité estimé à l'adresse (outil public Hydro-Québec). */
647 −export const fetchHydro = (adresse: string) =>
648 − get<HydroEstimate>(`/api/hydro?adresse=${encodeURIComponent(adresse)}`);
647 +export const fetchHydro = (adresse: string, solve = false) =>
648 + get<HydroEstimate>(`/api/hydro?adresse=${encodeURIComponent(adresse)}` + (solve ? "&solve=1" : ""));
modified frontend/src/components/HydroEstimation.tsx +29 −17
@@ -2,12 +2,12 @@
2 2 // Lou-Ka — Agrégateur de logements à louer (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 13 export default function HydroEstimation({ adresse }:
@@ -15,13 +15,27 @@ export default function HydroEstimation({ adresse }:
15 15 const [d, setD] = useState<HydroEstimate | null>(null);
16 16 const [loading, setLoading] = useState(false);
17 17 const [masque, setMasque] = useState(false);
18 − if (masque || !adresse) return null;
18 +
19 + // au montage : lecture du cache uniquement (aucun captcha, aucun coût)
20 + useEffect(() => {
21 + setD(null);
22 + if (!adresse) return;
23 + fetchHydro(adresse, false)
24 + .then((r) => {
25 + if (r.disponible || r.en_attente) setD(r);
26 + else if (/captcha|configuré|incomplète/.test(r.raison || ""))
27 + setMasque(true); // service off ou adresse inexploitable
28 + else setD(r);
29 + })
30 + .catch(() => setMasque(true));
31 + }, [adresse]);
32 +
33 + if (masque || !adresse || !d) return null;
19 34
20 35 const lancer = () => {
21 36 setLoading(true);
22 − fetchHydro(adresse)
37 + fetchHydro(adresse, true)
23 38 .then((r) => {
24 − // service non configuré : on masque le bloc plutôt qu'afficher une erreur
25 39 if (!r.disponible && /captcha|configuré/.test(r.raison || ""))
26 40 setMasque(true);
27 41 else setD(r);
@@ -33,13 +47,11 @@ export default function HydroEstimation({ adresse }:
33 47 return (
34 48 <section className="f-bloc f-hydro" id="hydro">
35 49 <h2>Coût d'électricité</h2>
36 − {d?.disponible ? (
50 + {d.disponible ? (
37 51 <>
38 52 <div className="hydro-montant">
39 53 {fmtPrice(d.cout_mensuel!)} <small>/ mois</small>
40 − <span className="hydro-an">
41 − soit ~{fmtPrice(d.cout_annuel!)} / an
42 − </span>
54 + <span className="hydro-an">soit ~{fmtPrice(d.cout_annuel!)} / an</span>
43 55 </div>
44 56 <p className="fine">
45 57 Estimation Hydro-Québec pour {d.adresse}
@@ -48,11 +60,7 @@ export default function HydroEstimation({ adresse }:
48 60 varie selon l'occupation et les habitudes.
49 61 </p>
50 62 </>
51 − ) : d && !d.disponible ? (
52 − <p className="hydro-vide">
53 − Hydro-Québec n'a pas d'estimation pour cette adresse.
54 − </p>
55 − ) : (
63 + ) : d.en_attente ? (
56 64 <>
57 65 <p className="hydro-intro">
58 66 Obtenez une estimation du coût annuel d'électricité pour ce
@@ -62,6 +70,10 @@ export default function HydroEstimation({ adresse }:
62 70 {loading ? "Estimation en cours…" : "Estimer le coût d'électricité"}
63 71 </button>
64 72 </>
73 + ) : (
74 + <p className="hydro-vide">
75 + Hydro-Québec n'a pas d'estimation pour cette adresse.
76 + </p>
65 77 )}
66 78 </section>
67 79 );
modified louka/hydro.py +66 −3
@@ -165,11 +165,12 @@ def _montant(d: dict) -> tuple[float | None, float | None, int | None]:
165 165
166 166
167 167 def estimate(civic: str | None = None, postal: str | None = None,
168 − adresse: str | None = None) -> dict:
168 + adresse: str | None = None, solve: bool = True) -> dict:
169 169 """Coût d'électricité annuel estimé à une adresse (cache 6 mois).
170 170
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.
171 + `solve=False` : ne consulte QUE le cache (aucune résolution de captcha,
172 + aucun coût) — utilisé pour l'affichage automatique des fiches.
173 + Renvoie {"disponible": bool, ...}.
173 174 """
174 175 if not civic or not postal:
175 176 if adresse and (pa := _parse_addr(adresse)):
@@ -190,6 +191,10 @@ def estimate(civic: str | None = None, postal: str | None = None,
190 191 "cout_annuel": row["cout_annuel"],
191 192 "cout_mensuel": row["cout_mensuel"], "cache": True}
192 193
194 + if not solve:
195 + con.close()
196 + return {"disponible": False, "raison": "non calculé", "en_attente": True}
197 +
193 198 if not os.environ.get("HQ_CAPTCHA_KEY"):
194 199 con.close()
195 200 return {"disponible": False, "raison": "solveur captcha non configuré"}
@@ -228,3 +233,61 @@ def estimate(civic: str | None = None, postal: str | None = None,
228 233 con.close()
229 234 return {"disponible": True, "adresse": adr, "cout_annuel": an,
230 235 "cout_mensuel": mens, "kwh_annuel": kwh, "cache": False}
236 +
237 +
238 +def _main_db() -> Path | None:
239 + d = DB_PATH.parent
240 + for n in ("louka.db", "immoka.db", "immo.db"):
241 + if (d / n).exists():
242 + return d / n
243 + return None
244 +
245 +
246 +def precompute(limit: int = 100, budget: int = 40) -> dict:
247 + """Pré-calcule l'estimation Hydro des `limit` annonces les plus récentes
248 + non encore en cache. `budget` = nb max de résolutions captcha par appel
249 + (borne le coût ; le reliquat sera comblé aux cycles suivants).
250 +
251 + Appelé au démarrage (100 dernières) et à chaque cycle de `watch` pour les
252 + nouvelles annonces — le cache persiste entre les releases (data/hydro.db).
253 + """
254 + db = _main_db()
255 + if db is None:
256 + return {"erreur": "base des annonces introuvable"}
257 + src = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
258 + src.row_factory = sqlite3.Row
259 + rows = src.execute(
260 + "SELECT uid, address FROM listings "
261 + "WHERE address IS NOT NULL AND address != '' "
262 + "ORDER BY first_seen DESC LIMIT ?", (limit,)).fetchall()
263 + src.close()
264 +
265 + con = _connect()
266 + fait = calcules = saut = echec = 0
267 + 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:
278 + fait += 1
279 + continue
280 + if calcules >= budget:
281 + break
282 + con.close() # estimate() ouvre sa propre connexion
283 + res = estimate(civic=civic, postal=postal)
284 + con = _connect()
285 + calcules += 1
286 + if not res.get("disponible"):
287 + echec += 1
288 + con.close()
289 + stats = {"vises": len(rows), "deja_en_cache": fait,
290 + "nouveaux_calcules": calcules, "sans_adresse": saut,
291 + "echecs": echec, "budget": budget}
292 + print(f"[hydro] précalcul : {stats}")
293 + return stats
modified louka/ingest.py +5 −0
@@ -137,6 +137,11 @@ def watch(interval_seconds: int = 3600) -> None:
137 137 print(f"[lou-ka] kascores: {stats_ks}")
138 138 except Exception as exc:
139 139 print(f"[lou-ka] kascores: erreur non bloquante: {exc}", file=sys.stderr)
140 + try: # estimation Hydro-Québec des nouvelles annonces (cache persistant, coût borné)
141 + from . import hydro
142 + hydro.precompute(limit=100, budget=30)
143 + except Exception as exc:
144 + print(f"[lou-ka] hydro: erreur non bloquante: {exc}", file=sys.stderr)
140 145 print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s")
141 146 time.sleep(interval_seconds)
142 147
modified louka/web.py +4 −3
@@ -543,10 +543,11 @@ def fairvalue_detail(uid: str):
543 543
544 544
545 545 @app.get("/api/hydro")
546 −def hydro_at(adresse: str):
547 − """Estimation du coût d'électricité à une adresse (Hydro-Québec)."""
546 +def hydro_at(adresse: str, solve: int = 0):
547 + """Estimation du coût d'électricité à une adresse (Hydro-Québec).
548 + Par défaut cache seulement (aucun captcha) ; solve=1 force le calcul."""
548 549 from . import hydro
549 − return hydro.estimate(adresse=adresse)
550 + return hydro.estimate(adresse=adresse, solve=bool(solve))
550 551
551 552
552 553 @app.get("/api/commerces")
modified run.py +5 −0
@@ -102,6 +102,11 @@ def main() -> None:
102 102 elif cmd == "gaz-refresh":
103 103 from louka import gaz
104 104 print(f"[gaz] {gaz.refresh()} stations")
105 + elif cmd == "hydro-precompute":
106 + from louka import hydro
107 + n = int(sys.argv[2]) if len(sys.argv) > 2 else 100
108 + b = int(sys.argv[3]) if len(sys.argv) > 3 else 40
109 + hydro.precompute(limit=n, budget=b)
105 110 elif cmd == "rdl":
106 111 from louka import rdl
107 112 rdl.refresh()
108 113