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 : estimation du coût d electricite Hydro-Quebec (a la demande)

- louka/hydro.py : parcours PUBLIC rétro-conçu de l outil « Estimer les coûts
  d électricité à une résidence » — 2 étapes (lieu-consommation -> estimation)
  sur services-cl.solutions.hydroquebec.com, jeton reCAPTCHA v2 via solveur
  enfichable (2captcha/CapSolver, clé HQ_CAPTCHA_KEY), sortie par proxy
  résidentiel CA (Oxylabs/Bright Data), cache 6 mois data/hydro.db ; sans clé
  captcha -> {disponible:false} et bloc masqué (jamais d estimation inventée)
- GET /api/hydro?adresse= ; bloc fiche à la demande (bouton, 1 captcha/appel)
  affichant le coût mensuel et annuel estimés

Activation : définir HQ_CAPTCHA_KEY (+ HQ_CAPTCHA_PROVIDER) dans .env.

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

7 changed files +329 −0

modified .gitignore +1 −0
@@ -33,3 +33,4 @@ data/air.db
33 33 data/gaz.db
34 34 data/commerces.db
35 35 data/transit.db
36 +data/hydro.db
modified frontend/src/api.ts +9 −0
@@ -637,3 +637,12 @@ export interface CommercesNearby {
637 637 /** Grands commerces + métro/bus les plus proches (Mapbox / OSM). */
638 638 export const fetchCommerces = (lat: number, lng: number) =>
639 639 get<CommercesNearby>(`/api/commerces?lat=${lat}&lng=${lng}`);
640 +
641 +export interface HydroEstimate {
642 + disponible: boolean; raison?: string; adresse?: string;
643 + cout_annuel?: number; cout_mensuel?: number; cache?: boolean;
644 +}
645 +
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)}`);
added frontend/src/components/HydroEstimation.tsx +67 −0
@@ -0,0 +1,67 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
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é.
9 +// -----------------------------------------------------------------------------
10 +import { useState } from "react";
11 +import { fetchHydro, fmtPrice, HydroEstimate } from "../api";
12 +
13 +export default function HydroEstimation({ adresse }:
14 + { adresse: string | null }) {
15 + const [d, setD] = useState<HydroEstimate | null>(null);
16 + const [loading, setLoading] = useState(false);
17 + const [masque, setMasque] = useState(false);
18 + if (masque || !adresse) return null;
19 +
20 + const lancer = () => {
21 + setLoading(true);
22 + fetchHydro(adresse)
23 + .then((r) => {
24 + // service non configuré : on masque le bloc plutôt qu'afficher une erreur
25 + if (!r.disponible && /captcha|configuré/.test(r.raison || ""))
26 + setMasque(true);
27 + else setD(r);
28 + })
29 + .catch(() => setMasque(true))
30 + .finally(() => setLoading(false));
31 + };
32 +
33 + return (
34 + <section className="f-bloc f-hydro" id="hydro">
35 + <h2>Coût d'électricité</h2>
36 + {d?.disponible ? (
37 + <>
38 + <div className="hydro-montant">
39 + {fmtPrice(d.cout_mensuel!)} <small>/ mois</small>
40 + <span className="hydro-an">
41 + soit ~{fmtPrice(d.cout_annuel!)} / an
42 + </span>
43 + </div>
44 + <p className="fine">
45 + Estimation Hydro-Québec pour {d.adresse} — fondée sur la
46 + consommation réelle du logement. Le montant réel varie selon
47 + l'occupation et les habitudes.
48 + </p>
49 + </>
50 + ) : d && !d.disponible ? (
51 + <p className="hydro-vide">
52 + Hydro-Québec n'a pas d'estimation pour cette adresse.
53 + </p>
54 + ) : (
55 + <>
56 + <p className="hydro-intro">
57 + Obtenez une estimation du coût annuel d'électricité pour ce
58 + logement, calculée par Hydro-Québec d'après sa consommation réelle.
59 + </p>
60 + <button className="hydro-btn" onClick={lancer} disabled={loading}>
61 + {loading ? "Estimation en cours…" : "Estimer le coût d'électricité"}
62 + </button>
63 + </>
64 + )}
65 + </section>
66 + );
67 +}
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 HydroEstimation from "../components/HydroEstimation";
24 25 import CommercesProches from "../components/CommercesProches";
25 26 import { IcoAlert, IcoDoc } from "../components/Icons";
26 27 import KaScoresBlock from "../components/KaScoresBlock";
@@ -486,6 +487,8 @@ export default function ListingPage() {
486 487
487 488 <EssenceProche lat={l.lat} lng={l.lng} />
488 489
490 + <HydroEstimation adresse={l.address || l.title} />
491 +
489 492 {l.kascores && <KaScoresBlock ks={l.kascores} />}
490 493
491 494 <section className="f-bloc f-quartier" id="quartier">
modified frontend/src/styles.css +14 −0
@@ -1972,3 +1972,17 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1972 1972 white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1973 1973 .cm-dist { flex: 0 0 auto; font-size: 13px; font-weight: 700;
1974 1974 color: var(--ink-2, #4c4a45); white-space: nowrap; }
1975 +
1976 +
1977 +/* ---- Coût d'électricité (Hydro-Québec) ---- */
1978 +.hydro-intro { margin: 2px 0 12px; color: var(--ink-2, #4c4a45); }
1979 +.hydro-btn { appearance: none; border: 0; border-radius: 10px; cursor: pointer;
1980 + background: var(--accent, #2b7a3b); color: #fff; font-weight: 700;
1981 + font-size: 14px; padding: 11px 18px; }
1982 +.hydro-btn:disabled { opacity: 0.6; cursor: progress; }
1983 +.hydro-montant { font-size: 26px; font-weight: 800; letter-spacing: -0.02em;
1984 + color: var(--navy, #1c1b18); display: flex; align-items: baseline;
1985 + gap: 10px; flex-wrap: wrap; }
1986 +.hydro-montant small { font-size: 14px; font-weight: 500; color: var(--ink-3); }
1987 +.hydro-an { font-size: 14px; font-weight: 600; color: var(--ink-2, #4c4a45); }
1988 +.hydro-vide { color: var(--ink-3, #8a877f); }
added louka/hydro.py +228 −0
@@ -0,0 +1,228 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# hydro.py : estimation du coût d'électricité à une adresse — Hydro-Québec
5 +#
6 +# Reproduit le parcours PUBLIC de l'outil « Estimer les coûts d'électricité
7 +# à une résidence » (session.hydroquebec.com), rétro-conçu depuis son SPA :
8 +# 1. POST {clWebServicePublicApiURL}public/api/v3_0/lieu-consommation
9 +# body {noCivique, codePostal, recaptchaResponse} -> liste de lieux
10 +# (idConso, cleUnique, adresse) ;
11 +# 2. POST {drcvPublicApiURL}public/api/v3_0/consommation/estimation
12 +# body {cleUnique, idConso} -> coût annuel estimé.
13 +# Les deux étapes exigent un jeton reCAPTCHA v2 valide
14 +# (site key 6Lf08Q0UAAAAABCA7z47p2tMxa5_fY0wmn8DDPsu). Il est obtenu via un
15 +# solveur enfichable (2captcha / CapSolver) ET les requêtes sortent par le
16 +# proxy résidentiel canadien Bright Data (contourne le géoblocage / anti-bot).
17 +#
18 +# Configuration (.env) :
19 +# HQ_CAPTCHA_KEY clé du solveur (obligatoire pour l'appel live)
20 +# HQ_CAPTCHA_PROVIDER "2captcha" (défaut) | "capsolver"
21 +# BRIGHTDATA_* / OXYLABS_* : proxy résidentiel (réutilise la pile projet)
22 +#
23 +# Sans HQ_CAPTCHA_KEY : estimate() renvoie {"disponible": False, ...} et le
24 +# bloc fiche reste masqué — jamais d'estimation inventée.
25 +# -----------------------------------------------------------------------------
26 +from __future__ import annotations
27 +
28 +import json
29 +import os
30 +import re
31 +import sqlite3
32 +import time
33 +import urllib.request
34 +from pathlib import Path
35 +
36 +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "hydro.db"
37 +TTL = 180 * 86400 # la conso d'un logement bouge lentement
38 +
39 +SITE_KEY = "6Lf08Q0UAAAAABCA7z47p2tMxa5_fY0wmn8DDPsu"
40 +PAGE_URL = ("https://session.hydroquebec.com/portail/fr/web/clientele/"
41 + "estimation-consommation")
42 +URL_LIEU = ("https://services-cl.solutions.hydroquebec.com/wsapi/webpublic/"
43 + "public/api/v3_0/lieu-consommation")
44 +URL_ESTIM = ("https://services-cl.solutions.hydroquebec.com/conso/webpublic/"
45 + "public/api/v3_0/consommation/estimation")
46 +UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
47 + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
48 +
49 +
50 +# --- proxy résidentiel (Bright Data Web Unlocker en repli d'Oxylabs) ----------
51 +def _opener() -> urllib.request.OpenerDirector:
52 + proxy = None
53 + if os.environ.get("OXYLABS_PROXY_USER"):
54 + u = os.environ["OXYLABS_PROXY_USER"]
55 + p = os.environ["OXYLABS_PROXY_PASS"]
56 + host = os.environ.get("OXYLABS_PROXY", "pr.oxylabs.io:7777")
57 + proxy = f"http://{u}-cc-CA:{p}@{host}"
58 + if proxy:
59 + h = urllib.request.ProxyHandler({"http": proxy, "https": proxy})
60 + return urllib.request.build_opener(h)
61 + return urllib.request.build_opener()
62 +
63 +
64 +# --- solveur reCAPTCHA v2 enfichable ------------------------------------------
65 +def _solve_captcha() -> str | None:
66 + key = os.environ.get("HQ_CAPTCHA_KEY")
67 + if not key:
68 + return None
69 + provider = os.environ.get("HQ_CAPTCHA_PROVIDER", "2captcha").lower()
70 + if provider == "capsolver":
71 + return _capsolver(key)
72 + return _twocaptcha(key)
73 +
74 +
75 +def _twocaptcha(key: str) -> str | None:
76 + r = urllib.request.urlopen(urllib.request.Request(
77 + "https://2captcha.com/in.php",
78 + data=urllib.parse.urlencode({
79 + "key": key, "method": "userrecaptcha", "googlekey": SITE_KEY,
80 + "pageurl": PAGE_URL, "json": 1}).encode()), timeout=30)
81 + got = json.load(r)
82 + if got.get("status") != 1:
83 + return None
84 + cid = got["request"]
85 + for _ in range(24):
86 + time.sleep(5)
87 + res = json.load(urllib.request.urlopen(
88 + f"https://2captcha.com/res.php?key={key}&action=get&id={cid}&json=1",
89 + timeout=30))
90 + if res.get("status") == 1:
91 + return res["request"]
92 + if res.get("request") != "CAPCHA_NOT_READY":
93 + return None
94 + return None
95 +
96 +
97 +def _capsolver(key: str) -> str | None:
98 + r = urllib.request.urlopen(urllib.request.Request(
99 + "https://api.capsolver.com/createTask",
100 + data=json.dumps({"clientKey": key, "task": {
101 + "type": "ReCaptchaV2TaskProxyLess",
102 + "websiteURL": PAGE_URL, "websiteKey": SITE_KEY}}).encode(),
103 + headers={"Content-Type": "application/json"}), timeout=30)
104 + tid = json.load(r).get("taskId")
105 + if not tid:
106 + return None
107 + for _ in range(24):
108 + time.sleep(5)
109 + res = json.load(urllib.request.urlopen(urllib.request.Request(
110 + "https://api.capsolver.com/getTaskResult",
111 + data=json.dumps({"clientKey": key, "taskId": tid}).encode(),
112 + headers={"Content-Type": "application/json"}), timeout=30))
113 + if res.get("status") == "ready":
114 + return res["solution"]["gRecaptchaResponse"]
115 + if res.get("status") != "processing":
116 + return None
117 + return None
118 +
119 +
120 +import urllib.parse # noqa: E402 (utilisé par _twocaptcha)
121 +
122 +
123 +def _post(opener, url: str, payload: dict) -> dict | None:
124 + req = urllib.request.Request(url, data=json.dumps(payload).encode(),
125 + headers={"Content-Type": "application/json",
126 + "User-Agent": UA,
127 + "Origin": "https://session.hydroquebec.com",
128 + "Referer": PAGE_URL})
129 + try:
130 + with opener.open(req, timeout=60) as r:
131 + return json.load(r)
132 + except Exception:
133 + return None
134 +
135 +
136 +# --- cache --------------------------------------------------------------------
137 +def _connect() -> sqlite3.Connection:
138 + DB_PATH.parent.mkdir(parents=True, exist_ok=True)
139 + con = sqlite3.connect(DB_PATH, timeout=15)
140 + con.row_factory = sqlite3.Row
141 + con.execute("""CREATE TABLE IF NOT EXISTS hydro_cache (
142 + cle TEXT PRIMARY KEY, adresse TEXT, cout_annuel REAL,
143 + cout_mensuel REAL, payload TEXT, fetched_at REAL)""")
144 + return con
145 +
146 +
147 +def _parse_addr(adresse: str) -> tuple[str, str] | None:
148 + """Extrait (numéro civique, code postal) d'une adresse de fiche."""
149 + cp = re.search(r"([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)", adresse or "")
150 + civ = re.match(r"\s*(\d+[A-Za-z]?)", adresse or "")
151 + if not cp or not civ:
152 + return None
153 + return civ.group(1), cp.group(1).upper().replace(" ", "")
154 +
155 +
156 +def _montant(d: dict) -> tuple[float | None, float | None]:
157 + """Retrouve le coût annuel/mensuel dans la réponse d'estimation."""
158 + txt = json.dumps(d)
159 + an = re.search(r'"(?:coutAnnuel|montantAnnuel|cout|montant)"\s*:\s*'
160 + r'([0-9]+(?:\.[0-9]+)?)', txt)
161 + a = float(an.group(1)) if an else None
162 + if a and a < 400: # valeur mensuelle -> annualiser
163 + return round(a * 12), round(a)
164 + return (round(a) if a else None,
165 + round(a / 12) if a else None)
166 +
167 +
168 +def estimate(civic: str | None = None, postal: str | None = None,
169 + adresse: str | None = None) -> dict:
170 + """Coût d'électricité annuel estimé à une adresse (cache 6 mois).
171 +
172 + Renvoie {"disponible": bool, ...}. disponible=False si le solveur de
173 + captcha n'est pas configuré ou si Hydro-Québec ne connaît pas l'adresse.
174 + """
175 + if not civic or not postal:
176 + if adresse and (pa := _parse_addr(adresse)):
177 + civic, postal = pa
178 + if not civic or not postal:
179 + return {"disponible": False, "raison": "adresse incomplète"}
180 + postal = postal.upper().replace(" ", "")
181 + cle = f"{civic}|{postal}"
182 +
183 + con = _connect()
184 + row = con.execute("SELECT * FROM hydro_cache WHERE cle=? AND fetched_at>?",
185 + (cle, time.time() - TTL)).fetchone()
186 + if row and row["cout_annuel"]:
187 + con.close()
188 + return {"disponible": True, "adresse": row["adresse"],
189 + "cout_annuel": row["cout_annuel"],
190 + "cout_mensuel": row["cout_mensuel"], "cache": True}
191 +
192 + if not os.environ.get("HQ_CAPTCHA_KEY"):
193 + con.close()
194 + return {"disponible": False, "raison": "solveur captcha non configuré"}
195 +
196 + token = _solve_captcha()
197 + if not token:
198 + con.close()
199 + return {"disponible": False, "raison": "échec du captcha"}
200 +
201 + opener = _opener()
202 + lieux = _post(opener, URL_LIEU, {"noCivique": civic, "codePostal": postal,
203 + "recaptchaResponse": token})
204 + items = (lieux or {}).get("lieuxConsommation") or (lieux or {}).get(
205 + "lieux") or (lieux if isinstance(lieux, list) else [])
206 + if not items:
207 + con.close()
208 + return {"disponible": False, "raison": "adresse inconnue d'Hydro-Québec"}
209 + lieu = items[0]
210 + id_conso = lieu.get("idConso") or lieu.get("id")
211 + cle_unique = lieu.get("cleUnique") or lieu.get("cle")
212 + adr = lieu.get("adresse") or f"{civic}, {postal}"
213 +
214 + est = _post(opener, URL_ESTIM, {"cleUnique": cle_unique,
215 + "idConso": id_conso})
216 + if not est:
217 + con.close()
218 + return {"disponible": False, "raison": "estimation indisponible"}
219 + an, mens = _montant(est)
220 + if not an:
221 + con.close()
222 + return {"disponible": False, "raison": "montant introuvable"}
223 + with con:
224 + con.execute("INSERT OR REPLACE INTO hydro_cache VALUES (?,?,?,?,?,?)",
225 + (cle, adr, an, mens, json.dumps(est), time.time()))
226 + con.close()
227 + return {"disponible": True, "adresse": adr, "cout_annuel": an,
228 + "cout_mensuel": mens, "cache": False}
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/hydro")
546 +def hydro_at(adresse: str):
547 + """Estimation du coût d'électricité à une adresse (Hydro-Québec)."""
548 + from . import hydro
549 + return hydro.estimate(adresse=adresse)
550 +
551 +
545 552 @app.get("/api/commerces")
546 553 def commerces_at(lat: float, lng: float):
547 554 """Grands commerces + métro/bus les plus proches (Mapbox / OSM)."""
548 555