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.
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 | |