SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

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>
Simon-Pierre Boucher committed (Aug 24, 2026) parent 45c7645 Browse files

1 changed file +79 −13

Unified Split
modified louka/hydro.py +79 −13
@@ -35,7 +35,9 @@ import urllib.request
35 from pathlib import Path 35 from pathlib import Path
36 36
37 DB_PATH = Path(__file__).resolve().parent.parent / "data" / "hydro.db" 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 SITE_KEY = "6Lf08Q0UAAAAABCA7z47p2tMxa5_fY0wmn8DDPsu" 42 SITE_KEY = "6Lf08Q0UAAAAABCA7z47p2tMxa5_fY0wmn8DDPsu"
41 PAGE_URL = ("https://session.hydroquebec.com/portail/fr/web/clientele/" 43 PAGE_URL = ("https://session.hydroquebec.com/portail/fr/web/clientele/"
@@ -149,9 +151,34 @@ def _connect() -> sqlite3.Connection:
149 return con 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 def _civic(adresse: str) -> str | None: 169 def _civic(adresse: str) -> str | None:
153− m = re.match(r"\s*(\d+[A-Za-z]?)", adresse or "") 170+ m = re.match(r"\s*(\d+)([A-Za-z]{0,3})\b", adresse or "")
154− return m.group(1) if m else None 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 def _parse_addr(adresse: str) -> tuple[str, str] | None: 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 return None 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 def _montant(d: dict) -> tuple[float | None, float | None, int | None]: 256 def _montant(d: dict) -> tuple[float | None, float | None, int | None]:
199 """(coût annuel, coût mensuel, conso annuelle kWh) de l'estimation.""" 257 """(coût annuel, coût mensuel, conso annuelle kWh) de l'estimation."""
200 a = d.get("montantAnnuel") 258 a = d.get("montantAnnuel")
@@ -249,8 +307,8 @@ def estimate(civic: str | None = None, postal: str | None = None,
249 return {"disponible": False, 307 return {"disponible": False,
250 "raison": "non calculé" if faisable else "adresse incomplète", 308 "raison": "non calculé" if faisable else "adresse incomplète",
251 "en_attente": faisable} 309 "en_attente": faisable}
252− if not postal and lat is not None and lng is not None: 310+ if not postal and civic:
253− postal = _postal_from_latlng(lat, lng) # dérivation du CP au calcul 311+ postal = _resolve_postal(civic, adresse or "", lat, lng) # au calcul
254 if not civic or not postal: 312 if not civic or not postal:
255 return {"disponible": False, "raison": "adresse incomplète"} 313 return {"disponible": False, "raison": "adresse incomplète"}
256 postal = postal.upper().replace(" ", "") 314 postal = postal.upper().replace(" ", "")
@@ -284,6 +342,7 @@ def estimate(civic: str | None = None, postal: str | None = None,
284 items = (lieux or {}).get("listeLieuxConsommation") or [] 342 items = (lieux or {}).get("listeLieuxConsommation") or []
285 cle_unique = (lieux or {}).get("cleUnique") 343 cle_unique = (lieux or {}).get("cleUnique")
286 if not items or not cle_unique: 344 if not items or not cle_unique:
345+ _mark_negatif(con, cle, uid) # cache négatif : pas de re-tentative 30 j
287 con.close() 346 con.close()
288 return {"disponible": False, 347 return {"disponible": False,
289 "raison": "adresse inconnue d'Hydro-Québec"} 348 "raison": "adresse inconnue d'Hydro-Québec"}
@@ -298,6 +357,7 @@ def estimate(civic: str | None = None, postal: str | None = None,
298 return {"disponible": False, "raison": "estimation indisponible"} 357 return {"disponible": False, "raison": "estimation indisponible"}
299 an, mens, kwh = _montant(est) 358 an, mens, kwh = _montant(est)
300 if not an: 359 if not an:
360+ _mark_negatif(con, cle, uid)
301 con.close() 361 con.close()
302 return {"disponible": False, "raison": "montant introuvable"} 362 return {"disponible": False, "raison": "montant introuvable"}
303 with con: 363 with con:
@@ -339,14 +399,20 @@ def precompute(limit: int = 100, budget: int = 40) -> dict:
339 src.close() 399 src.close()
340 400
341 con = _connect() 401 con = _connect()
342− fait = calcules = saut = echec = 0 402+ fait = calcules = saut = echec = negatif = 0
343 for r in rows: 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 cached = con.execute( 406 cached = con.execute(
346− "SELECT 1 FROM hydro_cache WHERE uid=? AND cout_annuel IS NOT NULL " 407+ "SELECT cout_annuel FROM hydro_cache WHERE uid=? AND ("
347− "AND fetched_at>?", (r["uid"], time.time() - TTL)).fetchone() 408+ "(cout_annuel IS NOT NULL AND fetched_at>?) OR "
348− if cached: 409+ "(cout_annuel IS NULL AND fetched_at>?))",
349− fait += 1 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 continue 416 continue
351 civic = _civic(r["address"]) 417 civic = _civic(r["address"])
352 if not civic or (r["lat"] is None and not _parse_addr(r["address"])): 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 echec += 1 429 echec += 1
364 con.close() 430 con.close()
365 stats = {"vises": len(rows), "deja_en_cache": fait, 431 stats = {"vises": len(rows), "deja_en_cache": fait,
366− "nouveaux_calcules": calcules, "sans_adresse": saut, 432+ "deja_echoue": negatif, "nouveaux_calcules": calcules,
367− "echecs": echec, "budget": budget} 433+ "sans_adresse": saut, "echecs": echec, "budget": budget}
368 print(f"[hydro] précalcul : {stats}") 434 print(f"[hydro] précalcul : {stats}")
369 return stats 435 return stats
370 436
Menu

Theme