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>
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 | |