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