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%

Connecteurs : clé Algolia auto-extraite (Barnes), taxes/éval RE/MAX, specs Via Capitale, Kijiji étendu

- barnes_quebec : identifiants Algolia extraits du HTML public (PHPVars) avec
  cache process 6 h + repli sur la dernière clé connue et ré-extraction sur
  échec — résilient aux rotations de clé (403 du 2026-08-15).
- remax_quebec : le tableau de caractéristiques est rendu « Libellé | Valeur »
  (l'ancien ordre inverse ne captait que du bruit) — parseur corrigé, section
  « Détails financiers » captée (taxes municipales/scolaires + évaluation
  municipale terrain/bâtiment/année), libellés étendus, clé de cache v3→v4
  (re-parse progressif dans le budget par cycle).
- via_capitale : enrichissement fiche détail complet (chambres, salles de
  bains, superficies, année, taxes, évaluations) via meta + tableau aplati ;
  requête directe d'abord, repli Firecrawl stealth sur challenge Cloudflare ;
  budget IMMOKA_VIACAP_DETAIL_LIMIT (défaut 200/cycle) ; clé v2→v3.
- kijiji : _ATTR_LABELS étendus (terrain/acres, année, stationnement, meublé,
  visites), « Size (acres) » (texte libre) → lot_sqft, salles de bains « 1.5 »
  désormais captées ; clé v1→v2.
- _detailutil.centris_details : ordre « Libellé | Valeur » prioritaire +
  garde-fous (libellés voisins/en-têtes de section jamais pris pour valeurs,
  format minimal exigé pour taxes/évaluation/année/superficies).
- schema.finalize : promotion details → colonnes (Année de construction →
  year_built, Superficie habitable/du terrain → area_sqft/lot_sqft) avec
  parse_year strict (normalize.py). Backfill one-shot exécuté sur la base :
  year_built +95, area_sqft +792, lot_sqft +275.
- db.get_stale_detail + repli « périmé plutôt que rien » (_detailutil.enrich,
  remax _enrich) : un bump de version de clé ne déshabille plus les fiches
  hors budget, et un échec transitoire n'écrase plus un payload valide.
- Registres : via_ag_dumontroyal (0 inscription à l'API source.immo),
  remax_ag_pro_commercial (redirige vers remax-alliance.ca — doublon) et
  remax_ag_solutions (nancyforlini.com migré hors Centris) désactivés
  (disabled+note) ; loaders ignorent disabled ; sources.json à jour ;
  95 → 92 connecteurs.
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent 13e0d7a

14 changed files +405 −61

modified README.md +4 −4
@@ -108,7 +108,7 @@ Les sous-agences servent de plan B : si le flux central d'une bannière tombe, e
108 108 - **Backend** : **Python 3.14** · **FastAPI** (API JSON + rendu SEO serveur + service du frontend) · **SQLite en mode WAL** (accès concurrents lecture/écriture)
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 −- **Ingestion** : **95 connecteurs** (API JSON internes — Meilisearch, Algolia, source.immo, wp-json —, JSON-LD, sitemaps, **Firecrawl** pour les sites SPA/anti-bot)
111 +- **Ingestion** : **92 connecteurs** (API JSON internes — Meilisearch, Algolia, source.immo, wp-json —, JSON-LD, sitemaps, **Firecrawl** pour les sites SPA/anti-bot)
112 112 - **Enrichissement** : géocodage avec cache, POI de proximité, quartier **StatCan/INSPQ**, estimations **Vrai-Prix**
113 113 - **Auth** : **SSO KA ID** (JWT HS256 du hub groupe-ka.com, stdlib pure)
114 114 - **Exploitation** : **PM2** + tunnel **ngrok**
@@ -118,7 +118,7 @@ Les sous-agences servent de plan B : si le flux central d'une bannière tombe, e
118 118 ```mermaid
119 119 flowchart LR
120 120 subgraph Sources["Agences & plateformes du Québec"]
121 − S1["RE/MAX · Sutton · Via Capitale<br/>Century 21 · Royal LePage · Proprio Direct<br/>DuProprio · Kijiji · LesPAC · E&V · Barnes<br/>… 95 connecteurs"]
121 + S1["RE/MAX · Sutton · Via Capitale<br/>Century 21 · Royal LePage · Proprio Direct<br/>DuProprio · Kijiji · LesPAC · E&V · Barnes<br/>… 92 connecteurs"]
122 122 end
123 123 subgraph ImmoKa["Immo-Ka"]
124 124 C["Connecteurs<br/><i>1 adaptateur / source</i>"] --> N["Normalisation<br/><i>schéma PropertyListing</i>"]
@@ -187,7 +187,7 @@ immo-ka/
187 187 │ ├── geocode.py · poi.py · quartier.py # enrichissement
188 188 │ ├── vraiprix.py · vraiprix_local.py # estimations de valeur
189 189 │ ├── auth.py · hubprofile.py · hubfav.py · favorites.py # KA ID + favoris
190 −│ └── connectors/ # 95 connecteurs (31 modules + registres auto-générés)
190 +│ └── connectors/ # 92 connecteurs (31 modules + registres auto-générés)
191 191 ├── frontend/ # React 18 + Vite + TS
192 192 │ └── src/
193 193 │ ├── pages/ # Home · Listing · Category · Stats · Sources · Profil
@@ -210,7 +210,7 @@ cd frontend && npm install && npm run build && cd ..
210 210 # (optionnel) sites JavaScript / anti-bot
211 211 echo "FIRECRAWL_API_KEY=fc-votre-cle" > .env
212 212
213 −.venv/bin/python run.py list # 95 connecteurs enregistrés
213 +.venv/bin/python run.py list # 92 connecteurs enregistrés
214 214 .venv/bin/python run.py sync # toutes les agences (ou: sync remax_quebec sutton)
215 215 .venv/bin/python run.py serve 8090 # API + frontend + SEO → http://localhost:8090
216 216 .venv/bin/python run.py watch 180 # synchronisation en boucle (minutes)
modified data/remax_agencies.json +7 −3
@@ -237,7 +237,9 @@
237 237 {
238 238 "banner": "remax-pro-commercial",
239 239 "domain": "https://remax-pro-commercial.ca",
240 − "platform": "nos-proprietes"
240 + "platform": "nos-proprietes",
241 + "disabled": true,
242 + "note": "remax-pro-commercial.ca redirige vers remax-alliance.ca — doublon de remax-alliance (vérifié 2026-08-18)"
241 243 },
242 244 {
243 245 "banner": "remax-professionnel",
@@ -262,11 +264,13 @@
262 264 {
263 265 "banner": "remax-solutions",
264 266 "domain": "https://nancyforlini.com",
265 − "platform": "centris-other"
267 + "platform": "centris-other",
268 + "disabled": true,
269 + "note": "nancyforlini.com migré sur WordPress — plus de plateforme Centris ni de sitemap d’inscriptions (vérifié 2026-08-18)"
266 270 },
267 271 {
268 272 "banner": "remax-vision",
269 273 "domain": "https://remaxvision.ca",
270 274 "platform": "nos-proprietes"
271 275 }
272 −]
\ No newline at end of file
276 +]
modified data/sources.json +4 −3
@@ -193,9 +193,10 @@
193 193 "listing_url": "https://viacapitaledumontroyal.com/proprietes/",
194 194 "coverage": "Sous-agence Via Capitale (source.immo)",
195 195 "connector": "via_ag_dumontroyal",
196 − "status": "actif",
196 + "status": "inactif",
197 197 "type": "franchise",
198 − "role": "backup"
198 + "role": "backup",
199 + "notes": "Désactivé 2026-08-18 : 0 inscription via l’API source.immo (agence sans inventaire publié). Voir data/via_capitale_agencies.json."
199 200 },
200 201 {
201 202 "id": "via_ag_acces",
@@ -439,4 +440,4 @@
439 440 "note": "WordPress (MW Properties / marketingwebsites.ca), liste SSR paginée ?pages=N (~9/page, ~11 pages). Carte col-lg-4: no Centris (URL+image+property-meta), région, adresse, ville, prix ($US), chambres/sdb, photo. source_id _ag_ = dédup Centris."
440 441 }
441 442 ]
442 −}
\ No newline at end of file
443 +}
modified data/via_capitale_agencies.json +4 −2
@@ -13,7 +13,9 @@
13 13 "account": "3c1eb232-8f05-4703-97a3-ee620701b0a6",
14 14 "api": "83e0694b-3c10-45a2-9fde-052212e802d8",
15 15 "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
16 − "view": "e8f1592f-c854-4eea-a860-805e04aef699"
16 + "view": "e8f1592f-c854-4eea-a860-805e04aef699",
17 + "disabled": true,
18 + "note": "0 inscription via l’API source.immo (config live du site, vérifié 2026-08-18) — agence sans inventaire publié"
17 19 },
18 20 {
19 21 "banner": "elite",
@@ -39,4 +41,4 @@
39 41 "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
40 42 "view": "b5c1115d-34e7-4de6-9b66-7b3699992ed2"
41 43 }
42 −]
\ No newline at end of file
44 +]
modified immoka/connectors/_detailutil.py +56 −7
@@ -81,17 +81,54 @@ _LABELS = [
81 81 ]
82 82
83 83
84 +# en-têtes de section des fiches (jamais des valeurs valides)
85 +_SECTION_HEADERS = {
86 + "bâtiment et intérieur", "particularités du bâtiment",
87 + "particularités du terrain", "particularités du site", "caractéristiques",
88 + "caractéristiques de la propriété", "détails financiers", "taxes et coûts",
89 + "taxes\xa0et\xa0coûts", "détails des pièces", "détails des rénovations",
90 + "inclusions et exclusions", "dimensions", "addenda", "frais mensuels",
91 + "évaluations", "équipement disponible", "dans les environs",
92 + "niveau", "revêtement", "détails", "taxes", "total", "pièce", "couvre-sol",
93 +}
94 +_LABELS_LOWER = {lb.lower() for lb in _LABELS}
95 +
96 +
97 +def _plausible(label: str, val: str) -> bool:
98 + """Garde-fou : rejette les valeurs qui sont d'autres libellés/en-têtes de
99 + section (l'aplatissement « | » rend les deux ordres ambigus) et exige un
100 + format minimal pour les champs monétaires/numériques."""
101 + low = val.lower().strip(" :")
102 + if not val or len(val) > 55 or low == label.lower():
103 + return False
104 + if low in _LABELS_LOWER or low in _SECTION_HEADERS:
105 + return False
106 + if label.startswith(("Taxes", "Évaluation")) and "$" not in val:
107 + return False
108 + if label == "Année de construction" and not re.search(r"\b(1[6-9]|20)\d{2}\b", val):
109 + return False
110 + if label.startswith("Superficie") and not re.search(r"\d", val):
111 + return False
112 + return True
113 +
114 +
84 115 def centris_details(text: str) -> dict:
85 − """Extrait les caractéristiques Centris d'un texte aplati (les deux ordres)."""
116 + """Extrait les caractéristiques Centris d'un texte aplati.
117 +
118 + Les fiches Centris modernes rendent « Libellé | Valeur » (le libellé
119 + précède la valeur) — c'est l'ordre essayé en premier ; l'ancien ordre
120 + « Valeur | Libellé » reste en repli. Un garde-fou (_plausible) évite de
121 + capter l'en-tête de section ou le libellé voisin comme valeur."""
86 122 out: dict = {}
87 123 for label in _LABELS:
88 124 lab = re.escape(label)
89 − m = (re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)
90 − or re.search(lab + r"\b\s*\|\s*([^|]{1,55})", text))
91 − if m:
92 − val = m.group(1).strip(" |")
93 − if val and 1 <= len(val) <= 55 and val.lower() != label.lower():
94 − out[label] = val
125 + for m in (re.search(lab + r"\s*(?:\(\d{4}\))?\s*\|\s*([^|]{1,55})", text),
126 + re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)):
127 + if m:
128 + val = re.sub(r"\s+", " ", m.group(1)).strip(" |")
129 + if _plausible(label, val):
130 + out[label] = val
131 + break
95 132 return out
96 133
97 134
@@ -125,12 +162,24 @@ def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):
125 162 for lst in listings:
126 163 cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key)
127 164 if cached is None:
165 + stale = db.get_stale_detail(con, connector.source_id, lst.external_id)
128 166 if budget <= 0:
167 + # budget épuisé : payload périmé (ancienne clé) plutôt que
168 + # rien — la fiche garde ses photos/détails en attendant
169 + # son re-parse à un prochain cycle
170 + if stale:
171 + apply_detail(lst, stale)
129 172 continue
130 173 try:
131 174 cached = parse_fn(fetch_html(lst.url))
132 175 except Exception:
133 176 cached = {}
177 + if not cached and stale:
178 + # échec transitoire (challenge, timeout) : on garde l'ancien
179 + # payload et on ne l'écrase pas — nouvel essai au prochain cycle
180 + apply_detail(lst, stale)
181 + budget -= 1
182 + continue
134 183 db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached)
135 184 budget -= 1
136 185 apply_detail(lst, cached)
modified immoka/connectors/barnes_quebec.py +56 −9
@@ -3,16 +3,20 @@
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # connectors/barnes_quebec.py : BARNES Québec (barnes-quebec.com)
5 5 # Site WordPress indexé dans Algolia. La config expose l'App ID et une clé
6 −# API dans le HTML ; l'index « quebec_all » contient les propriétés (type
7 −# "property") avec adresse, prix, MLS, chambres, salles de bains, superficie
8 −# et géolocalisation. Les enregistrements sont dupliqués par langue → on
9 −# dédoublonne par référence MLS. Les permaliens pointent vers le domaine de
10 −# staging Kinsta ; on les réécrit vers le domaine de production.
6 +# API dans le HTML (PHPVars) ; l'index « quebec_all » contient les propriétés
7 +# (type "property") avec adresse, prix, MLS, chambres, salles de bains,
8 +# superficie et géolocalisation. Les enregistrements sont dupliqués par
9 +# langue → on dédoublonne par référence MLS. Les permaliens pointent vers le
10 +# domaine de staging Kinsta ; on les réécrit vers le domaine de production.
11 +# RÉSILIENCE : la clé Algolia a déjà été rotée (403 le 2026-08-15) — on
12 +# l'extrait donc À CHAQUE FOIS du HTML public du site (avec cache process),
13 +# avec repli sur la dernière clé connue si l'extraction échoue.
11 14 # -----------------------------------------------------------------------------
12 15 from __future__ import annotations
13 16
14 17 import os
15 18 import re
19 +import time
16 20
17 21 from .base import BaseConnector
18 22 from . import _detailutil as du
@@ -25,15 +29,23 @@ _IMG_RE = re.compile(
25 29 re.I)
26 30 _SIZE_RE = re.compile(r'-(\d{2,4})x(\d{2,4})(?=\.[a-z]+$)', re.I)
27 31
32 +# Repli : derniers identifiants Algolia connus (si l'extraction échoue).
28 33 APP_ID = "HCW55VIQNM"
29 34 API_KEY = "f4a20779fb6ded84a9c96a9b5976328b"
30 35 INDEX = "quebec_all"
31 −QUERY_URL = f"https://{APP_ID}-dsn.algolia.net/1/indexes/{INDEX}/query"
32 36 SITE = "https://barnes-quebec.com"
33 37 STAGING = "stg-quebec-staging.kinsta.cloud"
34 38 HITS_PER_PAGE = 100
35 39 MAX_PAGES = 40
36 40
41 +# Identifiants publiés dans PHPVars (wp_localize_script du plugin barnes-algolia)
42 +_APP_ID_RE = re.compile(r'"algolia_application_id"\s*:\s*"([A-Za-z0-9]{8,16})"')
43 +_API_KEY_RE = re.compile(r'"algolia_admin_api_key"\s*:\s*"([0-9a-fA-F]{16,64})"')
44 +_PREFIX_RE = re.compile(r'"algolia_index_prefix"\s*:\s*"([A-Za-z0-9_-]{1,32})"')
45 +_CREDS_TTL = 6 * 3600 # ré-extraire au plus toutes les 6 h par process
46 +_creds_cache: tuple[str, str, str] | None = None
47 +_creds_ts = 0.0
48 +
37 49
38 50 class BarnesQuebecConnector(BaseConnector):
39 51 source_id = "barnes_quebec"
@@ -69,11 +81,46 @@ class BarnesQuebecConnector(BaseConnector):
69 81 du.enrich(self, listings, DETAIL_LIMIT, parse_barnes_detail, key="v2")
70 82 return listings
71 83
84 + # -- identifiants Algolia (auto-extraits du site, avec cache) --------------
85 + def _algolia_creds(self, force: bool = False) -> tuple[str, str, str]:
86 + """(app_id, api_key, index) lus dans le HTML public — résilient à une
87 + rotation de clé. Cache process (TTL 6 h) ; repli : dernière clé connue."""
88 + global _creds_cache, _creds_ts
89 + if not force and _creds_cache and time.time() - _creds_ts < _CREDS_TTL:
90 + return _creds_cache
91 + creds = None
92 + for path in ("/acheter/", "/louer/", "/"):
93 + try:
94 + html = self.get(SITE + path).text
95 + except Exception:
96 + continue
97 + m_app, m_key = _APP_ID_RE.search(html), _API_KEY_RE.search(html)
98 + if m_app and m_key:
99 + m_pfx = _PREFIX_RE.search(html)
100 + index = (m_pfx.group(1) + "_all") if m_pfx else INDEX
101 + creds = (m_app.group(1), m_key.group(1), index)
102 + break
103 + _creds_cache = creds or (APP_ID, API_KEY, INDEX)
104 + _creds_ts = time.time()
105 + return _creds_cache
106 +
72 107 def _query(self, page: int) -> dict:
108 + app_id, api_key, index = self._algolia_creds()
109 + try:
110 + return self._post_query(app_id, api_key, index, page)
111 + except Exception:
112 + # clé rotée depuis la mise en cache : ré-extraire, sinon repli connu
113 + app2, key2, idx2 = self._algolia_creds(force=True)
114 + if (app2, key2, idx2) == (app_id, api_key, index):
115 + app2, key2, idx2 = APP_ID, API_KEY, INDEX
116 + return self._post_query(app2, key2, idx2, page)
117 +
118 + def _post_query(self, app_id: str, api_key: str, index: str,
119 + page: int) -> dict:
73 120 resp = self.post(
74 − QUERY_URL,
75 − headers={"X-Algolia-API-Key": API_KEY,
76 − "X-Algolia-Application-Id": APP_ID,
121 + f"https://{app_id.lower()}-dsn.algolia.net/1/indexes/{index}/query",
122 + headers={"X-Algolia-API-Key": api_key,
123 + "X-Algolia-Application-Id": app_id,
77 124 "Content-Type": "application/json"},
78 125 json={"params": f"hitsPerPage={HITS_PER_PAGE}&page={page}"},
79 126 )
modified immoka/connectors/kijiji.py +40 −5
@@ -28,12 +28,40 @@ CATEGORIES = [
28 28 MAX_PAGES = int(os.environ.get("IMMOKA_KIJIJI_MAX_PAGES", "100"))
29 29 DETAIL_LIMIT = int(os.environ.get("IMMOKA_KIJIJI_DETAIL_LIMIT", "400"))
30 30
31 +# attributs Apollo (canonicalName) -> libellé FR. Les attributs absents de la
32 +# table gardent leur `name` Kijiji d'origine dans details.
31 33 _ATTR_LABELS = {
32 34 "numberbedrooms": "Chambres", "numberbathrooms": "Salles de bain",
33 − "areainfeet": "Superficie (pi²)", "forsalebyhousing": "À vendre par",
34 − "yearbuilt": "Année de construction", "sizesqft": "Superficie (pi²)",
35 + "areainfeet": "Superficie (pi²)", "sizesqft": "Superficie (pi²)",
36 + "areainacres": "Superficie du terrain", # texte libre : « 26 acres », « 35 000 pi2 »…
37 + "yearbuilt": "Année de construction", "forsalebyhousing": "À vendre par",
38 + "numberparkingspots": "Stationnements", "parkingincluded": "Stationnement inclus",
39 + "unittype": "Type d'unité", "furnished": "Meublé",
40 + "virtualtour": "Visite virtuelle", "videochat": "Visite par vidéo",
35 41 }
36 42
43 +_ACRES_RE = re.compile(r"([\d\s,.]+)\s*(?:acres?\b|ac\.?$)", re.I)
44 +
45 +
46 +def _lot_sqft_from_free_text(val: str) -> float | None:
47 + """Superficie de terrain depuis le champ libre « Size (acres) » de Kijiji :
48 + « 26 acres », « 35 000 pi2 », « 1586 mètre carré », ou un nombre nu (acres)."""
49 + from ..normalize import parse_area_sqft, parse_float
50 + t = (val or "").replace("pieds carres", "pi²").replace("pieds carrés", "pi²") \
51 + .replace("pied carré", "pi²").replace("mètres carrés", "m²") \
52 + .replace("mètre carré", "m²").replace("metre carre", "m²")
53 + t = re.sub(r"(\d),(\d{3})\b", r"\1\2", t) # « 18,000 » = milliers, pas décimale
54 + v = parse_area_sqft(t) # unités pi²/m² explicites
55 + if v:
56 + return v
57 + m = _ACRES_RE.search(t)
58 + n = parse_float(m.group(1)) if m else None
59 + if n is None and re.fullmatch(r"[\d\s,.]+", t.strip()):
60 + n = parse_float(t) # nombre nu = acres (nom du champ)
61 + if n and 0 < n < 1000:
62 + return round(n * 43560) # acres -> pi²
63 + return None
64 +
37 65
38 66 def _parse_kijiji_detail(html: str) -> dict:
39 67 """Fiche Kijiji : description complète, attributs, galerie haute résolution."""
@@ -70,12 +98,18 @@ def _parse_kijiji_detail(html: str) -> dict:
70 98 details[label] = val
71 99 if cn == "numberbedrooms" and val.isdigit():
72 100 out["bedrooms"] = int(val)
73 − elif cn == "numberbathrooms" and val.isdigit():
74 − out["bathrooms"] = int(val)
101 + elif cn == "numberbathrooms":
102 + mn = re.search(r"\d+", val) # « 1.5 » -> 1
103 + if mn:
104 + out["bathrooms"] = int(mn.group(0))
75 105 elif cn in ("areainfeet", "sizesqft"):
76 106 mn = re.search(r"[\d.]+", val.replace(",", ""))
77 107 if mn:
78 108 out["area_sqft"] = float(mn.group(0))
109 + elif cn == "areainacres":
110 + lot = _lot_sqft_from_free_text(val)
111 + if lot:
112 + out["lot_sqft"] = lot
79 113 elif cn == "yearbuilt" and val.isdigit():
80 114 out["year_built"] = int(val)
81 115 if features:
@@ -165,5 +199,6 @@ class KijijiConnector(BaseConnector):
165 199 if fresh == 0 or len(items) < 10:
166 200 break
167 201 listings = list(out.values())
168 − du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v1")
202 + # v2 = _ATTR_LABELS étendus (terrain/stationnement/année…) + lot_sqft
203 + du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v2")
169 204 return listings
modified immoka/connectors/remax_agences.py +3 −0
@@ -229,8 +229,11 @@ def _load_agencies() -> list[dict]:
229 229
230 230 # Crée dynamiquement RemaxAg<...> pour chaque agence dotée d'un site propre,
231 231 # sur l'une des deux plateformes Centris reconnues (nos-proprietes / centris-other).
232 +# `"disabled": true` dans le registre = site mort/migré/doublon (voir "note").
232 233 for _ag in _load_agencies():
233 234 _plat = _ag.get("platform")
235 + if _ag.get("disabled"):
236 + continue
234 237 if _plat not in ("nos-proprietes", "centris-other") or not _ag.get("domain"):
235 238 continue
236 239 _dom = re.sub(r"^https?://", "", _ag["domain"]).rstrip("/")
modified immoka/connectors/remax_quebec.py +86 −18
@@ -61,12 +61,24 @@ class RemaxQuebecConnector(BaseConnector):
61 61 con = db.connect()
62 62 budget = DETAIL_LIMIT
63 63 for lst in listings:
64 − key = f"v3|{lst.price_label or '-'}" # v3 = + agence/bureau ; refetch si prix change
64 + # v4 = tableau « Libellé | Valeur » + Détails financiers (taxes/éval) ;
65 + # bump de version = re-parse progressif de tout le parc dans le budget.
66 + key = f"v4|{lst.price_label or '-'}" # refetch aussi si le prix change
65 67 cached = db.get_cached_detail(con, self.source_id, lst.external_id, key)
66 68 if cached is None:
69 + stale = db.get_stale_detail(con, self.source_id, lst.external_id)
67 70 if budget <= 0:
68 − continue # enrichi à un prochain cycle
71 + # budget épuisé : payload périmé plutôt que rien (la fiche
72 + # garde photos/détails) — re-parse à un prochain cycle
73 + if stale:
74 + _apply_detail(lst, stale)
75 + continue
69 76 cached = self._scrape_detail(lst.url)
77 + if not cached and stale:
78 + # échec transitoire : garder l'ancien payload sans l'écraser
79 + _apply_detail(lst, stale)
80 + budget -= 1
81 + continue
70 82 db.put_cached_detail(con, self.source_id, lst.external_id, key, cached)
71 83 budget -= 1
72 84 _apply_detail(lst, cached)
@@ -188,16 +200,28 @@ _IMG_RE = re.compile(
188 200 r'https://media\.remax-quebec\.com/img/www_[a-z]+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',
189 201 re.I)
190 202 _IMG_SIZE_RE = re.compile(r'/www_[a-z]+/', re.I)
191 −# Dans la section caractéristiques, la valeur précède le libellé :
192 −# « 2 (1 + 1) | Chambres », « 1 | Salle de bain », « 1975 | Année de construction »
203 +# Dans le bandeau « Caractéristiques de la propriété », la valeur précède le
204 +# libellé : « 2 (1 + 1) | Chambres », « 1 | Salle de bain ». Le tableau détaillé
205 +# est lui rendu « Libellé | Valeur » (voir _DETAIL_LABELS plus bas).
193 206 _SPEC_RE = {
194 207 "bedrooms": re.compile(r'(\d+)(?:\s*\([^)]*\))?\s*\|\s*Chambre', re.I),
195 208 "bathrooms": re.compile(r'(\d+)\s*\|\s*Salle de bain', re.I),
196 209 "powder_rooms": re.compile(r"(\d+)\s*\|\s*Salle d'eau", re.I),
197 − "year_built": re.compile(r'(\d{4})\s*\|\s*Ann[ée]+e de construction', re.I),
210 + # « Année de construction | 1959 » (libellé avant la valeur)
211 + "year_built": re.compile(r'Ann[ée]+e de construction\s*\|\s*(\d{4})\b', re.I),
198 212 }
199 −_AREA_RE = re.compile(r'([\d ,]+)\s*(m²|mc|pi²|pi|ft)\s*\|\s*Superficie habitable', re.I)
200 −_LOT_RE = re.compile(r'([\d ,]+)\s*(m²|mc|pi²|pi|ft)\s*\|\s*Superficie du terrain', re.I)
213 +_NUM = r'[\d\s  ,.]+'
214 +_AREA_RE = re.compile(r'Superficie habitable\s*\|\s*(' + _NUM + r')\s*(m²|mc|pi²|pi|ft)', re.I)
215 +_LOT_RE = re.compile(r'Superficie du terrain\s*\|\s*(' + _NUM + r')\s*(m²|mc|pi²|pi|ft)', re.I)
216 +# Section « Détails financiers » : Évaluation municipale (AAAA) | Évaluation du
217 +# terrain | X $ | Évaluation du bâtiment | Y $ | Total | Z $ | Taxes |
218 +# Municipale (AAAA) | X $ | Scolaire (AAAA) | Y $ | Total | Z $
219 +_MONEY = r'((?:\d[\d\s  ]*)\$)'
220 +_FIN_EVAL_LAND_RE = re.compile(r'Évaluation du terrain\s*\|\s*' + _MONEY)
221 +_FIN_EVAL_BLDG_RE = re.compile(r'Évaluation du bâtiment\s*\|\s*' + _MONEY)
222 +_FIN_EVAL_YEAR_RE = re.compile(r'Évaluation municipale\s*\((\d{4})\)')
223 +_FIN_TAX_MUNI_RE = re.compile(r'\bMunicipale\s*(?:\((\d{4})\))?\s*\|\s*' + _MONEY, re.S)
224 +_FIN_TAX_SCOL_RE = re.compile(r'\bScolaire\s*(?:\((\d{4})\))?\s*\|\s*' + _MONEY, re.S)
201 225 _COORD_RE = re.compile(r'query=(-?\d+\.\d+)%2C\+?(-?\d+\.\d+)')
202 226 _ROOM_RE = re.compile(r'rooms-details-section__vertical-table[^>]*>(.*?)</div>\s*</div>', re.S)
203 227 _QSTAT_RE = re.compile(
@@ -205,17 +229,32 @@ _QSTAT_RE = re.compile(
205 229 _INCL_RE = re.compile(r'inclusions-exclusions-section(.*?)(?:</section>|realtor-section|financial-section)', re.S)
206 230 _PHONE_RE = re.compile(r'\d{3}[\s ]\d{3}-\d{4}')
207 231
208 −# Ensemble de libellés Centris standard capturés dans le tableau « valeur | libellé ».
232 +# Libellés du tableau de caractéristiques, rendu « Libellé | Valeur » (le
233 +# libellé PRÉCÈDE la valeur — vérifié sur les fiches live 2026-08 ; l'ancien
234 +# ordre inverse ne captait que du bruit). Les plus spécifiques d'abord.
209 235 _DETAIL_LABELS = [
210 236 "Type de propriété", "Genre de propriété", "Style de bâtiment",
211 237 "Année de construction", "Nombre d'unités", "Superficie du bâtiment (au sol)",
212 − "Superficie du terrain", "Superficie habitable", "Stationnement (total)",
213 − "Système de chauffage", "Énergie pour le chauffage", "Fenêtres", "Type de fenestration",
214 − "Toiture", "Revêtement", "Sous-sol", "Piscine", "Garage", "Zonage",
215 − "Système d'égouts", "Approvisionnement en eau", "Déménagement",
216 − "Taxes municipales", "Taxes scolaires", "Évaluation municipale (terrain)",
217 − "Évaluation municipale (bâtiment)", "Cuisine", "Salle de bain / Salle d'eau",
238 + "Superficie du terrain", "Superficie habitable", "Dimensions du terrain",
239 + "Stationnement (total)", "Système de chauffage", "Mode de chauffage",
240 + "Énergie pour le chauffage", "Fenêtres", "Type de fenestration",
241 + "Fenestration", "Toiture", "Revêtement de la toiture", "Revêtement",
242 + "Sous-sol", "Piscine", "Garage", "Zonage", "Système d'égouts",
243 + "Approvisionnement en eau", "Déménagement", "Cuisine", "Armoires de cuisine",
244 + "Salle de bain / Salle d'eau", "Salle de bains",
245 + "Installation laveuse-sécheuse", "Eau (accès)", "Topographie", "Vue",
246 + "Particularités du site", "Allée", "Proximité",
247 + "Commodités de la propriété ou de l'unité", "Équipement disponible",
218 248 ]
249 +_DETAIL_LABELS_LOWER = {lb.lower() for lb in _DETAIL_LABELS}
250 +# en-têtes de section : jamais des valeurs valides
251 +_SECTION_HEADERS = {
252 + "particularités du bâtiment", "particularités du terrain",
253 + "détails des pièces", "détails financiers", "caractéristiques",
254 + "inclusions et exclusions", "dimensions", "niveau", "revêtement",
255 + "détails", "taxes", "total", "dans les environs",
256 + "calculatrice de versements hypothécaire", "note légale",
257 +}
219 258
220 259
221 260 def _flatten(html: str) -> str:
@@ -289,14 +328,43 @@ def parse_remax_detail(html: str) -> dict:
289 328 if m:
290 329 out["lot_sqft"] = parse_area_sqft(f"{m.group(1)} {m.group(2)}")
291 330
292 − # tableau de caractéristiques complet (valeur | libellé) → details
331 + # tableau de caractéristiques complet (« Libellé | Valeur ») → details
293 332 for label in _DETAIL_LABELS:
294 − m = re.search(r'([^|]{1,60})\s*\|\s*' + re.escape(label) + r'\b', text)
333 + m = re.search(re.escape(label) + r'\s*\|\s*([^|]{1,60})', text)
295 334 if m:
296 − val = m.group(1).strip(" |")
297 − if val and 1 <= len(val) <= 60 and val.lower() != label.lower():
335 + val = re.sub(r'\s+', ' ', m.group(1)).strip(" |")
336 + low = val.lower().strip(" :")
337 + # garde-fou : un champ vide fait suivre le libellé voisin ou
338 + # l'en-tête de section — on ne capte jamais ces « valeurs »
339 + if (val and 1 <= len(val) <= 60 and low != label.lower()
340 + and low not in _DETAIL_LABELS_LOWER
341 + and low not in _SECTION_HEADERS):
298 342 details[label] = val
299 343
344 + # « Détails financiers » : taxes municipales/scolaires + évaluation municipale
345 + fin_at = text.find("Détails financiers")
346 + fin = text[fin_at: fin_at + 900] if fin_at >= 0 else ""
347 + if fin:
348 + def _amount(m) -> str:
349 + return re.sub(r'[\s ]+', ' ', m.group(m.lastindex)).strip()
350 + m = _FIN_TAX_MUNI_RE.search(fin)
351 + if m:
352 + details["Taxes municipales"] = _amount(m) + \
353 + (f" ({m.group(1)})" if m.group(1) else "")
354 + m = _FIN_TAX_SCOL_RE.search(fin)
355 + if m:
356 + details["Taxes scolaires"] = _amount(m) + \
357 + (f" ({m.group(1)})" if m.group(1) else "")
358 + m = _FIN_EVAL_LAND_RE.search(fin)
359 + if m:
360 + details["Évaluation municipale (terrain)"] = _amount(m)
361 + m = _FIN_EVAL_BLDG_RE.search(fin)
362 + if m:
363 + details["Évaluation municipale (bâtiment)"] = _amount(m)
364 + m = _FIN_EVAL_YEAR_RE.search(fin)
365 + if m:
366 + details["Évaluation municipale (année)"] = m.group(1)
367 +
300 368 # pièces avec dimensions/niveau/revêtement
301 369 rooms = []
302 370 for blk in _ROOM_RE.findall(html):
modified immoka/connectors/via_capitale.py +95 −9
@@ -16,12 +16,30 @@ import re
16 16
17 17 from .base import BaseConnector
18 18 from . import _detailutil as du
19 −from ..normalize import parse_price
19 +from ..normalize import parse_area_sqft, parse_price
20 20 from ..schema import PropertyListing
21 21
22 −# Fiche descriptive accessible en requête simple (galerie ~60 photos + description).
23 −DETAIL_LIMIT = int(os.environ.get("IMMOKA_VC_DETAIL_LIMIT", "300"))
22 +# Fiche descriptive : la requête simple (UA ImmoKaBot) passe généralement,
23 +# mais Cloudflare renvoie par intermittence un challenge 403 → repli Firecrawl
24 +# (proxy stealth, crédits payants) ; budget par cycle volontairement modeste.
25 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_VIACAP_DETAIL_LIMIT", "200"))
24 26 _FICHE_IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"\'?)\s]+)', re.I)
27 +# meta description de la fiche : « …à vendre 564 900$, 740 Route de St-Achillée.
28 +# 2018, 5 chambres, 2 salle(s) de bain, Superficie 2324pi² »
29 +_META_SPECS_RE = re.compile(
30 + r'(?:(\d{4}),\s*)?(\d+)\s*chambres?,\s*([\d.,]+)\s*salle\(s\) de bain'
31 + r'(?:,\s*Superficie\s*([\d\s ]+)\s*pi²)?', re.I)
32 +# tableau « Libellé | Valeur » de la fiche (texte aplati)
33 +_VC_YEAR_RE = re.compile(r'Année de construction\s*\|\s*(\d{4})\b')
34 +_VC_AREA_RE = re.compile(r'Superficie habitable\s*\|\s*([\d\s ,.]+)\s*(pi²|m²|pc|mc)', re.I)
35 +_VC_LOT_RE = re.compile(r'Superficie du terrain\s*\|\s*([\d\s ,.]+)\s*(pi²|m²|pc|mc)', re.I)
36 +_VC_MONEY = r'((?:\d[\d\s ]*)\$)'
37 +_VC_TAX_MUNI_RE = re.compile(r'Taxes municipales\s*(?:\((\d{4})\))?\s*\|\s*' + _VC_MONEY)
38 +_VC_TAX_SCOL_RE = re.compile(r'Taxes scolaires\s*(?:\((\d{4})\))?\s*\|\s*' + _VC_MONEY)
39 +# « Évaluations (2025) | Bâtiment | 304 300 $ | Terrain | 99 200 $ »
40 +_VC_EVAL_RE = re.compile(
41 + r'Évaluations?\s*(?:\((\d{4})\))?\s*\|\s*Bâtiment\s*\|\s*' + _VC_MONEY +
42 + r'\s*\|\s*Terrain\s*\|\s*' + _VC_MONEY)
25 43
26 44 SITE = "https://www.viacapitalevendu.com"
27 45 SEARCH = f"{SITE}/recherche/residentiel/"
@@ -102,10 +120,23 @@ class ViaCapitaleConnector(BaseConnector):
102 120 else:
103 121 empty_streak = 0
104 122 listings = list(by_id.values())
105 − # fiche détail (requête simple) : galerie complète + description
106 − du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v2")
123 + # fiche détail : galerie + description + specs (chambres, sdb,
124 + # superficies, année, taxes/évaluation). v3 = extraction des specs.
125 + du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v3",
126 + fetch_html=self._fetch_fiche)
107 127 return listings
108 128
129 + def _fetch_fiche(self, url: str) -> str:
130 + """HTML de la fiche : requête directe d'abord (passe en général), puis
131 + repli Firecrawl stealth si Cloudflare renvoie son challenge (403)."""
132 + try:
133 + html = self.get(url).text
134 + if len(html) > 20000: # une vraie fiche fait ~300 ko
135 + return html
136 + except Exception:
137 + pass
138 + return self.get_rendered(url, wait_for=4000, proxy="stealth")
139 +
109 140 def _parse_cards(self, html: str) -> list[PropertyListing]:
110 141 # découpe la page en segments, un par carte (lien fiche + id)
111 142 matches = list(CARD_SPLIT.finditer(html))
@@ -188,7 +219,8 @@ def _split_addr(full: str) -> tuple[str, str, str]:
188 219
189 220
190 221 def parse_vc_detail(html: str) -> dict:
191 − """Fiche Via Capitale : galerie complète + description (plus long bloc)."""
222 + """Fiche Via Capitale : galerie complète, description, et specs structurées
223 + (chambres, salles de bains, superficies, année, taxes, évaluation)."""
192 224 out: dict = {}
193 225 seen, imgs = set(), []
194 226 for m in _FICHE_IMG_RE.finditer(html):
@@ -206,9 +238,63 @@ def parse_vc_detail(html: str) -> dict:
206 238 best = txt
207 239 if len(best) > 120:
208 240 out["description"] = best[:4000]
209 − _det = du.centris_details(du.flatten(html))
210 − if _det:
211 − out.setdefault("details", {}).update(_det)
241 +
242 + # chambres / salles de bains / superficie / année : meta description de la
243 + # fiche (« 2018, 5 chambres, 2 salle(s) de bain, Superficie 2324pi² »)
244 + meta = re.search(r'<meta name="(?:og:)?description" content="([^"]*)"', html)
245 + m = _META_SPECS_RE.search(_unescape(meta.group(1))) if meta else None
246 + if m:
247 + if m.group(1):
248 + out["year_built"] = int(m.group(1))
249 + out["bedrooms"] = int(m.group(2))
250 + try:
251 + out["bathrooms"] = int(float(m.group(3).replace(",", ".")))
252 + except ValueError:
253 + pass
254 + if m.group(4):
255 + try:
256 + out["area_sqft"] = float(re.sub(r"[^\d]", "", m.group(4)))
257 + except ValueError:
258 + pass
259 +
260 + flat = du.flatten(html)
261 + details: dict = du.centris_details(flat)
262 +
263 + # tableau « Libellé | Valeur » : specs numériques prioritaires sur la meta
264 + m = _VC_YEAR_RE.search(flat)
265 + if m:
266 + out["year_built"] = int(m.group(1))
267 + m = _VC_AREA_RE.search(flat)
268 + if m:
269 + v = parse_area_sqft(f"{m.group(1)} {m.group(2)}".replace("pc", "pi²").replace("mc", "m²"))
270 + if v:
271 + out["area_sqft"] = v
272 + m = _VC_LOT_RE.search(flat)
273 + if m:
274 + v = parse_area_sqft(f"{m.group(1)} {m.group(2)}".replace("pc", "pi²").replace("mc", "m²"))
275 + if v:
276 + out["lot_sqft"] = v
277 +
278 + # taxes et évaluation municipale (« Taxes et coûts » / « Évaluations (AAAA) »)
279 + def _amt(g: str) -> str:
280 + return re.sub(r"[\s ]+", " ", g).strip()
281 + m = _VC_TAX_MUNI_RE.search(flat)
282 + if m:
283 + details["Taxes municipales"] = _amt(m.group(2)) + \
284 + (f" ({m.group(1)})" if m.group(1) else "")
285 + m = _VC_TAX_SCOL_RE.search(flat)
286 + if m:
287 + details["Taxes scolaires"] = _amt(m.group(2)) + \
288 + (f" ({m.group(1)})" if m.group(1) else "")
289 + m = _VC_EVAL_RE.search(flat)
290 + if m:
291 + details["Évaluation municipale (bâtiment)"] = _amt(m.group(2))
292 + details["Évaluation municipale (terrain)"] = _amt(m.group(3))
293 + if m.group(1):
294 + details["Évaluation municipale (année)"] = m.group(1)
295 +
296 + if details:
297 + out["details"] = details
212 298 return out
213 299
214 300
modified immoka/connectors/via_capitale_agences.py +3 −0
@@ -70,7 +70,10 @@ def _slug(banner: str) -> str:
70 70 return "via_ag_" + re.sub(r"[^a-z0-9]+", "_", banner.lower()).strip("_")
71 71
72 72
73 +# `"disabled": true` dans le registre = agence sans inventaire/doublon (voir "note")
73 74 for _ag in _load():
75 + if _ag.get("disabled"):
76 + continue
74 77 if not all(_ag.get(k) for k in ("domain", "account", "api", "app", "view")):
75 78 continue
76 79 _sid = _slug(_ag["banner"])
modified immoka/db.py +16 −0
@@ -372,6 +372,22 @@ def get_cached_detail(con: sqlite3.Connection, source: str,
372 372 return None
373 373
374 374
375 +def get_stale_detail(con: sqlite3.Connection, source: str,
376 + external_id: str) -> dict | None:
377 + """Payload détail SANS vérifier la clé — repli « périmé plutôt que rien »
378 + quand le budget de re-fetch d'un cycle est épuisé (ex. bump de version de
379 + clé) : la fiche garde photos/détails existants en attendant son re-parse."""
380 + row = con.execute(
381 + "SELECT payload FROM detail_cache WHERE source=? AND external_id=?",
382 + (source, external_id)).fetchone()
383 + if row and row["payload"]:
384 + try:
385 + return json.loads(row["payload"])
386 + except ValueError:
387 + return None
388 + return None
389 +
390 +
375 391 def put_cached_detail(con: sqlite3.Connection, source: str,
376 392 external_id: str, key: str, payload: dict) -> None:
377 393 con.execute(
modified immoka/normalize.py +21 −1
@@ -17,7 +17,7 @@ import unicodedata
17 17 __all__ = [
18 18 "strip_accents", "clean_address", "parse_price", "price_is_from",
19 19 "parse_int", "parse_float", "parse_area_sqft", "parse_lot_sqft",
20 − "normalize_property_type", "extract_bedrooms_bathrooms",
20 + "parse_year", "normalize_property_type", "extract_bedrooms_bathrooms",
21 21 ]
22 22
23 23
@@ -126,6 +126,26 @@ def parse_lot_sqft(text: str) -> float | None:
126 126 return parse_area_sqft(text)
127 127
128 128
129 +# « 1959 », « 2018 (Neuf) », « 1975, rénové »… mais JAMAIS « 20' X 34' irr. »
130 +# ni « À construire » : la valeur doit COMMENCER par une année plausible.
131 +_YEAR_RE = re.compile(r"^\s*(1[6-9]\d{2}|20[0-4]\d)\s*(?:$|[(,])")
132 +
133 +
134 +def parse_year(text) -> int | None:
135 + """Année de construction plausible (1600-2049) depuis une valeur `details`.
136 +
137 + Volontairement strict (année en tête de valeur, seule ou suivie d'une
138 + parenthèse/virgule) pour ne jamais promouvoir un libellé parasite vers la
139 + colonne year_built."""
140 + if text is None:
141 + return None
142 + if isinstance(text, (int, float)):
143 + y = int(text)
144 + return y if 1600 <= y <= 2049 else None
145 + m = _YEAR_RE.match(str(text))
146 + return int(m.group(1)) if m else None
147 +
148 +
129 149 # ---------------------------------------------------------------------------
130 150 # Type de propriété
131 151 # ---------------------------------------------------------------------------
modified immoka/schema.py +10 −0
@@ -26,6 +26,7 @@ from .normalize import (
26 26 parse_int,
27 27 parse_lot_sqft,
28 28 parse_price,
29 + parse_year,
29 30 price_is_from,
30 31 strip_accents,
31 32 )
@@ -93,6 +94,15 @@ class PropertyListing:
93 94 if self.price_label and price_is_from(self.price_label):
94 95 self.details.setdefault("price_from", True)
95 96
97 + # promotion details -> colonnes : les champs structurés de la fiche
98 + # (tableau Centris) priment sur l'extraction de texte libre ci-dessous
99 + if self.year_built is None and self.details.get("Année de construction"):
100 + self.year_built = parse_year(self.details["Année de construction"])
101 + if self.area_sqft is None and self.details.get("Superficie habitable"):
102 + self.area_sqft = parse_area_sqft(str(self.details["Superficie habitable"]))
103 + if self.lot_sqft is None and self.details.get("Superficie du terrain"):
104 + self.lot_sqft = parse_lot_sqft(str(self.details["Superficie du terrain"]))
105 +
96 106 texte = " ".join(filter(None, (self.title, self.description,
97 107 " ".join(self.features))))
98 108 if self.bedrooms is None or self.bathrooms is None:
99 109