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%

Vague 2 : jointure au rôle d'évaluation, fiches Sutton/RLP/LesPAC enrichies, days_on_market, dédup par adresse

- vraiprix_local : le JSON vraiprix porte désormais le rôle d'évaluation
  (valeur_role/terrain/bâtiment, année, superficies officielles) ; repli des
  colonnes year_built/lot_sqft (provenance details.*_source=role, jamais pour
  le terrain des copropriétés) ; enrich_role() = backfill par id_provinc
  (64 972 fiches, year_built 20 %→67 %, lot_sqft 17 %→62 %) ; limite
  d'appariement par cycle 2000→5000 + enrich_role 20 000/cycle.
- db.sync_source : COALESCE sur year_built/area_sqft/lot_sqft (les
  enrichissements ne sont plus écrasés par null).
- Sutton (v5) : année (Construction :), Aire habitable/Terrain superficie,
  taxes, évaluation municipale, frais de copropriété, revenus (plex).
- Royal LePage (v2) : parse complet direct (sans Firecrawl) — description
  (Remarques), Bâti en, superficies (garde-fou aménagée≤6000 pi²), salles
  d'eau, taxes/évaluations, frais de condo, listed_at, galerie complète.
- LesPAC (v2) : toutes les boîtes de caractéristiques (pas juste la 1re),
  salles de bain/d'eau, superficies + lot depuis Dimension du terrain,
  cityLabel, listed_at (publicReleaseTimestamp).
- Kijiji : details.listed_at (activationDate). DuProprio : lat/lng du JSON-LD.
- API : days_on_market calculé (first_seen) sur listings + fiche.
- Dédup 3e passe : inter-sources par adresse normalisée + type + prix ±1 %
  (garde-fous : apt/unité identique requis, même source en double = ignoré,
  autorité primaire > _ag_ > petites annonces) — 1 748 doublons masqués.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 7911d58

11 changed files +498 −31

modified README.md +5 −5
@@ -37,7 +37,7 @@
37 37
38 38 C'est le pendant « à vendre » de [Lou-Ka](https://www.lou-ka.com) (location) — même architecture, schéma adapté à la vente. Immo-Ka fait partie de l'univers **Groupe KA** (Lou-Ka, Auto-Ka, Fabri-Ka, Food-Ka, Ora-Ka, Vrai-Prix…) : compte **KA ID** unique et **favoris partagés** entre toutes les plateformes.
39 39
40 −**Fiches enrichies** : galerie photo complète · caractéristiques Centris · pièces & dimensions · **estimation de valeur Vrai-Prix** (fourchette + confiance + analyse) · **statistiques de quartier** (recensement StatCan, proximité, îlot de chaleur) · **carte 3D Ka Maps** · historique de prix · favoris **KA ID** · **référencement programmatique** (une page indexable par ville et par type, HTML complet rendu côté serveur).
40 +**Fiches enrichies** : galerie photo complète · caractéristiques Centris · pièces & dimensions · **estimation de valeur Vrai-Prix** (fourchette + confiance + analyse) · **rôle d'évaluation foncière** (valeur au rôle terrain/bâtiment, année de construction et superficies officielles — jointes localement depuis les 3,7 M d'unités de la base Vrai-Prix) · **jours sur le marché** · **statistiques de quartier** (recensement StatCan, proximité, îlot de chaleur) · **carte 3D Ka Maps** · historique de prix · favoris **KA ID** · **référencement programmatique** (une page indexable par ville et par type, HTML complet rendu côté serveur).
41 41
42 42 ## Aperçu
43 43
@@ -109,7 +109,7 @@ Les sous-agences servent de plan B : si le flux central d'une bannière tombe, e
109 109 - **Frontend** : **React 18 + Vite + TypeScript** · react-router · design system **ka-ui** (badge + footer communs du Groupe KA)
110 110 - **Cartographie** : **Ka Maps** — framework carto partagé du Groupe KA sur moteur **Mapbox GL 3D**
111 111 - **Ingestion** : **92 connecteurs** (API JSON internes — Meilisearch, Algolia, source.immo, wp-json —, JSON-LD, sitemaps, **Firecrawl** pour les sites SPA/anti-bot)
112 −- **Enrichissement** : géocodage avec cache, POI de proximité, quartier **StatCan/INSPQ**, estimations **Vrai-Prix**
112 +- **Enrichissement** : géocodage avec cache, POI de proximité, quartier **StatCan/INSPQ**, estimations **Vrai-Prix** + **rôle d'évaluation** (jointure d'adresse locale : valeurs officielles, année, superficies — en repli des colonnes quand la source ne les donne pas)
113 113 - **Auth** : **SSO KA ID** (JWT HS256 du hub groupe-ka.com, stdlib pure)
114 114 - **Exploitation** : **PM2** + tunnel **ngrok**
115 115
@@ -138,8 +138,8 @@ flowchart LR
138 138 |---|---|---|
139 139 | **Connecteurs** | 1 module par source : API JSON interne (Meilisearch, Algolia, source.immo, wp-json), JSON-LD, sitemap, ou Firecrawl pour les sites SPA/anti-bot ; registres auto-générateurs pour les bannières à sous-agences | `immoka/connectors/*.py` |
140 140 | **Schéma** | `PropertyListing` : adresse, secteur, ville, région, type canonique, prix, chambres/SdB, superficies, année, MLS, courtier, pièces (dimensions), **toutes les photos**, caractéristiques structurées | `immoka/schema.py`, `immoka/normalize.py` |
141 −| **Diff engine** | upsert par hash de contenu, délai de grâce (2 syncs), détection de dérive par source, historique de prix, **déduplication inter-agences par n° Centris** | `immoka/db.py`, `immoka/ingest.py` |
142 −| **Enrichissement** | géocodage avec cache, commodités de proximité (POI), statistiques de quartier StatCan/INSPQ, estimations Vrai-Prix | `immoka/geocode.py`, `immoka/poi.py`, `immoka/quartier.py`, `immoka/vraiprix*.py` |
141 +| **Diff engine** | upsert par hash de contenu, délai de grâce (2 syncs), détection de dérive par source, historique de prix, **déduplication inter-agences par n° Centris** + **dédup inter-sources par adresse** (même adresse normalisée + type + prix ±1 %, garde-fou apt/unité) | `immoka/db.py`, `immoka/ingest.py` |
142 +| **Enrichissement** | géocodage avec cache, commodités de proximité (POI), statistiques de quartier StatCan/INSPQ, estimations Vrai-Prix + rôle d'évaluation (year_built/lot_sqft en repli, provenance `details.*_source`) | `immoka/geocode.py`, `immoka/poi.py`, `immoka/quartier.py`, `immoka/vraiprix*.py` |
143 143 | **API** | filtres ville/secteur/région/type/agence/prix/chambres/superficie, facettes, stats, GeoJSON borné par bbox pour la carte | `immoka/web.py` |
144 144 | **SEO** | rendu HTML serveur par route, pages programmatiques, sitemaps, robots.txt, 301/410/404 | `immoka/seo.py` |
145 145 | **Auth & favoris** | SSO KA ID (JWT HS256 du hub, stdlib pure), profil hub, favoris centraux | `immoka/auth.py`, `immoka/hubprofile.py`, `immoka/hubfav.py`, `immoka/favorites.py` |
@@ -163,7 +163,7 @@ Toutes les réponses sont en **JSON**, compressées (gzip), CORS ouvert.
163 163 | Endpoint | Description |
164 164 |---|---|
165 165 | `GET /api/listings` | recherche paginée — filtres `city`, `sector`, `region`, `property_type`, `source`, `price_min/max`, `bedrooms_min`, `bathrooms_min`, `area_min`, `q`, `sort`, `limit`, `offset` |
166 −| `GET /api/listings/{uid}` | fiche complète : photos, caractéristiques, pièces, historique de prix, POI, quartier, Vrai-Prix |
166 +| `GET /api/listings/{uid}` | fiche complète : photos, caractéristiques, pièces, historique de prix, `days_on_market` (+ `details.listed_at` si la source la donne), POI, quartier, Vrai-Prix + rôle d'évaluation |
167 167 | `GET /api/listings.geojson` | marqueurs de carte allégés, bornés par `bbox=ouest,sud,est,nord` |
168 168 | `GET /api/facets` | valeurs distinctes (villes, secteurs, types, sources) pour les filtres |
169 169 | `GET /api/sources` · `GET /api/agencies` | registre des sources et arbre bannière → sous-agences |
modified immoka/connectors/duproprio.py +15 −0
@@ -121,6 +121,21 @@ def _parse_dp_detail(html: str) -> dict:
121 121 if d.get("description"):
122 122 out["description"] = _h.unescape(str(d["description"])).strip()[:4000]
123 123 break
124 + # coordonnées GPS du JSON-LD Residence (geo) — géocodage gratuit
125 + for block in _LDPROD_RE.findall(html):
126 + try:
127 + d = json.loads(block)
128 + except ValueError:
129 + continue
130 + geo = d.get("geo") if isinstance(d, dict) else None
131 + if isinstance(geo, dict):
132 + try:
133 + lat, lng = float(geo.get("latitude")), float(geo.get("longitude"))
134 + except (TypeError, ValueError):
135 + continue
136 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
137 + out["lat"], out["lng"] = lat, lng
138 + break
124 139 # galerie COMPLÈTE : le JSON « "photos":[{...}] » de la page liste toutes les
125 140 # photos (le JSON-LD/HTML n'en montre qu'une). On prend la plus haute résolution.
126 141 imgs, seen = [], set()
modified immoka/connectors/kijiji.py +7 −0
@@ -163,6 +163,12 @@ class KijijiConnector(BaseConnector):
163 163 images = []
164 164 for u in it.get("imageUrls") or []:
165 165 images.append(re.sub(r"rule=kijijica-\d+-", "rule=kijijica-640-", u))
166 + details = {}
167 + # date de mise en ligne : activationDate = 1re publication (sortingDate
168 + # est re-bumpée par les remontées/TOP AD)
169 + posted = str(it.get("activationDate") or it.get("sortingDate") or "")
170 + if re.match(r"\d{4}-\d{2}-\d{2}", posted):
171 + details["listed_at"] = posted[:10]
166 172 return PropertyListing(
167 173 source=self.source_id,
168 174 external_id=lid,
@@ -174,6 +180,7 @@ class KijijiConnector(BaseConnector):
174 180 price=price,
175 181 price_label=f"{price:,.0f} $".replace(",", " ") if price else "",
176 182 description=(it.get("description") or "")[:2000],
183 + details=details,
177 184 images=images,
178 185 lat=coords.get("latitude"),
179 186 lng=coords.get("longitude"),
modified immoka/connectors/lespac.py +74 −12
@@ -53,11 +53,20 @@ class LesPacConnector(BaseConnector):
53 53 def as_int(label):
54 54 m = re.search(r"\d+", chars.get(label, ""))
55 55 return int(m.group(0)) if m else None
56 − # ville = 1er segment du chemin de l'annonce
56 + # ville : cityLabel (accents corrects), sinon 1er segment du chemin
57 57 seg = url.replace(BASE + "/", "").split("/")
58 − city = seg[0].replace("-", " ").title() if seg else ""
58 + city = r.get("cityLabel") or (seg[0].replace("-", " ").title() if seg else "")
59 59 images = [i["formattableImageUrl"].replace("%FORMAT%", "zoomedGallery")
60 60 for i in r.get("images") or [] if i.get("formattableImageUrl")]
61 + details = {}
62 + ts = r.get("publicReleaseTimestamp") # date de mise en ligne (ms epoch)
63 + if ts:
64 + import datetime
65 + try:
66 + details["listed_at"] = datetime.datetime.fromtimestamp(
67 + ts / 1000.0, datetime.timezone.utc).strftime("%Y-%m-%d")
68 + except (ValueError, OSError, OverflowError):
69 + pass
61 70 return PropertyListing(
62 71 source=self.source_id,
63 72 external_id=lid,
@@ -72,6 +81,7 @@ class LesPacConnector(BaseConnector):
72 81 year_built=as_int("Année de construction"),
73 82 description=(r.get("description") or "")[:2000],
74 83 features=[f"{k} : {v}" for k, v in chars.items()],
84 + details=details,
75 85 images=images,
76 86 broker_name=r.get("advertiserName") or "LesPAC (particuliers)",
77 87 agency="LesPAC Québec",
@@ -95,7 +105,9 @@ class LesPacConnector(BaseConnector):
95 105 out.setdefault(lst.uid, lst)
96 106 page += 1
97 107 listings = list(out.values())
98 − du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v1")
108 + # v2 = TOUTES les boîtes de caractéristiques (l'ancien parseur ne lisait
109 + # que la 1re) + salles de bain/d'eau + superficies (dimensions du terrain)
110 + du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v2")
99 111 return listings
100 112
101 113
@@ -103,6 +115,34 @@ def _clean(s: str) -> str:
103 115 return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip()
104 116
105 117
118 +def _area_pi2(value: str) -> float | None:
119 + """« 1 200 pi2 » / « 111 m2 » -> pi² (via la normalisation commune)."""
120 + from ..normalize import parse_area_sqft
121 + return parse_area_sqft(value)
122 +
123 +
124 +_DIMS_RE = re.compile(r"([\d\s]+(?:,\d+)?)\s*x\s*([\d\s]+(?:,\d+)?)\s*(pieds|m[èe]tres)",
125 + re.I)
126 +
127 +
128 +def _dims_pi2(value: str) -> float | None:
129 + """« 15,24 x 30,48 mètres » / « 50 x 100 pieds » -> superficie en pi²."""
130 + m = _DIMS_RE.search(value or "")
131 + if not m:
132 + return None
133 + try:
134 + a = float(m.group(1).replace(" ", "").replace(",", "."))
135 + b = float(m.group(2).replace(" ", "").replace(",", "."))
136 + except ValueError:
137 + return None
138 + if a <= 0 or b <= 0 or a > 100000 or b > 100000:
139 + return None
140 + s = a * b
141 + if m.group(3).lower().startswith("m"):
142 + s *= 10.7639
143 + return round(s) if s >= 300 else None # < 300 pi² : dimensions suspectes
144 +
145 +
106 146 def _parse_lespac_detail(html: str) -> dict:
107 147 """Fiche LesPAC : description complète, adresse civique, caractéristiques
108 148 (boîte « Caractéristiques » : <p><span>Label</span><span>Valeur</span></p>)
@@ -114,16 +154,22 @@ def _parse_lespac_detail(html: str) -> dict:
114 154 if desc:
115 155 out["description"] = desc[:6000]
116 156 features, details = [], {}
117 − mbox = re.search(r'>Caractéristiques</p>\s*<div class="box">(.*?)</div>',
118 − html, re.S)
119 − if mbox:
157 + # section « Caractéristiques » -> fin des boîtes : PLUSIEURS <div class="box">
158 + # (types, équipements, description des pièces…) — on lit toutes les rangées
159 + # <p><span>Label</span> <span>Valeur</span> jusqu'à la bannière suivante.
160 + i = html.find(">Caractéristiques</p>")
161 + if i >= 0:
162 + j = html.find("pub-middle-listing-detail", i)
163 + seg = html[i:j if j > i else i + 30000]
120 164 for lm, vm in re.findall(r"<p><span>(.*?)</span>\s*<span>(.*?)</span>",
121 − mbox.group(1), re.S):
165 + seg, re.S):
122 166 label, value = _clean(lm), _clean(vm)
123 − if not label or not value:
167 + if not label or not value or len(label) > 45 or len(value) > 120:
124 168 continue
125 − features.append(f"{label} : {value}")
126 − details[label] = value
169 + if label not in details:
170 + features.append(f"{label} : {value}")
171 + details[label] = value
172 + low = label.lower()
127 173 if label == "Adresse" and re.match(r"\s*\d", value):
128 174 out["address"] = value
129 175 elif label == "Année":
@@ -132,14 +178,30 @@ def _parse_lespac_detail(html: str) -> dict:
132 178 out["year_built"] = int(my.group(0))
133 179 elif label == "Type de propriété":
134 180 out["property_type"] = value
135 − elif "chambre" in label.lower():
181 + elif "chambre" in low:
136 182 mn = re.search(r"\d+", value)
137 183 if mn:
138 184 out["bedrooms"] = int(mn.group(0))
139 − elif "salle" in label.lower() and "bain" in label.lower():
185 + elif "salle" in low and "eau" in low and "bain" not in low:
186 + mn = re.search(r"\d+", value)
187 + if mn and int(mn.group(0)) > 0:
188 + out["powder_rooms"] = int(mn.group(0))
189 + elif "salle" in low and "bain" in low:
140 190 mn = re.search(r"\d+", value)
141 191 if mn:
142 192 out["bathrooms"] = int(mn.group(0))
193 + elif "superficie" in low and "terrain" in low:
194 + v = _area_pi2(value)
195 + if v:
196 + out["lot_sqft"] = v
197 + elif "superficie" in low:
198 + v = _area_pi2(value)
199 + if v:
200 + out["area_sqft"] = v
201 + elif label == "Dimension du terrain":
202 + v = _dims_pi2(value)
203 + if v:
204 + out["lot_sqft"] = v
143 205 if features:
144 206 out["features"] = features
145 207 if details:
modified immoka/connectors/royal_lepage.py +103 −2
@@ -74,8 +74,11 @@ class RoyalLepageConnector(BaseConnector):
74 74 for name, lat, lng in ANCHORS:
75 75 self._search_anchor(name, lat, lng, by_id)
76 76 listings = list(by_id.values())
77 − # bureau (agency) via la fiche détail — plafonné, cache accumulé
78 − du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_office, key="office1")
77 + # fiche détail (server-rendered, requêtes directes) : description
78 + # (« Remarques »), année (« Bâti en »), superficies, salles d'eau,
79 + # taxes/évaluations, frais de copropriété, date d'inscription, bureau.
80 + # v2 = parse complet (l'ancien « office1 » ne captait que le bureau).
81 + du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_detail, key="v2")
79 82 for lst in listings:
80 83 office = lst.details.pop("__agency", "")
81 84 if office:
@@ -154,3 +157,101 @@ def _parse_rlp_office(html: str) -> dict:
154 157 return {}
155 158 office = _html.unescape(m.group(1)).strip()
156 159 return {"details": {"__agency": office}} if office else {}
160 +
161 +
162 +def _sqft(val: str) -> float | None:
163 + """« 58932.38 Pieds carrés » / « 1163 Pieds carrés » / « 108 Mètres carrés »
164 + -> pi² (les fiches RLP écrivent l'unité en toutes lettres)."""
165 + m = re.search(r"([\d\s ]+(?:[.,]\d+)?)\s*(pieds|m[èe]tres)\s+carr",
166 + val or "", re.I)
167 + if not m:
168 + return None
169 + try:
170 + v = float(m.group(1).replace(" ", "").replace(" ", "").replace(",", "."))
171 + except ValueError:
172 + return None
173 + if v <= 0:
174 + return None
175 + return round(v) if m.group(2).lower().startswith("pied") else round(v * 10.7639)
176 +
177 +
178 +# libellés « Libellé : | Valeur » de la fiche RLP -> clé details
179 +_RLP_DETAILS = [
180 + "Frais de copropriété", "Taxes municipales", "Taxe scolaire",
181 + "Taxes annuelles totales", "Évaluation du terrain",
182 + "Évaluation de l'immeuble", "Évaluation totale", "Superficie habitable (approx)",
183 + "Superficie totale aménagée", "Dimensions du terrain", "Zonage", "Chauffage",
184 + "Sous-sol", "Toiture", "Style", "Type de structure",
185 + "Nbre d'espaces de stationnement", "Vue", "Égout", "Piscine",
186 +]
187 +_RLP_STOPS = ("Particularités du bâtiment", "Caractéristiques de", "Chauffage",
188 + "Détails |", "Carte |", "Données démographiques")
189 +
190 +
191 +def _parse_rlp_detail(html: str) -> dict:
192 + """Fiche Royal LePage (server-rendered) : description, année, superficies,
193 + salles d'eau, taxes/évaluations, frais de condo, date d'inscription, bureau."""
194 + out: dict = _parse_rlp_office(html)
195 + flat = _html.unescape(re.sub(r"<[^>]+>", " | ", html))
196 + flat = re.sub(r"(\s*\|\s*)+", " | ", re.sub(r"[ \t\r\n]+", " ", flat))
197 +
198 + def field(label: str) -> str:
199 + m = re.search(re.escape(label) + r"\s*:?\s*\|\s*([^|]{1,70})\s*\|", flat)
200 + return m.group(1).strip() if m else ""
201 +
202 + details = out.setdefault("details", {})
203 + for label in _RLP_DETAILS:
204 + v = field(label)
205 + if v and v.rstrip(" :") not in _RLP_DETAILS:
206 + details[label] = v
207 +
208 + # description = section « Remarques : » (paragraphes séparés par des |)
209 + md = re.search(r"Remarques\s*:\s*\|\s*(.{80,8000}?)\s*\|\s*(?:"
210 + + "|".join(re.escape(s) for s in _RLP_STOPS) + ")", flat)
211 + if md:
212 + desc = re.sub(r"\s*\|\s*", "\n", md.group(1)).strip()
213 + if len(desc) > 60:
214 + out["description"] = desc[:6000]
215 +
216 + my = re.search(r"Bâti en\s*:?\s*\|\s*((?:1[6-9]|20)\d{2})\b", flat)
217 + if my:
218 + out["year_built"] = int(my.group(1))
219 + mb = re.search(r"Chambres\s*:?\s*\|\s*(\d+)\b", flat)
220 + if mb:
221 + out["bedrooms"] = int(mb.group(1))
222 + ms = re.search(r"Salle\(s\) de bains\s*:?\s*\|\s*(\d+)\b", flat)
223 + if ms:
224 + out["bathrooms"] = int(ms.group(1))
225 + mp = re.search(r"Salle\(s\) d'eau\s*:?\s*\|\s*(\d+)\b", flat)
226 + if mp and int(mp.group(1)) > 0:
227 + out["powder_rooms"] = int(mp.group(1))
228 + area = _sqft(details.get("Superficie habitable (approx)", ""))
229 + if not area:
230 + # « Superficie totale aménagée » désigne parfois le TERRAIN aménagé :
231 + # on ne s'en sert que si la valeur est plausible pour de l'habitable
232 + a2 = _sqft(details.get("Superficie totale aménagée", ""))
233 + if a2 and a2 <= 6000:
234 + area = a2
235 + if area:
236 + out["area_sqft"] = area
237 + lot = _sqft(details.get("Dimensions du terrain", ""))
238 + if lot:
239 + out["lot_sqft"] = lot
240 +
241 + # date d'inscription (JSON-LD datePosted « 2026/08/12 »)
242 + mdp = re.search(r'"datePosted"\s*:\s*"(\d{4})/(\d{2})/(\d{2})"', html)
243 + if mdp:
244 + details["listed_at"] = "-".join(mdp.groups())
245 +
246 + # galerie : photos jumplisting (la carte liste n'en donne qu'une)
247 + imgs, seen = [], set()
248 + for m in re.finditer(r'(//rlp\.jumplisting\.com/photos/[^"\'\s]+?\.jpg)', html):
249 + u = "https:" + m.group(1)
250 + if u not in seen:
251 + seen.add(u)
252 + imgs.append(u)
253 + if len(imgs) > 1:
254 + out["images"] = imgs[:60]
255 + if not details:
256 + out.pop("details", None)
257 + return out
modified immoka/connectors/sutton.py +46 −1
@@ -69,7 +69,9 @@ class SuttonConnector(BaseConnector):
69 69 # fiche détail : prix, chambres/sdb, galerie complète (~80 photos),
70 70 # description, pièces, inclusions, caractéristiques — plafonné, cache
71 71 # accumulé entre cycles (tout le parc finit enrichi au fil des syncs).
72 − du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v4")
72 + # v5 = + année (« Construction : »), superficies (Aire habitable /
73 + # Terrain superficie), taxes, évaluation, frais de copropriété, revenus
74 + du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v5")
73 75 for lst in listings:
74 76 office = lst.details.pop("__agency", "")
75 77 lst.agency = office or "Groupe Sutton Québec"
@@ -246,6 +248,18 @@ def _merge(dst: PropertyListing, src: PropertyListing) -> None:
246 248 dst.images = src.images
247 249
248 250
251 +# libellés des tableaux de la fiche Sutton retenus dans details (les lignes de
252 +# pièces « Salon 12.8x11.10 P »… sont déjà captées à part dans details.pieces)
253 +_TABLE_LABELS = {
254 + "Aire habitable", "Terrain superficie", "Terrain dim.", "Batisse dim.",
255 + "Frais de copropriété", "Revenu", "Taxes municipales", "Taxes scolaires",
256 + "Coût d'énergie", "Zonage", "Sous-sol", "Toiture", "Fondation",
257 + "Mode de chauffage", "Énergie pour le chauffage",
258 + "Approvisionnement en eau", "Système d'égouts", "Stationnement (total)",
259 + "Garage", "Piscine", "Proximité", "Fenêtres", "Revêtements",
260 +}
261 +
262 +
249 263 def parse_sutton_detail(html: str) -> dict:
250 264 """Fiche Sutton : galerie complète, description, pièces (dimensions), inclusions."""
251 265 out: dict = {}
@@ -294,6 +308,37 @@ def parse_sutton_detail(html: str) -> dict:
294 308 if rooms:
295 309 out.setdefault("details", {})["pieces"] = rooms[:20]
296 310
311 + # année de construction (bloc « Construction : 1975 » de l'entête)
312 + my = re.search(r"Construction\s*:\s*((?:1[6-9]|20)\d{2})\b", text)
313 + if my:
314 + out["year_built"] = int(my.group(1))
315 +
316 + # tableaux « Dimensions / Caractéristiques / Dépenses » (<tr><td>l</td><td>v</td>)
317 + det2: dict = {}
318 + for lm, vm in re.findall(r"<tr>\s*<td>(.*?)</td>\s*<td>(.*?)</td>", html, re.S):
319 + label = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", lm))).strip()
320 + val = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", vm))).strip()
321 + if not label or not val or len(label) > 45 or len(val) > 90:
322 + continue
323 + if label in _TABLE_LABELS:
324 + det2[label] = val
325 + from ..normalize import parse_area_sqft, parse_lot_sqft
326 + if det2.get("Aire habitable"):
327 + a = parse_area_sqft(det2["Aire habitable"]) # « 188 MC (2024 pi2) »
328 + if a:
329 + out["area_sqft"] = a
330 + if det2.get("Terrain superficie"):
331 + t = parse_lot_sqft(det2["Terrain superficie"]) # « 685.4 MC (7378 pi2) »
332 + if t:
333 + out["lot_sqft"] = t
334 + # évaluation municipale : rendue hors <td> propre → texte aplati
335 + me = re.search(r"valuation municipale\s*:?\s*([\d\s  ]{4,}\$"
336 + r"(?:\s*\(\d{4}\))?)", text)
337 + if me:
338 + det2["Évaluation municipale"] = re.sub(r"\s+", " ", me.group(1)).strip()
339 + if det2:
340 + out.setdefault("details", {}).update(det2)
341 +
297 342 # inclusions
298 343 mi = re.search(r"Inclusions?\s*:?(.*?)(?:Exclusions|Addenda|Financ|$)", text, re.I | re.S)
299 344 if mi:
modified immoka/db.py +102 −5
@@ -151,8 +151,10 @@ def refresh_dedup(con: sqlite3.Connection) -> int:
151 151 Règle (identique à l'ancienne clause) : une fiche de sous-agence est masquée
152 152 si une fiche de plus haute priorité — même n° Centris (external_id), active —
153 153 existe : le flux central/agence principale d'abord, sinon la sous-agence au
154 − plus petit uid. Les fiches non sous-agence ne sont jamais masquées.
155 − Retourne le nombre de fiches masquées."""
154 + plus petit uid. Les fiches non sous-agence ne sont jamais masquées par
155 + cette règle. Une 3e passe (dedup_by_address) masque ensuite les doublons
156 + INTER-SOURCES sans n° Centris commun (même adresse + type + prix ±1 %).
157 + Retourne le nombre total de fiches masquées."""
156 158 AG = "source LIKE '%\\_ag\\_%' ESCAPE '\\'"
157 159 NOTAG = "source NOT LIKE '%\\_ag\\_%' ESCAPE '\\'"
158 160 con.execute("UPDATE listings SET dup_hidden=0")
@@ -168,11 +170,102 @@ def refresh_dedup(con: sqlite3.Connection) -> int:
168 170 f"UPDATE listings SET dup_hidden=1 WHERE active=1 AND {AG} AND dup_hidden=0"
169 171 f" AND uid > (SELECT MIN(d.uid) FROM listings d WHERE d.active=1"
170 172 f" AND d.external_id=listings.external_id AND d.{AG} AND d.dup_hidden=0)")
173 + # 3) dédup INTER-SOURCES par adresse : la même propriété publiée sur deux
174 + # plateformes (ex. courtier + Kijiji) sans n° Centris commun. Règle
175 + # conservatrice : même adresse normalisée (civique + rue + ville) + même
176 + # type + prix identique à ±1 % → on garde la fiche la plus autoritaire.
177 + try:
178 + n_addr = dedup_by_address(con)
179 + if n_addr:
180 + print(f"[immo-ka] dédup adresse: {n_addr} doublon(s) inter-sources masqué(s)")
181 + except Exception:
182 + import traceback
183 + traceback.print_exc()
171 184 n = con.execute("SELECT COUNT(*) c FROM listings WHERE dup_hidden=1").fetchone()["c"]
172 185 con.commit()
173 186 return n
174 187
175 188
189 +# petites annonces généralistes (republication d'annonces d'ailleurs) : moins
190 +# autoritaires que la source primaire (courtier / FSBO première main)
191 +_PETITES_ANNONCES = {"kijiji", "lespac"}
192 +
193 +
194 +def _source_rank(source: str) -> int:
195 + """Autorité d'une source pour la dédup d'adresse :
196 + 0 = source primaire (bannière/agence/FSBO), 1 = sous-agence (_ag_),
197 + 2 = petites annonces (republication probable)."""
198 + if source in _PETITES_ANNONCES:
199 + return 2
200 + if "_ag_" in source:
201 + return 1
202 + return 0
203 +
204 +
205 +def dedup_by_address(con: sqlite3.Connection) -> int:
206 + """Passe de déduplication conservatrice par ADRESSE (inter-sources).
207 +
208 + Clé : n° civique(s) + mots significatifs de la rue + ville normalisée +
209 + type canonique + n° d'app/unité (vide s'il n'y en a pas — deux unités
210 + différentes d'un même immeuble ne partagent JAMAIS la même clé, et une
211 + adresse sans unité ne s'apparie pas à une adresse avec unité). Dans une
212 + clé, seules les fiches au prix identique à ±1 % sont considérées comme
213 + doublons ; si une même source apparaît deux fois dans le groupe (probables
214 + unités jumelles d'un projet neuf), le groupe ENTIER est ignoré. On garde
215 + la fiche la plus autoritaire (source primaire > sous-agence _ag_ >
216 + petites annonces), puis le plus petit uid. Retourne le nb masqué."""
217 + import re as _re
218 + from .vraiprix_local import _addr_parts, _norm, _muni_norm, _APP_RE
219 + groups: dict[tuple, list] = {}
220 + for r in con.execute(
221 + "SELECT uid, source, address, city, price, property_type"
222 + " FROM listings WHERE active=1 AND dup_hidden=0 AND address<>''"
223 + " AND city<>'' AND price IS NOT NULL AND property_type<>''"):
224 + civs, words = _addr_parts(r["address"])
225 + if not civs or not words:
226 + continue
227 + a = _norm(r["address"]).split(",")[0]
228 + mapt = _re.search(_APP_RE, a)
229 + apt = ""
230 + if mapt:
231 + toks = _re.findall(r"[a-z0-9]+", mapt.group(0))
232 + # dernier token = le n° d'unité (le 1er est le mot-clé app/unité/#)
233 + apt = toks[-1] if toks else ""
234 + key = ("-".join(civs), " ".join(sorted(set(words))),
235 + _muni_norm(r["city"]), _norm(r["property_type"]), apt)
236 + groups.setdefault(key, []).append(
237 + (r["price"], _source_rank(r["source"]), r["uid"], r["source"]))
238 + hidden = 0
239 + for rows in groups.values():
240 + if len(rows) < 2:
241 + continue
242 + rows.sort() # par prix croissant
243 + cluster: list = []
244 + for row in rows:
245 + if cluster and row[0] > cluster[0][0] * 1.01:
246 + hidden += _mask_cluster(con, cluster)
247 + cluster = []
248 + cluster.append(row)
249 + hidden += _mask_cluster(con, cluster)
250 + return hidden
251 +
252 +
253 +def _mask_cluster(con: sqlite3.Connection, cluster: list) -> int:
254 + """Masque les doublons d'un groupe (même clé d'adresse, prix ±1 %)."""
255 + if len(cluster) < 2:
256 + return 0
257 + sources = [c[3] for c in cluster]
258 + if len(set(sources)) != len(sources):
259 + return 0 # même source en double = probables unités distinctes : prudence
260 + keep = min(cluster, key=lambda c: (c[1], c[2])) # (autorité, uid)
261 + n = 0
262 + for c in cluster:
263 + if c[2] != keep[2]:
264 + con.execute("UPDATE listings SET dup_hidden=1 WHERE uid=?", (c[2],))
265 + n += 1
266 + return n
267 +
268 +
176 269 def connect() -> sqlite3.Connection:
177 270 global _SCHEMA_READY
178 271 DB_PATH.parent.mkdir(parents=True, exist_ok=True)
@@ -283,15 +376,19 @@ def sync_source(con: sqlite3.Connection, source: str,
283 376 (lst.uid, now, lst.price))
284 377 added += 1
285 378 elif row["content_hash"] != h:
286 − # COALESCE : ne jamais écraser des coordonnées géocodées par null
379 + # COALESCE : ne jamais écraser par null des coordonnées géocodées ni
380 + # les année/superficies remplies par enrichissement (rôle d'évaluation,
381 + # fiche détail) quand la source liste ne les fournit pas
287 382 con.execute(
288 383 """UPDATE listings SET url=:url, title=:title,
289 384 address=:address, sector=:sector, city=:city, region=:region,
290 385 property_type=:property_type, price=:price,
291 386 price_label=:price_label, bedrooms=:bedrooms,
292 387 bathrooms=:bathrooms, powder_rooms=:powder_rooms,
293 − area_sqft=:area_sqft, lot_sqft=:lot_sqft,
294 − year_built=:year_built, mls=:mls, status=:status,
388 + area_sqft=COALESCE(:area_sqft, area_sqft),
389 + lot_sqft=COALESCE(:lot_sqft, lot_sqft),
390 + year_built=COALESCE(:year_built, year_built),
391 + mls=:mls, status=:status,
295 392 broker_name=:broker_name, broker_phone=:broker_phone,
296 393 agency=:agency, description=:description, features=:features,
297 394 details=:details, images=:images,
modified immoka/ingest.py +6 −2
@@ -49,7 +49,8 @@ def run(sources: list[str] | None = None) -> list[dict]:
49 49 # recalcule la déduplication (pré-calculée pour des lectures instantanées)
50 50 try:
51 51 hidden = db.refresh_dedup(con)
52 − print(f"[immo-ka] dédup: {hidden} doublon(s) de sous-agence masqué(s)")
52 + print(f"[immo-ka] dédup: {hidden} doublon(s) masqué(s) "
53 + "(sous-agences Centris + adresse inter-sources)")
53 54 except Exception:
54 55 traceback.print_exc()
55 56 # statistiques du planificateur SQLite : sans elles, les requêtes bbox
@@ -80,7 +81,10 @@ def watch(interval_seconds: int = 3600) -> None:
80 81 try: # estimations Vrai-Prix + géocodage bonus depuis le rôle
81 82 from . import vraiprix_local
82 83 if vraiprix_local.available():
83 − vraiprix_local.run(limit=2000)
84 + vraiprix_local.run(limit=5000)
85 + # rôle d'évaluation (valeurs/année/superficies officielles) pour
86 + # les annonces déjà appariées — jointure par id, quasi gratuite
87 + vraiprix_local.enrich_role(limit=20000)
84 88 except Exception as exc:
85 89 print(f"[immo-ka] vraiprix_local: erreur non bloquante: {exc}", file=sys.stderr)
86 90 print(f"[immo-ka] prochaine synchronisation dans {interval_seconds // 60} min")
modified immoka/vraiprix_local.py +131 −3
@@ -90,12 +90,71 @@ def available() -> bool:
90 90
91 91
92 92 def _pack(r) -> dict:
93 − return {
93 + d = {
94 94 "id": r["id_provinc"], "lat": r["lat"], "lng": r["lng"],
95 95 "value": r["est_hedo"] or r["est_2026"], "low": r["p10"], "high": r["p90"],
96 96 "confidence": None, "confidence_pct": None,
97 97 "url": f"{SITE}/estimation/{r['id_provinc']}",
98 98 }
99 + d.update(_role_fields(r))
100 + return d
101 +
102 +
103 +# clés « rôle d'évaluation » ajoutées au JSON vraiprix (valeurs officielles)
104 +ROLE_KEYS = ("valeur_role", "valeur_terrain", "valeur_batiment",
105 + "annee_construction_role", "superficie_terrain_role_m2",
106 + "aire_etages_role_m2")
107 +
108 +
109 +def _role_fields(r) -> dict:
110 + """Champs du rôle d'évaluation foncière de l'unité appariée (officiels) :
111 + valeurs (rôle/terrain/bâtiment), année de construction et superficies."""
112 + out = {}
113 + for src, dst in (("valeur_role", "valeur_role"),
114 + ("valeur_terrain", "valeur_terrain"),
115 + ("valeur_batiment", "valeur_batiment"),
116 + ("annee_construction", "annee_construction_role"),
117 + ("superficie_terrain_m2", "superficie_terrain_role_m2"),
118 + ("aire_etages_m2", "aire_etages_role_m2")):
119 + try:
120 + v = r[src]
121 + except (KeyError, IndexError):
122 + v = None
123 + if v:
124 + out[dst] = v
125 + return out
126 +
127 +
128 +# le terrain du rôle d'une COPROPRIÉTÉ est souvent celui de l'immeuble entier :
129 +# jamais de repli lot_sqft pour ces types
130 +_NO_LOT_TYPES = ("condo", "appartement", "loft", "copropriete")
131 +
132 +
133 +def _apply_role_fallback(con, uid: str, year_built, lot_sqft,
134 + property_type: str, est: dict) -> tuple[int, int]:
135 + """Repli des COLONNES depuis le rôle quand la source ne fournit rien :
136 + year_built ← annee_construction_role, lot_sqft ← superficie_terrain_role_m2
137 + (sauf copropriétés). Provenance marquée dans details.*_source='role'.
138 + Retourne (année_remplie, terrain_rempli) ∈ {0,1}²."""
139 + fy = fl = 0
140 + y = est.get("annee_construction_role")
141 + if year_built is None and y and 1600 <= int(y) <= 2049:
142 + con.execute(
143 + "UPDATE listings SET year_built=?,"
144 + " details=json_set(COALESCE(details,'{}'),'$.year_built_source','role')"
145 + " WHERE uid=? AND year_built IS NULL", (int(y), uid))
146 + fy = 1
147 + t = est.get("superficie_terrain_role_m2")
148 + pt = _norm(property_type or "")
149 + if (lot_sqft is None and t and float(t) > 0
150 + and not any(k in pt for k in _NO_LOT_TYPES)):
151 + con.execute(
152 + "UPDATE listings SET lot_sqft=?,"
153 + " details=json_set(COALESCE(details,'{}'),'$.lot_sqft_source','role')"
154 + " WHERE uid=? AND lot_sqft IS NULL",
155 + (round(float(t) * 10.7639), uid))
156 + fl = 1
157 + return fy, fl
99 158
100 159
101 160 def _meters(a1: float, o1: float, a2: float, o2: float) -> float:
@@ -161,7 +220,9 @@ def _concordance(address: str, city: str, r) -> tuple[bool, bool, bool]:
161 220 return civ_ok, rue_ok, muni_ok
162 221
163 222
164 −_COLS = ("id_provinc, adresse, municipalite, lat, lng, est_hedo, est_2026, p10, p90")
223 +_COLS = ("id_provinc, adresse, municipalite, lat, lng, est_hedo, est_2026, p10, p90, "
224 + "annee_construction, aire_etages_m2, superficie_terrain_m2, "
225 + "valeur_terrain, valeur_batiment, valeur_role")
165 226
166 227
167 228 def _match(vp: sqlite3.Connection, address: str, city: str,
@@ -241,7 +302,8 @@ def run(limit: int | None = None, revalidate: bool = False) -> dict:
241 302 else "active=1 AND dup_hidden=0 AND address<>''"
242 303 " AND (vraiprix IS NULL OR vraiprix='{}')")
243 304 rows = con.execute(
244 − f"SELECT uid, address, city, lat, lng FROM listings WHERE {where}"
305 + f"SELECT uid, address, city, lat, lng, year_built, lot_sqft,"
306 + f" property_type FROM listings WHERE {where}"
245 307 " ORDER BY first_seen DESC").fetchall()
246 308 if limit is not None:
247 309 rows = rows[:limit]
@@ -253,8 +315,12 @@ def run(limit: int | None = None, revalidate: bool = False) -> dict:
253 315 if m and m["value"]:
254 316 est = {k: m[k] for k in ("id", "value", "low", "high",
255 317 "confidence", "confidence_pct", "url")}
318 + est.update({k: m[k] for k in ROLE_KEYS if m.get(k)})
256 319 con.execute("UPDATE listings SET vraiprix=? WHERE uid=?",
257 320 (json.dumps(est, ensure_ascii=False), r["uid"]))
321 + # repli des colonnes depuis le rôle (année, terrain)
322 + _apply_role_fallback(con, r["uid"], r["year_built"],
323 + r["lot_sqft"], r["property_type"], est)
258 324 # géocodage gratuit : coordonnées de l'unité si l'annonce n'en a pas
259 325 if (r["lat"] is None and m["lat"] is not None
260 326 and _BBOX[0] <= m["lat"] <= _BBOX[1]
@@ -280,3 +346,65 @@ def run(limit: int | None = None, revalidate: bool = False) -> dict:
280 346 out = {"matched": matched, "geolocated": geoloc, "no_match": miss}
281 347 print(f"[immo-ka] vraiprix_local {out}")
282 348 return out
349 +
350 +
351 +def enrich_role(limit: int | None = None) -> dict:
352 + """Backfill du rôle d'évaluation pour les annonces DÉJÀ appariées dont le
353 + JSON vraiprix ne porte pas encore les champs officiels (valeur_role,
354 + annee_construction_role, superficies…). Aucun ré-appariement : jointure
355 + directe par id_provinc (clé primaire) — quasi gratuit. Applique aussi les
356 + replis de colonnes (year_built/lot_sqft) marqués `*_source='role'`."""
357 + if not available():
358 + return {"error": "vraiprix.db absent"}
359 + vp = sqlite3.connect(f"file:{VP_DB}?mode=ro", uri=True)
360 + vp.row_factory = sqlite3.Row
361 + con = db.connect()
362 + rows = con.execute(
363 + "SELECT uid, vraiprix, year_built, lot_sqft, property_type FROM listings"
364 + " WHERE active=1 AND vraiprix LIKE '%\"id\"%'"
365 + " AND vraiprix NOT LIKE '%valeur_role%'"
366 + " AND vraiprix NOT LIKE '%role_checked%'").fetchall()
367 + if limit is not None:
368 + rows = rows[:limit]
369 + enriched = fallback_y = fallback_l = 0
370 + for i, r in enumerate(rows):
371 + try:
372 + est = json.loads(r["vraiprix"]) or {}
373 + except (TypeError, ValueError):
374 + continue
375 + if not est.get("id"):
376 + continue
377 + u = vp.execute(
378 + "SELECT annee_construction, aire_etages_m2, superficie_terrain_m2,"
379 + " valeur_terrain, valeur_batiment, valeur_role"
380 + " FROM units WHERE id_provinc=?", (str(est["id"]),)).fetchone()
381 + try:
382 + if u is None:
383 + est["role_checked"] = 1 # unité disparue : ne pas repasser
384 + else:
385 + fields = _role_fields(u)
386 + if not fields:
387 + est["role_checked"] = 1 # unité sans données de rôle
388 + est.update(fields)
389 + fy, fl = _apply_role_fallback(con, r["uid"], r["year_built"],
390 + r["lot_sqft"], r["property_type"], est)
391 + fallback_y += fy
392 + fallback_l += fl
393 + con.execute("UPDATE listings SET vraiprix=? WHERE uid=?",
394 + (json.dumps(est, ensure_ascii=False), r["uid"]))
395 + enriched += 1
396 + if i % 500 == 0:
397 + con.commit()
398 + except sqlite3.OperationalError:
399 + try:
400 + con.rollback()
401 + except sqlite3.Error:
402 + pass
403 + time.sleep(1.0)
404 + con.commit()
405 + con.close()
406 + vp.close()
407 + out = {"role_enriched": enriched, "year_built_filled": fallback_y,
408 + "lot_filled": fallback_l}
409 + print(f"[immo-ka] vraiprix_local.enrich_role {out}")
410 + return out
modified immoka/web.py +5 −0
@@ -7,6 +7,7 @@ from __future__ import annotations
7 7
8 8 import json
9 9 import threading
10 +import time
10 11 from pathlib import Path
11 12
12 13 from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request
@@ -64,6 +65,10 @@ def _row_to_dict(row) -> dict:
64 65 d["vraiprix"] = json.loads(d["vraiprix"]) or None
65 66 except (ValueError, TypeError):
66 67 d["vraiprix"] = None
68 + # jours sur le marché (depuis la 1re observation par Immo-Ka ; la date
69 + # d'inscription de la source, si connue, est dans details.listed_at)
70 + if d.get("first_seen"):
71 + d["days_on_market"] = max(0, int((time.time() - d["first_seen"]) // 86400))
67 72 return d
68 73
69 74
modified run.py +4 −1
@@ -62,7 +62,10 @@ def main() -> None:
62 62 from immoka import vraiprix_local
63 63 reval = "reval" in sys.argv[2:]
64 64 lim = next((int(a) for a in sys.argv[2:] if a.isdigit()), None)
65 − vraiprix_local.run(lim, revalidate=reval)
65 + if "role" in sys.argv[2:]: # backfill rôle d'évaluation (jointure par id)
66 + vraiprix_local.enrich_role(lim)
67 + else:
68 + vraiprix_local.run(lim, revalidate=reval)
66 69 elif cmd == "serve":
67 70 import uvicorn
68 71 port = int(sys.argv[2]) if len(sys.argv) > 2 else 8090
69 72