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%

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 1 mo ago (Aug 24, 2026) parent 45c7645

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