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%
18.2 KB · 436 lines python
Raw Blame History
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