SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

feat: matching Vrai-Prix spatial+text durci (civique obligatoire), géocodage batch AQ, filtre prix sur demande, pagination dans l'URL

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 12, 2026) parent 59abdf7

6 changed files +455 −20

modified frontend/src/pages/Home.tsx +25 −5
@@ -3,7 +3,7 @@
3 3 // Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 // pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille + carte
5 5 // -----------------------------------------------------------------------------
6 −import { Suspense, lazy, useEffect, useMemo, useState } from "react";
6 +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
7 7 import { useSearchParams } from "react-router-dom";
8 8 import {
9 9 Facets, Listing, ListingFilters, Stats,
@@ -36,13 +36,12 @@ function pageNumbers(p: number, n: number): (number | "…")[] {
36 36 export default function Home() {
37 37 const [listings, setListings] = useState<Listing[] | null>(null);
38 38 const [total, setTotal] = useState(0);
39 − const [page, setPage] = useState(1); // pagination 12/page
39 + const [params, setParams] = useSearchParams();
40 + const [page, setPage] = useState(Number(params.get("page")) || 1); // 12/page, dans l'URL
40 41 const [facets, setFacets] = useState<Facets | null>(null);
41 42 const [sectors, setSectors] = useState<string[]>([]);
42 43 const [stats, setStats] = useState<Stats | null>(null);
43 44 const [error, setError] = useState<string | null>(null);
44 −
45 − const [params] = useSearchParams();
46 45 const [q, setQ] = useState(params.get("q") ?? "");
47 46 const [city, setCity] = useState(params.get("city") ?? "");
48 47 const [sector, setSector] = useState(params.get("sector") ?? "");
@@ -80,7 +79,28 @@ export default function Home() {
80 79 }, [city]);
81 80
82 81 // revenir à la page 1 quand les filtres changent
83 − useEffect(() => { setPage(1); }, [filters]);
82 + const firstRender = useRef(true);
83 + useEffect(() => {
84 + if (firstRender.current) { firstRender.current = false; return; }
85 + setPage(1);
86 + }, [filters]);
87 +
88 + // synchroniser filtres + page + tri + vue dans l'URL → le retour arrière
89 + // depuis une fiche revient à la MÊME page/filtres.
90 + useEffect(() => {
91 + const p = new URLSearchParams();
92 + const set = (k: string, v: string) => { if (v) p.set(k, v); };
93 + set("q", q); set("city", city); set("sector", sector);
94 + set("property_type", ptype); set("source", source);
95 + set("price_min", priceMin); set("price_max", priceMax);
96 + set("bedrooms_min", bedsMin); set("bathrooms_min", bathsMin);
97 + set("area_min", areaMin);
98 + if (sort && sort !== "recent") p.set("sort", sort);
99 + if (view === "carte") p.set("view", "carte");
100 + if (page > 1) p.set("page", String(page));
101 + setParams(p, { replace: true });
102 + }, [filters, page, view, q, city, sector, ptype, source, priceMin, priceMax,
103 + bedsMin, bathsMin, areaMin, sort, setParams]);
84 104
85 105 // charger la page courante (12 annonces) — remplace la grille
86 106 useEffect(() => {
modified immoka/db.py +3 −1
@@ -179,7 +179,9 @@ def connect() -> sqlite3.Connection:
179 179 con.row_factory = sqlite3.Row
180 180 # WAL + busy_timeout : accès concurrents (watcher + web) sans « db is locked ».
181 181 con.execute("PRAGMA journal_mode=WAL")
182 − con.execute("PRAGMA busy_timeout=60000")
182 + # 120 s : couvre les longues transactions (refresh_dedup ~70 s) sans que les
183 + # autres écrivains (géocodage, vraiprix) ne lèvent « database is locked ».
184 + con.execute("PRAGMA busy_timeout=120000")
183 185 con.execute("PRAGMA synchronous=NORMAL")
184 186 if not _SCHEMA_READY:
185 187 _init_schema(con)
modified immoka/geocode.py +133 −13
@@ -11,6 +11,9 @@
11 11 # -----------------------------------------------------------------------------
12 12 from __future__ import annotations
13 13
14 +import json
15 +import sqlite3
16 +
14 17 import re
15 18 import time
16 19
@@ -241,22 +244,139 @@ def run(limit: int | None = None) -> dict:
241 244 if limit is not None and requests_made >= limit:
242 245 continue
243 246 requests_made += 1
244 − coords = geo.resolve(members[0]["address"], members[0]["city"])
245 − if coords:
246 − for r in members:
247 − con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
248 − (coords[0], coords[1], r["uid"]))
249 − done += len(members)
250 − else:
251 − # introuvable/hors zone : flag pour révision, jamais de coordonnées bidon
252 − for r in members:
253 − con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",
254 − (r["uid"],))
255 − failed += len(members)
256 − con.commit()
247 + try:
248 + coords = geo.resolve(members[0]["address"], members[0]["city"])
249 + if coords:
250 + for r in members:
251 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
252 + (coords[0], coords[1], r["uid"]))
253 + done += len(members)
254 + else:
255 + # introuvable/hors zone : flag ; jamais de coordonnées bidon
256 + for r in members:
257 + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",
258 + (r["uid"],))
259 + failed += len(members)
260 + con.commit()
261 + except sqlite3.OperationalError:
262 + # verrou transitoire (watcher/refresh_dedup) : on saute cette adresse,
263 + # elle sera reprise au prochain passage — jamais crasher tout le run.
264 + try:
265 + con.rollback()
266 + except sqlite3.Error:
267 + pass
268 + time.sleep(1.0)
269 + continue
257 270
258 271 con.close()
259 272 stats = {"geocoded": done, "failed": failed,
260 273 "unique_addresses": len(groupes), "api_requests": requests_made}
261 274 print(f"[immo-ka] geocode {stats}")
262 275 return stats
276 +
277 +
278 +AQ_BATCH_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"
279 + "Adresse_Geocodage/GeocodeServer/geocodeAddresses")
280 +BATCH_SIZE = 200
281 +
282 +
283 +def run_batch(limit: int | None = None) -> dict:
284 + """Géocodage EN LOT via Adresses Québec (`geocodeAddresses`, jusqu'à 1000
285 + adresses/requête) — ~35 requêtes pour tout le parc au lieu de dizaines de
286 + milliers. Beaucoup plus rapide que le mode 1-par-1 (et que Nominatim)."""
287 + con = db.connect()
288 + geo = Geocoder(con) # pour réutiliser _clean / bbox
289 + rows = con.execute(
290 + """SELECT uid, address, city FROM listings
291 + WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0
292 + AND dup_hidden=0 ORDER BY address""").fetchall()
293 + # 1 entrée par immeuble (adresse normalisée) ; on saute les échecs en cache
294 + uniq: dict[str, dict] = {}
295 + for r in rows:
296 + k = norm_key(r["address"])
297 + if not k or len(k) < 6:
298 + continue
299 + u = uniq.setdefault(k, {"address": r["address"], "city": r["city"],
300 + "members": []})
301 + u["members"].append(r["uid"])
302 + pending = []
303 + for k, u in uniq.items():
304 + c = con.execute("SELECT lat,lng,failed FROM geocode_cache WHERE address=?",
305 + (k,)).fetchone()
306 + if c is not None and not c["failed"]: # déjà résolu : appliquer direct
307 + for uid in u["members"]:
308 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
309 + (c["lat"], c["lng"], uid))
310 + continue
311 + if c is not None and c["failed"]:
312 + continue
313 + pending.append((k, u))
314 + con.commit()
315 + if limit is not None:
316 + pending = pending[:limit]
317 +
318 + session = requests.Session()
319 + session.headers["User-Agent"] = USER_AGENT
320 + done = failed = 0
321 + for i in range(0, len(pending), BATCH_SIZE):
322 + chunk = pending[i:i + BATCH_SIZE]
323 + records = {"records": [
324 + {"attributes": {"OBJECTID": j,
325 + "SingleLine": f"{geo._clean(u['address']).split(',')[0].strip()}, "
326 + f"{(u['city'] or 'Québec').strip()}"}}
327 + for j, (_k, u) in enumerate(chunk)]}
328 + try:
329 + # POST obligatoire : le param `addresses` (JSON de N records) est trop
330 + # long pour une URL GET dès quelques dizaines d'adresses.
331 + resp = session.post(AQ_BATCH_URL, data={
332 + "addresses": json.dumps(records, ensure_ascii=False),
333 + "f": "json", "outSR": 4326}, timeout=90)
334 + locs = resp.json().get("locations", [])
335 + except Exception as e:
336 + print(f"[immo-ka] geocode-batch lot {i//BATCH_SIZE} ERREUR: {str(e)[:80]}")
337 + time.sleep(1.0)
338 + continue
339 + by_id = {l["attributes"].get("ResultID"): l for l in locs}
340 + for j, (k, u) in enumerate(chunk):
341 + loc = by_id.get(j)
342 + coords = None
343 + if loc and loc["attributes"].get("Score", 0) >= AQ_MIN_SCORE:
344 + lc = loc.get("location") or {}
345 + try:
346 + cand = (float(lc["y"]), float(lc["x"]))
347 + if _in_bbox(*cand, _bbox_for(u["city"])):
348 + coords = cand
349 + except (KeyError, ValueError, TypeError):
350 + coords = None
351 + try:
352 + if coords:
353 + for uid in u["members"]:
354 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
355 + (coords[0], coords[1], uid))
356 + con.execute(
357 + "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)"
358 + " VALUES (?,?,?,?,0,?) ON CONFLICT(address) DO UPDATE SET"
359 + " lat=excluded.lat, lng=excluded.lng, provider=excluded.provider,"
360 + " failed=0, ts=excluded.ts",
361 + (k, coords[0], coords[1], "adresses_quebec_batch", time.time()))
362 + done += len(u["members"])
363 + else:
364 + for uid in u["members"]:
365 + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", (uid,))
366 + con.execute(
367 + "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)"
368 + " VALUES (?,?,?,?,1,?) ON CONFLICT(address) DO UPDATE SET"
369 + " failed=1, ts=excluded.ts",
370 + (k, None, None, "adresses_quebec_batch", time.time()))
371 + failed += len(u["members"])
372 + con.commit()
373 + except sqlite3.OperationalError:
374 + try: con.rollback()
375 + except sqlite3.Error: pass
376 + time.sleep(1.0)
377 + print(f"[immo-ka] geocode-batch {i+len(chunk)}/{len(pending)} "
378 + f"(résolues {done}, échecs {failed})")
379 + con.close()
380 + stats = {"geocoded": done, "failed": failed, "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE}
381 + print(f"[immo-ka] geocode-batch {stats}")
382 + return stats
added immoka/vraiprix_local.py +282 −0
@@ -0,0 +1,282 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# vraiprix_local.py : appariement LOCAL contre la base Vrai-Prix (vraiprix.db,
5 +# 3,7 M unités d'évaluation avec adresse, lat/lng, estimation, fourchette).
6 +# Une jointure d'adresse (FTS) par annonce → remplit d'un coup :
7 +# · lat/lng manquants (géocodage instantané, sans API externe)
8 +# · l'estimation Vrai-Prix (valeur + P10-P90 + lien /estimation/{id})
9 +# Bien plus rapide que l'API vrai-prix (une requête réseau par annonce).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import json
14 +import os
15 +import re
16 +import sqlite3
17 +import time
18 +import unicodedata
19 +
20 +from . import db
21 +
22 +# Emplacement de la base Vrai-Prix (copiée à côté de immoka.db sur le nœud).
23 +VP_DB = os.environ.get(
24 + "VRAIPRIX_DB",
25 + str((__import__("pathlib").Path(__file__).resolve().parent.parent
26 + / "data" / "vraiprix.db")))
27 +SITE = "https://www.vrai-prix.com"
28 +_BBOX = (44.5, 63.0, -80.0, -56.0) # Québec
29 +
30 +
31 +def _norm(s: str) -> str:
32 + return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower())
33 + if unicodedata.category(c) != "Mn").strip()
34 +
35 +
36 +# mots de « type de voie » ignorés dans l'appariement (variabilité rue/av/boul…)
37 +_VOIE = {"rue", "av", "ave", "avenue", "boul", "boulevard", "bd", "blvd",
38 + "ch", "chemin", "place", "pl", "rang", "rangs", "rg", "montee",
39 + "montée", "mtee", "cote", "côte", "route", "rte", "terrasse", "tsse",
40 + "ter", "impasse", "imp", "croissant", "crois", "croiss", "cours",
41 + "allee", "allée", "prom", "promenade", "carre", "aut", "autoroute",
42 + "de", "du", "des", "la", "le", "les", "l", "d", "et", "sur",
43 + "est", "ouest", "nord", "sud", "st", "ste", "saint", "sainte"}
44 +
45 +_APP_RE = r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+"
46 +
47 +
48 +def _street_words(norm_addr: str) -> set:
49 + """Mots significatifs de la rue (sans n° civique, type de voie, n° d'app.)."""
50 + a = re.sub(_APP_RE, " ", norm_addr.split(",")[0])
51 + a = re.sub(r"[^a-z0-9 ]+", " ", a)
52 + a = re.sub(r"^\s*\d+[a-z]{0,2}(?:\s+\d+)?\s+", " ", a) # civique(s) en tête
53 + toks = [t for t in a.split() if t]
54 + return {t for t in toks if t not in _VOIE and len(t) > 1}
55 +
56 +
57 +def _addr_parts(address: str) -> tuple[list, list]:
58 + """→ (civiques candidats, mots de rue). Gère « 822Z » (suffixe de lettre),
59 + « 102 50 Rue X » (app-civique : les deux nombres sont candidats) et
60 + conserve les rues numériques (« Route 202 », « 117e Avenue »)."""
61 + a = _norm(address).split(",")[0]
62 + a = re.sub(_APP_RE, " ", a)
63 + a = re.sub(r"[^a-z0-9 ]+", " ", a)
64 + toks = [t for t in a.split() if t]
65 + civs = []
66 + while toks and len(civs) < 2:
67 + m = re.match(r"^(\d+)[a-z]{0,2}$", toks[0])
68 + if not m:
69 + break
70 + if civs and not toks[0].isdigit(): # ordinal de rue (2e, 3e…) : garder
71 + break
72 + civs.append(m.group(1))
73 + toks = toks[1:]
74 + words = [t for t in toks if t not in _VOIE and len(t) > 1]
75 + return civs, words
76 +
77 +
78 +def _fts_query(address: str) -> tuple[str, str]:
79 + """Requête FTS AND (n° civique + mots significatifs de la rue)."""
80 + civs, words = _addr_parts(address)
81 + civ = civs[-1] if civs else ""
82 + parts = ([civ] if civ else []) + words
83 + if not parts:
84 + return "", civ
85 + return " AND ".join(f'"{p}"' for p in parts), civ
86 +
87 +
88 +def available() -> bool:
89 + return os.path.exists(VP_DB)
90 +
91 +
92 +def _pack(r) -> dict:
93 + return {
94 + "id": r["id_provinc"], "lat": r["lat"], "lng": r["lng"],
95 + "value": r["est_hedo"] or r["est_2026"], "low": r["p10"], "high": r["p90"],
96 + "confidence": None, "confidence_pct": None,
97 + "url": f"{SITE}/estimation/{r['id_provinc']}",
98 + }
99 +
100 +
101 +def _meters(a1: float, o1: float, a2: float, o2: float) -> float:
102 + """Distance approx. en mètres (équirectangulaire, ~exact à courte portée)."""
103 + import math
104 + dlat = (a2 - a1) * 111_000.0
105 + dlng = (o2 - o1) * 111_000.0 * math.cos(math.radians(a1))
106 + return (dlat * dlat + dlng * dlng) ** 0.5
107 +
108 +
109 +# mots génériques ignorés dans la comparaison de municipalités
110 +_MUNI_GEN = {"saint", "sainte", "ville", "de", "du", "des", "la", "le", "les",
111 + "sur", "au", "aux", "lac", "notre", "dame", "canton", "cantons",
112 + "municipalite", "paroisse", "village", "mont"}
113 +
114 +
115 +def _muni_norm(s: str) -> str:
116 + s = re.sub(r"[^a-z0-9 ]+", " ", _norm(s or ""))
117 + s = re.sub(r"\bst\b", "saint", s)
118 + s = re.sub(r"\bste\b", "sainte", s)
119 + return " ".join(s.split())
120 +
121 +
122 +def _muni_one(nc: str, um: str) -> bool:
123 + if nc in um or um in nc:
124 + return True
125 + return bool((set(nc.split()) - _MUNI_GEN) & (set(um.split()) - _MUNI_GEN))
126 +
127 +
128 +def _muni_match(city: str, unit_muni: str, address: str = "") -> bool:
129 + """Ville de l'annonce vs municipalité du rôle — tolère St/Ste, accents,
130 + arrondissement (« Gatineau Aylmer ») et la ville glissée dans l'adresse
131 + (« 1119 Ch. Dunant, Sainte-Anne-des-Lacs » avec city=« Laurentides »)."""
132 + nc = _muni_norm(city)
133 + if not nc:
134 + return True
135 + um = _muni_norm(unit_muni)
136 + if not um:
137 + return False
138 + cands = [nc] + [_muni_norm(p) for p in _norm(address).split(",")[1:]]
139 + return any(c and _muni_one(c, um) for c in cands)
140 +
141 +
142 +def _concordance(address: str, city: str, r) -> tuple[bool, bool, bool]:
143 + """(civique_ok, rue_ok, municipalité_ok) entre l'annonce et l'unité."""
144 + civs, words = _addr_parts(address)
145 + ua = _norm(r["adresse"])
146 + civ_ok = False
147 + mr = re.match(r"\s*(\d+)\s*-\s*(\d+)", ua)
148 + ms = re.match(r"\s*(\d+)", ua)
149 + for c in civs:
150 + ci = int(c)
151 + if mr:
152 + lo, hi = int(mr.group(1)), int(mr.group(2))
153 + civ_ok = min(lo, hi) <= ci <= max(lo, hi)
154 + elif ms:
155 + civ_ok = ms.group(1) == c
156 + if civ_ok:
157 + break
158 + iwords = set(words)
159 + rue_ok = bool(iwords) and bool(iwords & _street_words(ua))
160 + muni_ok = _muni_match(city, r["municipalite"] or "", address)
161 + return civ_ok, rue_ok, muni_ok
162 +
163 +
164 +_COLS = ("id_provinc, adresse, municipalite, lat, lng, est_hedo, est_2026, p10, p90")
165 +
166 +
167 +def _match(vp: sqlite3.Connection, address: str, city: str,
168 + lat: float | None = None, lng: float | None = None) -> dict | None:
169 + """Apparie une annonce à une unité Vrai-Prix par :
170 + 1) PROXIMITÉ SPATIALE (si lat/lng) — l'unité la plus proche, validée par
171 + l'adresse (text mining : civique/rue/municipalité) ;
172 + 2) sinon recherche d'adresse FTS stricte.
173 + Ne retourne un match que s'il est fiable (mieux vaut rien qu'un faux)."""
174 + # 1) spatial + text-mining : candidats triés par distance
175 + if lat is not None and lng is not None:
176 + d = 0.0022 # ~±250 m
177 + try:
178 + cands = vp.execute(
179 + f"SELECT {_COLS} FROM units"
180 + " WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?",
181 + (lat - d, lat + d, lng - d, lng + d)).fetchall()
182 + except sqlite3.OperationalError:
183 + cands = []
184 + cands = [r for r in cands if r["lat"] is not None]
185 + cands.sort(key=lambda r: _meters(lat, lng, r["lat"], r["lng"]))
186 + has_civ = bool(_addr_parts(address)[0])
187 + # ≤20 m du plus proche = même propriété (seulement si l'annonce a un
188 + # n° civique : un terrain géocodé près d'une maison ne doit pas hériter
189 + # de la valeur du voisin)
190 + if (cands and has_civ
191 + and _meters(lat, lng, cands[0]["lat"], cands[0]["lng"]) <= 20):
192 + return _pack(cands[0])
193 + for r in cands:
194 + dm = _meters(lat, lng, r["lat"], r["lng"])
195 + civ_ok, rue_ok, muni_ok = _concordance(address, city, r)
196 + # au-delà de 20 m : le CIVIQUE doit concorder (la « même rue » seule
197 + # apparie l'immeuble d'à côté → mauvais merge)
198 + if civ_ok and muni_ok and (dm <= 100 or rue_ok):
199 + return _pack(r)
200 +
201 + # 2) repli : recherche d'adresse FTS stricte (avec relances)
202 + civs, words = _addr_parts(address)
203 + tries = []
204 + for c in (civs[::-1] or [""]): # civique(s), du plus probable au moins
205 + parts = ([c] if c else []) + words
206 + if parts:
207 + tries.append(" AND ".join(f'"{p}"' for p in parts))
208 + if civs and words:
209 + tries.append(" AND ".join(f'"{p}"' for p in words)) # rue seule (dernier recours)
210 + seen = set()
211 + for q in tries:
212 + if q in seen:
213 + continue
214 + seen.add(q)
215 + try:
216 + rows = vp.execute(
217 + f"SELECT u.{_COLS.replace(', ', ', u.')} FROM units_fts f"
218 + " JOIN units u ON u.rowid=f.rowid WHERE units_fts MATCH ? LIMIT 50",
219 + (q,)).fetchall()
220 + except sqlite3.OperationalError:
221 + return None
222 + for r in rows:
223 + civ_ok, rue_ok, muni_ok = _concordance(address, city, r)
224 + if civ_ok and rue_ok and muni_ok:
225 + return _pack(r)
226 + return None
227 +
228 +
229 +def run(limit: int | None = None, revalidate: bool = False) -> dict:
230 + """Apparie les annonces à la base Vrai-Prix : remplit lat/lng (si manquant)
231 + et l'estimation. `limit` borne le nombre d'annonces traitées.
232 + `revalidate=True` : re-vérifie AUSSI les annonces déjà appariées (corrige les
233 + mauvais merges après durcissement du matcher)."""
234 + if not available():
235 + print(f"[immo-ka] vraiprix_local: base absente ({VP_DB})")
236 + return {"error": "vraiprix.db absent"}
237 + vp = sqlite3.connect(f"file:{VP_DB}?mode=ro", uri=True)
238 + vp.row_factory = sqlite3.Row
239 + con = db.connect()
240 + where = ("active=1 AND dup_hidden=0 AND address<>''" if revalidate
241 + else "active=1 AND dup_hidden=0 AND address<>''"
242 + " AND (vraiprix IS NULL OR vraiprix='{}')")
243 + rows = con.execute(
244 + f"SELECT uid, address, city, lat, lng FROM listings WHERE {where}"
245 + " ORDER BY first_seen DESC").fetchall()
246 + if limit is not None:
247 + rows = rows[:limit]
248 +
249 + matched = geoloc = miss = 0
250 + for i, r in enumerate(rows):
251 + m = _match(vp, r["address"], r["city"], r["lat"], r["lng"])
252 + try:
253 + if m and m["value"]:
254 + est = {k: m[k] for k in ("id", "value", "low", "high",
255 + "confidence", "confidence_pct", "url")}
256 + con.execute("UPDATE listings SET vraiprix=? WHERE uid=?",
257 + (json.dumps(est, ensure_ascii=False), r["uid"]))
258 + # géocodage gratuit : coordonnées de l'unité si l'annonce n'en a pas
259 + if (r["lat"] is None and m["lat"] is not None
260 + and _BBOX[0] <= m["lat"] <= _BBOX[1]
261 + and _BBOX[2] <= m["lng"] <= _BBOX[3]):
262 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
263 + (m["lat"], m["lng"], r["uid"]))
264 + geoloc += 1
265 + matched += 1
266 + else:
267 + con.execute("UPDATE listings SET vraiprix='{}' WHERE uid=?", (r["uid"],))
268 + miss += 1
269 + if i % 200 == 0:
270 + con.commit()
271 + except sqlite3.OperationalError:
272 + try:
273 + con.rollback()
274 + except sqlite3.Error:
275 + pass
276 + time.sleep(1.0)
277 + con.commit()
278 + con.close()
279 + vp.close()
280 + out = {"matched": matched, "geolocated": geoloc, "no_match": miss}
281 + print(f"[immo-ka] vraiprix_local {out}")
282 + return out
modified immoka/web.py +4 −1
@@ -40,7 +40,10 @@ _sync_lock = threading.Lock()
40 40 # db.refresh_dedup après chaque sync) marque les doublons de sous-agences. La
41 41 # lecture est ainsi instantanée (index) au lieu d'un sous-select corrélé par
42 42 # ligne (~300 s sur 75 k lignes). Voir db.refresh_dedup pour la règle.
43 −DEDUP_CLAUSE = " AND dup_hidden=0"
43 +# + `price IS NOT NULL` : on masque partout les annonces « Prix sur demande »
44 +# (sans prix) — grille, carte, stats, sources. La fiche détail par uid reste
45 +# accessible en direct.
46 +DEDUP_CLAUSE = " AND dup_hidden=0 AND price IS NOT NULL"
44 47
45 48
46 49 def _row_to_dict(row) -> dict:
modified run.py +8 −0
@@ -44,6 +44,9 @@ def main() -> None:
44 44 print(sid)
45 45 print(f"-- {len(CONNECTORS)} connecteur(s)")
46 46 elif cmd == "geocode":
47 + from immoka import geocode
48 + geocode.run_batch(int(sys.argv[2]) if len(sys.argv) > 2 else None)
49 + elif cmd == "geocode1": # ancien mode 1-par-1 (+ repli Nominatim)
47 50 from immoka import geocode
48 51 geocode.run(int(sys.argv[2]) if len(sys.argv) > 2 else None)
49 52 elif cmd == "poi":
@@ -55,6 +58,11 @@ def main() -> None:
55 58 elif cmd == "vraiprix":
56 59 from immoka import vraiprix
57 60 vraiprix.run(int(sys.argv[2]) if len(sys.argv) > 2 else None)
61 + elif cmd == "vraiprix_local": # appariement local (vraiprix.db) : rapide
62 + from immoka import vraiprix_local
63 + reval = "reval" in sys.argv[2:]
64 + lim = next((int(a) for a in sys.argv[2:] if a.isdigit()), None)
65 + vraiprix_local.run(lim, revalidate=reval)
58 66 elif cmd == "serve":
59 67 import uvicorn
60 68 port = int(sys.argv[2]) if len(sys.argv) > 2 else 8090
61 69