Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# hydro.py : estimation du coût d'électricité à une adresse — Hydro-Québec5#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-consommation9# body {noCivique, codePostal, recaptchaResponse} -> liste de lieux10# (idConso, cleUnique, adresse) ;11# 2. POST {drcvPublicApiURL}public/api/v3_0/consommation/estimation12# body {cleUnique, idConso} -> coût annuel estimé.13# Les deux étapes exigent un jeton reCAPTCHA v2 valide14# (site key 6Lf08Q0UAAAAABCA7z47p2tMxa5_fY0wmn8DDPsu). Il est obtenu via un15# solveur enfichable (2captcha / CapSolver) ET les requêtes sortent par le16# 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 le24# bloc fiche reste masqué — jamais d'estimation inventée.25# -----------------------------------------------------------------------------26from __future__ import annotations2728import json29import os30import re31import sqlite332import time33import urllib.parse34import urllib.request35from pathlib import Path3637DB_PATH = Path(__file__).resolve().parent.parent / "data" / "hydro.db"38TTL = 180 * 86400 # succès : la conso bouge lentement39NEG_TTL = 30 * 86400 # échec (adresse inconnue) : re-tenter dans 30 j40 # — borne le coût captcha des cycles watch4142SITE_KEY = "6Lf08Q0UAAAAABCA7z47p2tMxa5_fY0wmn8DDPsu"43PAGE_URL = ("https://session.hydroquebec.com/portail/fr/web/clientele/"44 "estimation-consommation")45URL_LIEU = ("https://services-cl.solutions.hydroquebec.com/wsapi/webpublic/"46 "public/api/v3_0/lieu-consommation")47URL_ESTIM = ("https://services-cl.solutions.hydroquebec.com/conso/webpublic/"48 "public/api/v3_0/consommation/estimation")49UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "50 "(KHTML, like Gecko) Chrome/126.0 Safari/537.36")515253# --- proxy résidentiel (Bright Data Web Unlocker en repli d'Oxylabs) ----------54def _opener() -> urllib.request.OpenerDirector:55 proxy = None56 if os.environ.get("OXYLABS_PROXY_USER"):57 u = os.environ["OXYLABS_PROXY_USER"]58 p = os.environ["OXYLABS_PROXY_PASS"]59 host = os.environ.get("OXYLABS_PROXY", "pr.oxylabs.io:7777")60 proxy = f"http://{u}-cc-CA:{p}@{host}"61 if proxy:62 h = urllib.request.ProxyHandler({"http": proxy, "https": proxy})63 return urllib.request.build_opener(h)64 return urllib.request.build_opener()656667# --- solveur reCAPTCHA v2 enfichable ------------------------------------------68def _solve_captcha() -> str | None:69 key = os.environ.get("HQ_CAPTCHA_KEY")70 if not key:71 return None72 provider = os.environ.get("HQ_CAPTCHA_PROVIDER", "2captcha").lower()73 if provider == "capsolver":74 return _capsolver(key)75 return _twocaptcha(key)767778def _twocaptcha(key: str) -> str | None:79 r = urllib.request.urlopen(urllib.request.Request(80 "https://2captcha.com/in.php",81 data=urllib.parse.urlencode({82 "key": key, "method": "userrecaptcha", "googlekey": SITE_KEY,83 "pageurl": PAGE_URL, "json": 1}).encode()), timeout=30)84 got = json.load(r)85 if got.get("status") != 1:86 return None87 cid = got["request"]88 for _ in range(24):89 time.sleep(5)90 res = json.load(urllib.request.urlopen(91 f"https://2captcha.com/res.php?key={key}&action=get&id={cid}&json=1",92 timeout=30))93 if res.get("status") == 1:94 return res["request"]95 if res.get("request") != "CAPCHA_NOT_READY":96 return None97 return None9899100def _capsolver(key: str) -> str | None:101 r = urllib.request.urlopen(urllib.request.Request(102 "https://api.capsolver.com/createTask",103 data=json.dumps({"clientKey": key, "task": {104 "type": "ReCaptchaV2TaskProxyLess",105 "websiteURL": PAGE_URL, "websiteKey": SITE_KEY}}).encode(),106 headers={"Content-Type": "application/json"}), timeout=30)107 tid = json.load(r).get("taskId")108 if not tid:109 return None110 for _ in range(24):111 time.sleep(5)112 res = json.load(urllib.request.urlopen(urllib.request.Request(113 "https://api.capsolver.com/getTaskResult",114 data=json.dumps({"clientKey": key, "taskId": tid}).encode(),115 headers={"Content-Type": "application/json"}), timeout=30))116 if res.get("status") == "ready":117 return res["solution"]["gRecaptchaResponse"]118 if res.get("status") != "processing":119 return None120 return None121122123def _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 None134135136# --- cache --------------------------------------------------------------------137def _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.Row141 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 cols = {r[1] for r in con.execute("PRAGMA table_info(hydro_cache)")}145 if "uid" not in cols:146 con.execute("ALTER TABLE hydro_cache ADD COLUMN uid TEXT")147 con.execute("CREATE INDEX IF NOT EXISTS idx_hydro_uid "148 "ON hydro_cache(uid)")149 if "kwh" not in cols:150 con.execute("ALTER TABLE hydro_cache ADD COLUMN kwh INTEGER")151 return con152153154def _mark_negatif(con: sqlite3.Connection, cle: str, uid: str | None) -> None:155 """Échec (adresse inconnue d'HQ) : ligne sans coût, pour ne pas re-résoudre156 de captcha avant NEG_TTL. N'écrase jamais un succès existant."""157 with con:158 con.execute(159 "INSERT OR IGNORE INTO hydro_cache "160 "(cle, adresse, cout_annuel, cout_mensuel, payload, fetched_at, "161 "uid, kwh) VALUES (?,?,NULL,NULL,NULL,?,?,NULL)",162 (cle, "", time.time(), uid))163 con.execute(164 "UPDATE hydro_cache SET fetched_at=?, uid=COALESCE(?, uid) "165 "WHERE cle=? AND cout_annuel IS NULL",166 (time.time(), uid, cle))167168169def _civic(adresse: str) -> str | None:170 m = re.match(r"\s*(\d+)([A-Za-z]{0,3})\b", adresse or "")171 if not m:172 return None173 num, suf = m.group(1), m.group(2)174 reste = (adresse or "")[m.end():].lstrip(" ,")175 # rues ordinales : « 15e Avenue », « 1re Avenue », « 2e Rang » — le suffixe176 # ordinal colle au chiffre et est suivi d'un type de voie177 if (suf.lower() in ("e", "re", "er", "eme", "ieme", "ième", "nd", "nde")178 and re.match(r"(av|ave|avenue|rue|rang|boul|ch|chemin)\b",179 reste, re.I)):180 return None181 return num + suf # numéro civique, avec lettre d'appartement (24A)182183184def _parse_addr(adresse: str) -> tuple[str, str] | None:185 """Extrait (numéro civique, code postal) d'une adresse de fiche."""186 cp = re.search(r"([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)", adresse or "")187 civ = _civic(adresse or "")188 if not cp or not civ:189 return None190 return civ, cp.group(1).upper().replace(" ", "")191192193_MAPBOX_TOKEN: list[str] = []194195196def _mapbox_token() -> str | None:197 if not _MAPBOX_TOKEN:198 cfg = (Path(__file__).resolve().parent.parent / "frontend" / "src"199 / "kamaps" / "config.ts")200 if cfg.exists():201 m = re.search(r'"(pk\.[A-Za-z0-9._-]+)"', cfg.read_text())202 _MAPBOX_TOKEN.append(m.group(1) if m else "")203 else:204 _MAPBOX_TOKEN.append("")205 return _MAPBOX_TOKEN[0] or None206207208def _postal_from_latlng(lat: float, lng: float) -> str | None:209 """Code postal via géocodage inverse Mapbox (adresses sans CP)."""210 tok = _mapbox_token()211 if not tok:212 return None213 url = (f"https://api.mapbox.com/geocoding/v5/mapbox.places/{lng},{lat}.json"214 f"?types=postcode&country=CA&access_token={tok}")215 try:216 with urllib.request.urlopen(url, timeout=15) as r:217 feats = json.load(r).get("features") or []218 if feats:219 return (feats[0].get("text") or "").upper().replace(" ", "")220 except Exception:221 return None222 return None223224225def _resolve_postal(civic: str, adresse: str,226 lat: float | None, lng: float | None) -> str | None:227 """Code postal le plus PRÉCIS pour civic+adresse. Priorité au géocodage228 AVANT de l'adresse complète (rooftop) ancré par proximity=lat/lng — plus229 fiable que l'inverse du lat/lng souvent approximatif des annonces. Vérifie230 que le numéro civique du résultat correspond ; sinon repli sur l'inverse."""231 tok = _mapbox_token()232 if tok and adresse:233 try:234 q = urllib.parse.quote(adresse)235 url = (f"https://api.mapbox.com/geocoding/v5/mapbox.places/{q}.json"236 f"?country=CA&types=address&limit=1&language=fr")237 if lat is not None and lng is not None:238 url += f"&proximity={lng},{lat}"239 url += f"&access_token={tok}"240 with urllib.request.urlopen(url, timeout=15) as r:241 f = (json.load(r).get("features") or [{}])[0]242 num = str(f.get("address") or "")243 pc = next((c["text"] for c in (f.get("context") or [])244 if c.get("id", "").startswith("postcode")), None)245 # accepter si le numéro civique concorde (évite les dérives de rue)246 if pc and num and num.rstrip("abABcC ") == re.sub(r"[A-Za-z]$", "",247 civic):248 return pc.upper().replace(" ", "")249 except Exception:250 pass251 if lat is not None and lng is not None:252 return _postal_from_latlng(lat, lng)253 return None254255256def _montant(d: dict) -> tuple[float | None, float | None, int | None]:257 """(coût annuel, coût mensuel, conso annuelle kWh) de l'estimation."""258 a = d.get("montantAnnuel")259 kwh = d.get("consommationAnnuelle")260 if a is None:261 m = re.search(r'"montantAnnuel"\s*:\s*([0-9]+(?:\.[0-9]+)?)',262 json.dumps(d))263 a = float(m.group(1)) if m else None264 if a is None:265 return None, None, kwh266 return round(a), round(a / 12), kwh267268269def estimate(civic: str | None = None, postal: str | None = None,270 adresse: str | None = None, lat: float | None = None,271 lng: float | None = None, uid: str | None = None,272 solve: bool = True) -> dict:273 """Coût d'électricité annuel estimé à une adresse (cache 6 mois).274275 `solve=False` : ne consulte QUE le cache (aucune résolution de captcha,276 aucun coût) — utilisé pour l'affichage automatique des fiches.277 Le code postal est extrait de l'adresse, ou dérivé du couple lat/lng par278 géocodage inverse Mapbox si l'adresse n'en contient pas (cas d'Immo-Ka).279 Le cache est indexé par `uid` (clé stable de l'annonce) ET par280 civique|code postal, pour que l'affichage cache-only fonctionne même281 quand l'adresse ne porte pas de code postal.282 """283 # lecture cache par uid d'abord (ne nécessite ni CP ni Mapbox)284 if uid:285 con = _connect()286 row = con.execute(287 "SELECT * FROM hydro_cache WHERE uid=? AND cout_annuel IS NOT NULL "288 "AND fetched_at>?", (uid, time.time() - TTL)).fetchone()289 con.close()290 if row:291 return {"disponible": True, "adresse": row["adresse"],292 "cout_annuel": row["cout_annuel"],293 "cout_mensuel": row["cout_mensuel"],294 "kwh_annuel": row["kwh"], "cache": True}295296 if not civic and adresse:297 civic = _civic(adresse)298 if not postal and adresse:299 cp = re.search(r"([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)", adresse)300 if cp:301 postal = cp.group(1)302 # cache-only : ne pas déclencher Mapbox ; proposer le bouton si un calcul303 # est faisable (civique connu + CP OU coordonnées disponibles)304 if not solve:305 faisable = bool(civic) and (bool(postal) or306 (lat is not None and lng is not None))307 return {"disponible": False,308 "raison": "non calculé" if faisable else "adresse incomplète",309 "en_attente": faisable}310 if not postal and civic:311 postal = _resolve_postal(civic, adresse or "", lat, lng) # au calcul312 if not civic or not postal:313 return {"disponible": False, "raison": "adresse incomplète"}314 postal = postal.upper().replace(" ", "")315 cle = f"{civic}|{postal}"316317 postal_espace = re.sub(r"^([A-Z]\d[A-Z])\s*(\d[A-Z]\d)$", r"\1 \2", postal)318319 con = _connect()320 row = con.execute("SELECT * FROM hydro_cache WHERE cle=? AND fetched_at>?",321 (cle, time.time() - TTL)).fetchone()322 if row and row["cout_annuel"]:323 con.close()324 return {"disponible": True, "adresse": row["adresse"],325 "cout_annuel": row["cout_annuel"],326 "cout_mensuel": row["cout_mensuel"],327 "kwh_annuel": row["kwh"], "cache": True}328329 if not os.environ.get("HQ_CAPTCHA_KEY"):330 con.close()331 return {"disponible": False, "raison": "solveur captcha non configuré"}332333 token = _solve_captcha()334 if not token:335 con.close()336 return {"disponible": False, "raison": "échec du captcha"}337338 opener = _opener()339 lieux = _post(opener, URL_LIEU, {"noCivique": civic,340 "codePostal": postal_espace,341 "recaptchaResponse": token})342 items = (lieux or {}).get("listeLieuxConsommation") or []343 cle_unique = (lieux or {}).get("cleUnique")344 if not items or not cle_unique:345 _mark_negatif(con, cle, uid) # cache négatif : pas de re-tentative 30 j346 con.close()347 return {"disponible": False,348 "raison": "adresse inconnue d'Hydro-Québec"}349 lieu = items[0]350 id_conso = lieu.get("id")351 adr = lieu.get("libelleAdresse") or f"{civic} {lieu.get('rue', '')}".strip()352353 est = _post(opener, URL_ESTIM, {"cleUnique": cle_unique,354 "idConso": id_conso})355 if not est:356 con.close()357 return {"disponible": False, "raison": "estimation indisponible"}358 an, mens, kwh = _montant(est)359 if not an:360 _mark_negatif(con, cle, uid)361 con.close()362 return {"disponible": False, "raison": "montant introuvable"}363 with con:364 con.execute(365 "INSERT OR REPLACE INTO hydro_cache "366 "(cle, adresse, cout_annuel, cout_mensuel, payload, fetched_at, "367 "uid, kwh) VALUES (?,?,?,?,?,?,?,?)",368 (cle, adr, an, mens, json.dumps(est), time.time(), uid, kwh))369 con.close()370 return {"disponible": True, "adresse": adr, "cout_annuel": an,371 "cout_mensuel": mens, "kwh_annuel": kwh, "cache": False}372373374def _main_db() -> Path | None:375 d = DB_PATH.parent376 for n in ("louka.db", "immoka.db", "immo.db"):377 if (d / n).exists():378 return d / n379 return None380381382def precompute(limit: int = 100, budget: int = 40) -> dict:383 """Pré-calcule l'estimation Hydro des `limit` annonces les plus récentes384 non encore en cache. `budget` = nb max de résolutions captcha par appel385 (borne le coût ; le reliquat sera comblé aux cycles suivants).386387 Appelé au démarrage (100 dernières) et à chaque cycle de `watch` pour les388 nouvelles annonces — le cache persiste entre les releases (data/hydro.db).389 """390 db = _main_db()391 if db is None:392 return {"erreur": "base des annonces introuvable"}393 src = sqlite3.connect(f"file:{db}?mode=ro", uri=True)394 src.row_factory = sqlite3.Row395 rows = src.execute(396 "SELECT uid, address, lat, lng FROM listings "397 "WHERE address IS NOT NULL AND address != '' "398 "ORDER BY first_seen DESC LIMIT ?", (limit,)).fetchall()399 src.close()400401 con = _connect()402 fait = calcules = saut = echec = negatif = 0403 for r in rows:404 # déjà traité pour cet uid ? succès (TTL) ou échec récent (NEG_TTL) —405 # dans les deux cas on ne re-résout PAS de captcha406 cached = con.execute(407 "SELECT cout_annuel FROM hydro_cache WHERE uid=? AND ("408 "(cout_annuel IS NOT NULL AND fetched_at>?) OR "409 "(cout_annuel IS NULL AND fetched_at>?))",410 (r["uid"], time.time() - TTL, time.time() - NEG_TTL)).fetchone()411 if cached is not None:412 if cached["cout_annuel"] is not None:413 fait += 1414 else:415 negatif += 1416 continue417 civic = _civic(r["address"])418 if not civic or (r["lat"] is None and not _parse_addr(r["address"])):419 saut += 1420 continue421 if calcules >= budget:422 break423 con.close() # estimate() ouvre sa propre connexion424 res = estimate(adresse=r["address"], lat=r["lat"], lng=r["lng"],425 uid=r["uid"])426 con = _connect()427 calcules += 1428 if not res.get("disponible"):429 echec += 1430 con.close()431 stats = {"vises": len(rows), "deja_en_cache": fait,432 "deja_echoue": negatif, "nouveaux_calcules": calcules,433 "sans_adresse": saut, "echecs": echec, "budget": budget}434 print(f"[hydro] précalcul : {stats}")435 return stats436