| 139 |
139 |
con.execute("""CREATE TABLE IF NOT EXISTS hydro_cache ( |
| 140 |
140 |
cle TEXT PRIMARY KEY, adresse TEXT, cout_annuel REAL, |
| 141 |
141 |
cout_mensuel REAL, payload TEXT, fetched_at REAL)""") |
|
142 |
+ cols = {r[1] for r in con.execute("PRAGMA table_info(hydro_cache)")} |
|
143 |
+ if "uid" not in cols: |
|
144 |
+ con.execute("ALTER TABLE hydro_cache ADD COLUMN uid TEXT") |
|
145 |
+ con.execute("CREATE INDEX IF NOT EXISTS idx_hydro_uid " |
|
146 |
+ "ON hydro_cache(uid)") |
|
147 |
+ if "kwh" not in cols: |
|
148 |
+ con.execute("ALTER TABLE hydro_cache ADD COLUMN kwh INTEGER") |
| 142 |
149 |
return con |
| 143 |
150 |
|
| 144 |
151 |
|
|
152 |
+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 |
|
155 |
+ |
|
156 |
+ |
| 145 |
157 |
def _parse_addr(adresse: str) -> tuple[str, str] | None: |
| 146 |
158 |
"""Extrait (numéro civique, code postal) d'une adresse de fiche.""" |
| 147 |
159 |
cp = re.search(r"([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)", adresse or "") |
| 148 |
|
− civ = re.match(r"\s*(\d+[A-Za-z]?)", adresse or "") |
|
160 |
+ civ = _civic(adresse or "") |
| 149 |
161 |
if not cp or not civ: |
| 150 |
162 |
return None |
| 151 |
|
− return civ.group(1), cp.group(1).upper().replace(" ", "") |
|
163 |
+ return civ, cp.group(1).upper().replace(" ", "") |
|
164 |
+ |
|
165 |
+ |
|
166 |
+_MAPBOX_TOKEN: list[str] = [] |
|
167 |
+ |
|
168 |
+ |
|
169 |
+def _mapbox_token() -> str | None: |
|
170 |
+ if not _MAPBOX_TOKEN: |
|
171 |
+ cfg = (Path(__file__).resolve().parent.parent / "frontend" / "src" |
|
172 |
+ / "kamaps" / "config.ts") |
|
173 |
+ if cfg.exists(): |
|
174 |
+ m = re.search(r'"(pk\.[A-Za-z0-9._-]+)"', cfg.read_text()) |
|
175 |
+ _MAPBOX_TOKEN.append(m.group(1) if m else "") |
|
176 |
+ else: |
|
177 |
+ _MAPBOX_TOKEN.append("") |
|
178 |
+ return _MAPBOX_TOKEN[0] or None |
|
179 |
+ |
|
180 |
+ |
|
181 |
+def _postal_from_latlng(lat: float, lng: float) -> str | None: |
|
182 |
+ """Code postal via géocodage inverse Mapbox (adresses sans CP).""" |
|
183 |
+ tok = _mapbox_token() |
|
184 |
+ if not tok: |
|
185 |
+ return None |
|
186 |
+ url = (f"https://api.mapbox.com/geocoding/v5/mapbox.places/{lng},{lat}.json" |
|
187 |
+ f"?types=postcode&country=CA&access_token={tok}") |
|
188 |
+ try: |
|
189 |
+ with urllib.request.urlopen(url, timeout=15) as r: |
|
190 |
+ feats = json.load(r).get("features") or [] |
|
191 |
+ if feats: |
|
192 |
+ return (feats[0].get("text") or "").upper().replace(" ", "") |
|
193 |
+ except Exception: |
|
194 |
+ return None |
|
195 |
+ return None |
| 152 |
196 |
|
| 153 |
197 |
|
| 154 |
198 |
def _montant(d: dict) -> tuple[float | None, float | None, int | None]: |
| 165 |
209 |
|
| 166 |
210 |
|
| 167 |
211 |
def estimate(civic: str | None = None, postal: str | None = None, |
| 168 |
|
− adresse: str | None = None, solve: bool = True) -> dict: |
|
212 |
+ adresse: str | None = None, lat: float | None = None, |
|
213 |
+ lng: float | None = None, uid: str | None = None, |
|
214 |
+ solve: bool = True) -> dict: |
| 169 |
215 |
"""Coût d'électricité annuel estimé à une adresse (cache 6 mois). |
| 170 |
216 |
|
| 171 |
217 |
`solve=False` : ne consulte QUE le cache (aucune résolution de captcha, |
| 172 |
218 |
aucun coût) — utilisé pour l'affichage automatique des fiches. |
| 173 |
|
− Renvoie {"disponible": bool, ...}. |
|
219 |
+ Le code postal est extrait de l'adresse, ou dérivé du couple lat/lng par |
|
220 |
+ géocodage inverse Mapbox si l'adresse n'en contient pas (cas d'Immo-Ka). |
|
221 |
+ Le cache est indexé par `uid` (clé stable de l'annonce) ET par |
|
222 |
+ civique|code postal, pour que l'affichage cache-only fonctionne même |
|
223 |
+ quand l'adresse ne porte pas de code postal. |
| 174 |
224 |
""" |
| 175 |
|
− if not civic or not postal: |
| 176 |
|
− if adresse and (pa := _parse_addr(adresse)): |
| 177 |
|
− civic, postal = pa |
|
225 |
+ # lecture cache par uid d'abord (ne nécessite ni CP ni Mapbox) |
|
226 |
+ if uid: |
|
227 |
+ con = _connect() |
|
228 |
+ row = con.execute( |
|
229 |
+ "SELECT * FROM hydro_cache WHERE uid=? AND cout_annuel IS NOT NULL " |
|
230 |
+ "AND fetched_at>?", (uid, time.time() - TTL)).fetchone() |
|
231 |
+ con.close() |
|
232 |
+ if row: |
|
233 |
+ return {"disponible": True, "adresse": row["adresse"], |
|
234 |
+ "cout_annuel": row["cout_annuel"], |
|
235 |
+ "cout_mensuel": row["cout_mensuel"], |
|
236 |
+ "kwh_annuel": row["kwh"], "cache": True} |
|
237 |
+ |
|
238 |
+ if not civic and adresse: |
|
239 |
+ civic = _civic(adresse) |
|
240 |
+ if not postal and adresse: |
|
241 |
+ cp = re.search(r"([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)", adresse) |
|
242 |
+ if cp: |
|
243 |
+ postal = cp.group(1) |
|
244 |
+ # cache-only : ne pas déclencher Mapbox ; proposer le bouton si un calcul |
|
245 |
+ # est faisable (civique connu + CP OU coordonnées disponibles) |
|
246 |
+ if not solve: |
|
247 |
+ faisable = bool(civic) and (bool(postal) or |
|
248 |
+ (lat is not None and lng is not None)) |
|
249 |
+ return {"disponible": False, |
|
250 |
+ "raison": "non calculé" if faisable else "adresse incomplète", |
|
251 |
+ "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 |
| 178 |
254 |
if not civic or not postal: |
| 179 |
255 |
return {"disponible": False, "raison": "adresse incomplète"} |
| 180 |
256 |
postal = postal.upper().replace(" ", "") |
| 189 |
265 |
con.close() |
| 190 |
266 |
return {"disponible": True, "adresse": row["adresse"], |
| 191 |
267 |
"cout_annuel": row["cout_annuel"], |
| 192 |
|
− "cout_mensuel": row["cout_mensuel"], "cache": True} |
| 193 |
|
− |
| 194 |
|
− if not solve: |
| 195 |
|
− con.close() |
| 196 |
|
− return {"disponible": False, "raison": "non calculé", "en_attente": True} |
|
268 |
+ "cout_mensuel": row["cout_mensuel"], |
|
269 |
+ "kwh_annuel": row["kwh"], "cache": True} |
| 197 |
270 |
|
| 198 |
271 |
if not os.environ.get("HQ_CAPTCHA_KEY"): |
| 199 |
272 |
con.close() |
| 228 |
301 |
con.close() |
| 229 |
302 |
return {"disponible": False, "raison": "montant introuvable"} |
| 230 |
303 |
with con: |
| 231 |
|
− con.execute("INSERT OR REPLACE INTO hydro_cache VALUES (?,?,?,?,?,?)", |
| 232 |
|
− (cle, adr, an, mens, json.dumps(est), time.time())) |
|
304 |
+ con.execute( |
|
305 |
+ "INSERT OR REPLACE INTO hydro_cache " |
|
306 |
+ "(cle, adresse, cout_annuel, cout_mensuel, payload, fetched_at, " |
|
307 |
+ "uid, kwh) VALUES (?,?,?,?,?,?,?,?)", |
|
308 |
+ (cle, adr, an, mens, json.dumps(est), time.time(), uid, kwh)) |
| 233 |
309 |
con.close() |
| 234 |
310 |
return {"disponible": True, "adresse": adr, "cout_annuel": an, |
| 235 |
311 |
"cout_mensuel": mens, "kwh_annuel": kwh, "cache": False} |
| 257 |
333 |
src = sqlite3.connect(f"file:{db}?mode=ro", uri=True) |
| 258 |
334 |
src.row_factory = sqlite3.Row |
| 259 |
335 |
rows = src.execute( |
| 260 |
|
− "SELECT uid, address FROM listings " |
|
336 |
+ "SELECT uid, address, lat, lng FROM listings " |
| 261 |
337 |
"WHERE address IS NOT NULL AND address != '' " |
| 262 |
338 |
"ORDER BY first_seen DESC LIMIT ?", (limit,)).fetchall() |
| 263 |
339 |
src.close() |
| 265 |
341 |
con = _connect() |
| 266 |
342 |
fait = calcules = saut = echec = 0 |
| 267 |
343 |
for r in rows: |
| 268 |
|
− pa = _parse_addr(r["address"]) |
| 269 |
|
− if not pa: |
| 270 |
|
− saut += 1 |
| 271 |
|
− continue |
| 272 |
|
− civic, postal = pa |
| 273 |
|
− cache_row = con.execute( |
| 274 |
|
− "SELECT 1 FROM hydro_cache WHERE cle=? AND fetched_at>?", |
| 275 |
|
− (f"{civic}|{postal.upper().replace(' ', '')}", |
| 276 |
|
− time.time() - TTL)).fetchone() |
| 277 |
|
− if cache_row: |
|
344 |
+ # déjà en cache pour cet uid ? (aucun coût) |
|
345 |
+ 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: |
| 278 |
349 |
fait += 1 |
| 279 |
350 |
continue |
|
351 |
+ civic = _civic(r["address"]) |
|
352 |
+ if not civic or (r["lat"] is None and not _parse_addr(r["address"])): |
|
353 |
+ saut += 1 |
|
354 |
+ continue |
| 280 |
355 |
if calcules >= budget: |
| 281 |
356 |
break |
| 282 |
357 |
con.close() # estimate() ouvre sa propre connexion |
| 283 |
|
− res = estimate(civic=civic, postal=postal) |
|
358 |
+ res = estimate(adresse=r["address"], lat=r["lat"], lng=r["lng"], |
|
359 |
+ uid=r["uid"]) |
| 284 |
360 |
con = _connect() |
| 285 |
361 |
calcules += 1 |
| 286 |
362 |
if not res.get("disponible"): |