hydro : postal rooftop (géocodage avant Mapbox + proximity), cache négatif, fix civiques ordinaux
- _resolve_postal : géocodage AVANT de l adresse complète ancré par proximity lat/lng (CP rooftop précis, vérif du numéro civique) au lieu de l inverse du lat/lng approximatif — récupère des adresses qui échouaient (ex. 2038 Charlemagne : 0 -> 618 $/an) - cache négatif (NEG_TTL 30 j) : les adresses inconnues d HQ ne re-consomment plus de captcha à chaque cycle watch - _civic rejette les rues ordinales (15e Avenue, 1re Avenue, 2e Rang) tout en gardant la lettre d appartement (2457A) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 changed file +79 −13
modified
louka/hydro.py
+79 −13
@@ -35,7 +35,9 @@ import urllib.request | ||
| 35 | 35 | from pathlib import Path |
| 36 | 36 | |
| 37 | 37 | DB_PATH = Path(__file__).resolve().parent.parent / "data" / "hydro.db" |
| 38 | −TTL = 180 * 86400 # la conso d'un logement bouge lentement | |
| 38 | +TTL = 180 * 86400 # succès : la conso bouge lentement | |
| 39 | +NEG_TTL = 30 * 86400 # échec (adresse inconnue) : re-tenter dans 30 j | |
| 40 | + # — borne le coût captcha des cycles watch | |
| 39 | 41 | |
| 40 | 42 | SITE_KEY = "6Lf08Q0UAAAAABCA7z47p2tMxa5_fY0wmn8DDPsu" |
| 41 | 43 | PAGE_URL = ("https://session.hydroquebec.com/portail/fr/web/clientele/" |
@@ -149,9 +151,34 @@ def _connect() -> sqlite3.Connection: | ||
| 149 | 151 | return con |
| 150 | 152 | |
| 151 | 153 | |
| 154 | +def _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ésoudre | |
| 156 | + 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)) | |
| 167 | + | |
| 168 | + | |
| 152 | 169 | def _civic(adresse: str) -> str | None: |
| 153 | − m = re.match(r"\s*(\d+[A-Za-z]?)", adresse or "") | |
| 154 | − return m.group(1) if m else None | |
| 170 | + m = re.match(r"\s*(\d+)([A-Za-z]{0,3})\b", adresse or "") | |
| 171 | + if not m: | |
| 172 | + return None | |
| 173 | + 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 suffixe | |
| 176 | + # ordinal colle au chiffre et est suivi d'un type de voie | |
| 177 | + 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 None | |
| 181 | + return num + suf # numéro civique, avec lettre d'appartement (24A) | |
| 155 | 182 | |
| 156 | 183 | |
| 157 | 184 | def _parse_addr(adresse: str) -> tuple[str, str] | None: |
@@ -195,6 +222,37 @@ def _postal_from_latlng(lat: float, lng: float) -> str | None: | ||
| 195 | 222 | return None |
| 196 | 223 | |
| 197 | 224 | |
| 225 | +def _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éocodage | |
| 228 | + AVANT de l'adresse complète (rooftop) ancré par proximity=lat/lng — plus | |
| 229 | + fiable que l'inverse du lat/lng souvent approximatif des annonces. Vérifie | |
| 230 | + 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 | + pass | |
| 251 | + if lat is not None and lng is not None: | |
| 252 | + return _postal_from_latlng(lat, lng) | |
| 253 | + return None | |
| 254 | + | |
| 255 | + | |
| 198 | 256 | def _montant(d: dict) -> tuple[float | None, float | None, int | None]: |
| 199 | 257 | """(coût annuel, coût mensuel, conso annuelle kWh) de l'estimation.""" |
| 200 | 258 | a = d.get("montantAnnuel") |
@@ -249,8 +307,8 @@ def estimate(civic: str | None = None, postal: str | None = None, | ||
| 249 | 307 | return {"disponible": False, |
| 250 | 308 | "raison": "non calculé" if faisable else "adresse incomplète", |
| 251 | 309 | "en_attente": faisable} |
| 252 | − if not postal and lat is not None and lng is not None: | |
| 253 | − postal = _postal_from_latlng(lat, lng) # dérivation du CP au calcul | |
| 310 | + if not postal and civic: | |
| 311 | + postal = _resolve_postal(civic, adresse or "", lat, lng) # au calcul | |
| 254 | 312 | if not civic or not postal: |
| 255 | 313 | return {"disponible": False, "raison": "adresse incomplète"} |
| 256 | 314 | postal = postal.upper().replace(" ", "") |
@@ -284,6 +342,7 @@ def estimate(civic: str | None = None, postal: str | None = None, | ||
| 284 | 342 | items = (lieux or {}).get("listeLieuxConsommation") or [] |
| 285 | 343 | cle_unique = (lieux or {}).get("cleUnique") |
| 286 | 344 | if not items or not cle_unique: |
| 345 | + _mark_negatif(con, cle, uid) # cache négatif : pas de re-tentative 30 j | |
| 287 | 346 | con.close() |
| 288 | 347 | return {"disponible": False, |
| 289 | 348 | "raison": "adresse inconnue d'Hydro-Québec"} |
@@ -298,6 +357,7 @@ def estimate(civic: str | None = None, postal: str | None = None, | ||
| 298 | 357 | return {"disponible": False, "raison": "estimation indisponible"} |
| 299 | 358 | an, mens, kwh = _montant(est) |
| 300 | 359 | if not an: |
| 360 | + _mark_negatif(con, cle, uid) | |
| 301 | 361 | con.close() |
| 302 | 362 | return {"disponible": False, "raison": "montant introuvable"} |
| 303 | 363 | with con: |
@@ -339,14 +399,20 @@ def precompute(limit: int = 100, budget: int = 40) -> dict: | ||
| 339 | 399 | src.close() |
| 340 | 400 | |
| 341 | 401 | con = _connect() |
| 342 | − fait = calcules = saut = echec = 0 | |
| 402 | + fait = calcules = saut = echec = negatif = 0 | |
| 343 | 403 | for r in rows: |
| 344 | − # déjà en cache pour cet uid ? (aucun coût) | |
| 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 captcha | |
| 345 | 406 | cached = con.execute( |
| 346 | − "SELECT 1 FROM hydro_cache WHERE uid=? AND cout_annuel IS NOT NULL " | |
| 347 | − "AND fetched_at>?", (r["uid"], time.time() - TTL)).fetchone() | |
| 348 | − if cached: | |
| 349 | − fait += 1 | |
| 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 += 1 | |
| 414 | + else: | |
| 415 | + negatif += 1 | |
| 350 | 416 | continue |
| 351 | 417 | civic = _civic(r["address"]) |
| 352 | 418 | if not civic or (r["lat"] is None and not _parse_addr(r["address"])): |
@@ -363,7 +429,7 @@ def precompute(limit: int = 100, budget: int = 40) -> dict: | ||
| 363 | 429 | echec += 1 |
| 364 | 430 | con.close() |
| 365 | 431 | stats = {"vises": len(rows), "deja_en_cache": fait, |
| 366 | − "nouveaux_calcules": calcules, "sans_adresse": saut, | |
| 367 | − "echecs": echec, "budget": budget} | |
| 432 | + "deja_echoue": negatif, "nouveaux_calcules": calcules, | |
| 433 | + "sans_adresse": saut, "echecs": echec, "budget": budget} | |
| 368 | 434 | print(f"[hydro] précalcul : {stats}") |
| 369 | 435 | return stats |
| 370 | 436 | |