# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # hydro.py : estimation du coût d'électricité à une adresse — Hydro-Québec # # Reproduit le parcours PUBLIC de l'outil « Estimer les coûts d'électricité # à une résidence » (session.hydroquebec.com), rétro-conçu depuis son SPA : # 1. POST {clWebServicePublicApiURL}public/api/v3_0/lieu-consommation # body {noCivique, codePostal, recaptchaResponse} -> liste de lieux # (idConso, cleUnique, adresse) ; # 2. POST {drcvPublicApiURL}public/api/v3_0/consommation/estimation # body {cleUnique, idConso} -> coût annuel estimé. # Les deux étapes exigent un jeton reCAPTCHA v2 valide # (site key 6Lf08Q0UAAAAABCA7z47p2tMxa5_fY0wmn8DDPsu). Il est obtenu via un # solveur enfichable (2captcha / CapSolver) ET les requêtes sortent par le # proxy résidentiel canadien Bright Data (contourne le géoblocage / anti-bot). # # Configuration (.env) : # HQ_CAPTCHA_KEY clé du solveur (obligatoire pour l'appel live) # HQ_CAPTCHA_PROVIDER "2captcha" (défaut) | "capsolver" # BRIGHTDATA_* / OXYLABS_* : proxy résidentiel (réutilise la pile projet) # # Sans HQ_CAPTCHA_KEY : estimate() renvoie {"disponible": False, ...} et le # bloc fiche reste masqué — jamais d'estimation inventée. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import sqlite3 import time import urllib.parse import urllib.request from pathlib import Path DB_PATH = Path(__file__).resolve().parent.parent / "data" / "hydro.db" TTL = 180 * 86400 # succès : la conso bouge lentement NEG_TTL = 30 * 86400 # échec (adresse inconnue) : re-tenter dans 30 j # — borne le coût captcha des cycles watch SITE_KEY = "6Lf08Q0UAAAAABCA7z47p2tMxa5_fY0wmn8DDPsu" PAGE_URL = ("https://session.hydroquebec.com/portail/fr/web/clientele/" "estimation-consommation") URL_LIEU = ("https://services-cl.solutions.hydroquebec.com/wsapi/webpublic/" "public/api/v3_0/lieu-consommation") URL_ESTIM = ("https://services-cl.solutions.hydroquebec.com/conso/webpublic/" "public/api/v3_0/consommation/estimation") UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126.0 Safari/537.36") # --- proxy résidentiel (Bright Data Web Unlocker en repli d'Oxylabs) ---------- def _opener() -> urllib.request.OpenerDirector: proxy = None if os.environ.get("OXYLABS_PROXY_USER"): u = os.environ["OXYLABS_PROXY_USER"] p = os.environ["OXYLABS_PROXY_PASS"] host = os.environ.get("OXYLABS_PROXY", "pr.oxylabs.io:7777") proxy = f"http://{u}-cc-CA:{p}@{host}" if proxy: h = urllib.request.ProxyHandler({"http": proxy, "https": proxy}) return urllib.request.build_opener(h) return urllib.request.build_opener() # --- solveur reCAPTCHA v2 enfichable ------------------------------------------ def _solve_captcha() -> str | None: key = os.environ.get("HQ_CAPTCHA_KEY") if not key: return None provider = os.environ.get("HQ_CAPTCHA_PROVIDER", "2captcha").lower() if provider == "capsolver": return _capsolver(key) return _twocaptcha(key) def _twocaptcha(key: str) -> str | None: r = urllib.request.urlopen(urllib.request.Request( "https://2captcha.com/in.php", data=urllib.parse.urlencode({ "key": key, "method": "userrecaptcha", "googlekey": SITE_KEY, "pageurl": PAGE_URL, "json": 1}).encode()), timeout=30) got = json.load(r) if got.get("status") != 1: return None cid = got["request"] for _ in range(24): time.sleep(5) res = json.load(urllib.request.urlopen( f"https://2captcha.com/res.php?key={key}&action=get&id={cid}&json=1", timeout=30)) if res.get("status") == 1: return res["request"] if res.get("request") != "CAPCHA_NOT_READY": return None return None def _capsolver(key: str) -> str | None: r = urllib.request.urlopen(urllib.request.Request( "https://api.capsolver.com/createTask", data=json.dumps({"clientKey": key, "task": { "type": "ReCaptchaV2TaskProxyLess", "websiteURL": PAGE_URL, "websiteKey": SITE_KEY}}).encode(), headers={"Content-Type": "application/json"}), timeout=30) tid = json.load(r).get("taskId") if not tid: return None for _ in range(24): time.sleep(5) res = json.load(urllib.request.urlopen(urllib.request.Request( "https://api.capsolver.com/getTaskResult", data=json.dumps({"clientKey": key, "taskId": tid}).encode(), headers={"Content-Type": "application/json"}), timeout=30)) if res.get("status") == "ready": return res["solution"]["gRecaptchaResponse"] if res.get("status") != "processing": return None return None def _post(opener, url: str, payload: dict) -> dict | None: req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json", "User-Agent": UA, "Origin": "https://session.hydroquebec.com", "Referer": PAGE_URL}) try: with opener.open(req, timeout=60) as r: return json.load(r) except Exception: return None # --- cache -------------------------------------------------------------------- def _connect() -> sqlite3.Connection: DB_PATH.parent.mkdir(parents=True, exist_ok=True) con = sqlite3.connect(DB_PATH, timeout=15) con.row_factory = sqlite3.Row con.execute("""CREATE TABLE IF NOT EXISTS hydro_cache ( cle TEXT PRIMARY KEY, adresse TEXT, cout_annuel REAL, cout_mensuel REAL, payload TEXT, fetched_at REAL)""") cols = {r[1] for r in con.execute("PRAGMA table_info(hydro_cache)")} if "uid" not in cols: con.execute("ALTER TABLE hydro_cache ADD COLUMN uid TEXT") con.execute("CREATE INDEX IF NOT EXISTS idx_hydro_uid " "ON hydro_cache(uid)") if "kwh" not in cols: con.execute("ALTER TABLE hydro_cache ADD COLUMN kwh INTEGER") return con def _mark_negatif(con: sqlite3.Connection, cle: str, uid: str | None) -> None: """Échec (adresse inconnue d'HQ) : ligne sans coût, pour ne pas re-résoudre de captcha avant NEG_TTL. N'écrase jamais un succès existant.""" with con: con.execute( "INSERT OR IGNORE INTO hydro_cache " "(cle, adresse, cout_annuel, cout_mensuel, payload, fetched_at, " "uid, kwh) VALUES (?,?,NULL,NULL,NULL,?,?,NULL)", (cle, "", time.time(), uid)) con.execute( "UPDATE hydro_cache SET fetched_at=?, uid=COALESCE(?, uid) " "WHERE cle=? AND cout_annuel IS NULL", (time.time(), uid, cle)) def _civic(adresse: str) -> str | None: m = re.match(r"\s*(\d+)([A-Za-z]{0,3})\b", adresse or "") if not m: return None num, suf = m.group(1), m.group(2) reste = (adresse or "")[m.end():].lstrip(" ,") # rues ordinales : « 15e Avenue », « 1re Avenue », « 2e Rang » — le suffixe # ordinal colle au chiffre et est suivi d'un type de voie if (suf.lower() in ("e", "re", "er", "eme", "ieme", "ième", "nd", "nde") and re.match(r"(av|ave|avenue|rue|rang|boul|ch|chemin)\b", reste, re.I)): return None return num + suf # numéro civique, avec lettre d'appartement (24A) def _parse_addr(adresse: str) -> tuple[str, str] | None: """Extrait (numéro civique, code postal) d'une adresse de fiche.""" cp = re.search(r"([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)", adresse or "") civ = _civic(adresse or "") if not cp or not civ: return None return civ, cp.group(1).upper().replace(" ", "") _MAPBOX_TOKEN: list[str] = [] def _mapbox_token() -> str | None: if not _MAPBOX_TOKEN: cfg = (Path(__file__).resolve().parent.parent / "frontend" / "src" / "kamaps" / "config.ts") if cfg.exists(): m = re.search(r'"(pk\.[A-Za-z0-9._-]+)"', cfg.read_text()) _MAPBOX_TOKEN.append(m.group(1) if m else "") else: _MAPBOX_TOKEN.append("") return _MAPBOX_TOKEN[0] or None def _postal_from_latlng(lat: float, lng: float) -> str | None: """Code postal via géocodage inverse Mapbox (adresses sans CP).""" tok = _mapbox_token() if not tok: return None url = (f"https://api.mapbox.com/geocoding/v5/mapbox.places/{lng},{lat}.json" f"?types=postcode&country=CA&access_token={tok}") try: with urllib.request.urlopen(url, timeout=15) as r: feats = json.load(r).get("features") or [] if feats: return (feats[0].get("text") or "").upper().replace(" ", "") except Exception: return None return None def _resolve_postal(civic: str, adresse: str, lat: float | None, lng: float | None) -> str | None: """Code postal le plus PRÉCIS pour civic+adresse. Priorité au géocodage AVANT de l'adresse complète (rooftop) ancré par proximity=lat/lng — plus fiable que l'inverse du lat/lng souvent approximatif des annonces. Vérifie que le numéro civique du résultat correspond ; sinon repli sur l'inverse.""" tok = _mapbox_token() if tok and adresse: try: q = urllib.parse.quote(adresse) url = (f"https://api.mapbox.com/geocoding/v5/mapbox.places/{q}.json" f"?country=CA&types=address&limit=1&language=fr") if lat is not None and lng is not None: url += f"&proximity={lng},{lat}" url += f"&access_token={tok}" with urllib.request.urlopen(url, timeout=15) as r: f = (json.load(r).get("features") or [{}])[0] num = str(f.get("address") or "") pc = next((c["text"] for c in (f.get("context") or []) if c.get("id", "").startswith("postcode")), None) # accepter si le numéro civique concorde (évite les dérives de rue) if pc and num and num.rstrip("abABcC ") == re.sub(r"[A-Za-z]$", "", civic): return pc.upper().replace(" ", "") except Exception: pass if lat is not None and lng is not None: return _postal_from_latlng(lat, lng) return None def _montant(d: dict) -> tuple[float | None, float | None, int | None]: """(coût annuel, coût mensuel, conso annuelle kWh) de l'estimation.""" a = d.get("montantAnnuel") kwh = d.get("consommationAnnuelle") if a is None: m = re.search(r'"montantAnnuel"\s*:\s*([0-9]+(?:\.[0-9]+)?)', json.dumps(d)) a = float(m.group(1)) if m else None if a is None: return None, None, kwh return round(a), round(a / 12), kwh def estimate(civic: str | None = None, postal: str | None = None, adresse: str | None = None, lat: float | None = None, lng: float | None = None, uid: str | None = None, solve: bool = True) -> dict: """Coût d'électricité annuel estimé à une adresse (cache 6 mois). `solve=False` : ne consulte QUE le cache (aucune résolution de captcha, aucun coût) — utilisé pour l'affichage automatique des fiches. Le code postal est extrait de l'adresse, ou dérivé du couple lat/lng par géocodage inverse Mapbox si l'adresse n'en contient pas (cas d'Immo-Ka). Le cache est indexé par `uid` (clé stable de l'annonce) ET par civique|code postal, pour que l'affichage cache-only fonctionne même quand l'adresse ne porte pas de code postal. """ # lecture cache par uid d'abord (ne nécessite ni CP ni Mapbox) if uid: con = _connect() row = con.execute( "SELECT * FROM hydro_cache WHERE uid=? AND cout_annuel IS NOT NULL " "AND fetched_at>?", (uid, time.time() - TTL)).fetchone() con.close() if row: return {"disponible": True, "adresse": row["adresse"], "cout_annuel": row["cout_annuel"], "cout_mensuel": row["cout_mensuel"], "kwh_annuel": row["kwh"], "cache": True} if not civic and adresse: civic = _civic(adresse) if not postal and adresse: cp = re.search(r"([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)", adresse) if cp: postal = cp.group(1) # cache-only : ne pas déclencher Mapbox ; proposer le bouton si un calcul # est faisable (civique connu + CP OU coordonnées disponibles) if not solve: faisable = bool(civic) and (bool(postal) or (lat is not None and lng is not None)) return {"disponible": False, "raison": "non calculé" if faisable else "adresse incomplète", "en_attente": faisable} if not postal and civic: postal = _resolve_postal(civic, adresse or "", lat, lng) # au calcul if not civic or not postal: return {"disponible": False, "raison": "adresse incomplète"} postal = postal.upper().replace(" ", "") cle = f"{civic}|{postal}" postal_espace = re.sub(r"^([A-Z]\d[A-Z])\s*(\d[A-Z]\d)$", r"\1 \2", postal) con = _connect() row = con.execute("SELECT * FROM hydro_cache WHERE cle=? AND fetched_at>?", (cle, time.time() - TTL)).fetchone() if row and row["cout_annuel"]: con.close() return {"disponible": True, "adresse": row["adresse"], "cout_annuel": row["cout_annuel"], "cout_mensuel": row["cout_mensuel"], "kwh_annuel": row["kwh"], "cache": True} if not os.environ.get("HQ_CAPTCHA_KEY"): con.close() return {"disponible": False, "raison": "solveur captcha non configuré"} token = _solve_captcha() if not token: con.close() return {"disponible": False, "raison": "échec du captcha"} opener = _opener() lieux = _post(opener, URL_LIEU, {"noCivique": civic, "codePostal": postal_espace, "recaptchaResponse": token}) items = (lieux or {}).get("listeLieuxConsommation") or [] cle_unique = (lieux or {}).get("cleUnique") if not items or not cle_unique: _mark_negatif(con, cle, uid) # cache négatif : pas de re-tentative 30 j con.close() return {"disponible": False, "raison": "adresse inconnue d'Hydro-Québec"} lieu = items[0] id_conso = lieu.get("id") adr = lieu.get("libelleAdresse") or f"{civic} {lieu.get('rue', '')}".strip() est = _post(opener, URL_ESTIM, {"cleUnique": cle_unique, "idConso": id_conso}) if not est: con.close() return {"disponible": False, "raison": "estimation indisponible"} an, mens, kwh = _montant(est) if not an: _mark_negatif(con, cle, uid) con.close() return {"disponible": False, "raison": "montant introuvable"} with con: con.execute( "INSERT OR REPLACE INTO hydro_cache " "(cle, adresse, cout_annuel, cout_mensuel, payload, fetched_at, " "uid, kwh) VALUES (?,?,?,?,?,?,?,?)", (cle, adr, an, mens, json.dumps(est), time.time(), uid, kwh)) con.close() return {"disponible": True, "adresse": adr, "cout_annuel": an, "cout_mensuel": mens, "kwh_annuel": kwh, "cache": False} def _main_db() -> Path | None: d = DB_PATH.parent for n in ("louka.db", "immoka.db", "immo.db"): if (d / n).exists(): return d / n return None def precompute(limit: int = 100, budget: int = 40) -> dict: """Pré-calcule l'estimation Hydro des `limit` annonces les plus récentes non encore en cache. `budget` = nb max de résolutions captcha par appel (borne le coût ; le reliquat sera comblé aux cycles suivants). Appelé au démarrage (100 dernières) et à chaque cycle de `watch` pour les nouvelles annonces — le cache persiste entre les releases (data/hydro.db). """ db = _main_db() if db is None: return {"erreur": "base des annonces introuvable"} src = sqlite3.connect(f"file:{db}?mode=ro", uri=True) src.row_factory = sqlite3.Row rows = src.execute( "SELECT uid, address, lat, lng FROM listings " "WHERE address IS NOT NULL AND address != '' " "ORDER BY first_seen DESC LIMIT ?", (limit,)).fetchall() src.close() con = _connect() fait = calcules = saut = echec = negatif = 0 for r in rows: # déjà traité pour cet uid ? succès (TTL) ou échec récent (NEG_TTL) — # dans les deux cas on ne re-résout PAS de captcha cached = con.execute( "SELECT cout_annuel FROM hydro_cache WHERE uid=? AND (" "(cout_annuel IS NOT NULL AND fetched_at>?) OR " "(cout_annuel IS NULL AND fetched_at>?))", (r["uid"], time.time() - TTL, time.time() - NEG_TTL)).fetchone() if cached is not None: if cached["cout_annuel"] is not None: fait += 1 else: negatif += 1 continue civic = _civic(r["address"]) if not civic or (r["lat"] is None and not _parse_addr(r["address"])): saut += 1 continue if calcules >= budget: break con.close() # estimate() ouvre sa propre connexion res = estimate(adresse=r["address"], lat=r["lat"], lng=r["lng"], uid=r["uid"]) con = _connect() calcules += 1 if not res.get("disponible"): echec += 1 con.close() stats = {"vises": len(rows), "deja_en_cache": fait, "deja_echoue": negatif, "nouveaux_calcules": calcules, "sans_adresse": saut, "echecs": echec, "budget": budget} print(f"[hydro] précalcul : {stats}") return stats