feat: enrichissement fiches Kijiji (description, attributs, galerie 1600px) et LesPAC (adresse civique, caractéristiques, galerie basephoto) + champs annexes Ubee
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4 changed files +141 −4
modified
immoka/connectors/_detailutil.py
+4 −1
@@ -157,7 +157,10 @@ def apply_detail(lst: PropertyListing, d: dict) -> None: | ||
| 157 | 157 | lst.details.update(d["details"]) |
| 158 | 158 | if d.get("broker_name"): |
| 159 | 159 | lst.broker_name = d["broker_name"] |
| 160 | − for f in ("description", "price_label"): | |
| 160 | + # description : on garde la plus riche (la fiche détail bat le résumé liste) | |
| 161 | + if d.get("description") and len(d["description"]) > len(lst.description or ""): | |
| 162 | + lst.description = d["description"] | |
| 163 | + for f in ("price_label", "address", "city", "sector", "property_type"): | |
| 161 | 164 | if d.get(f) and not getattr(lst, f, ""): |
| 162 | 165 | setattr(lst, f, d[f]) |
| 163 | 166 | for f in ("bedrooms", "bathrooms", "powder_rooms", "year_built", |
modified
immoka/connectors/kijiji.py
+66 −1
@@ -16,6 +16,8 @@ import re | ||
| 16 | 16 | from ..schema import PropertyListing |
| 17 | 17 | from .base import BaseConnector |
| 18 | 18 | |
| 19 | +from . import _detailutil as du | |
| 20 | + | |
| 19 | 21 | BASE = "https://www.kijiji.ca" |
| 20 | 22 | # (code catégorie, segment d'URL, type canonique) |
| 21 | 23 | CATEGORIES = [ |
@@ -24,6 +26,67 @@ CATEGORIES = [ | ||
| 24 | 26 | (641, "b-terrain-a-vendre", "Terrain"), |
| 25 | 27 | ] |
| 26 | 28 | MAX_PAGES = int(os.environ.get("IMMOKA_KIJIJI_MAX_PAGES", "100")) |
| 29 | +DETAIL_LIMIT = int(os.environ.get("IMMOKA_KIJIJI_DETAIL_LIMIT", "400")) | |
| 30 | + | |
| 31 | +_ATTR_LABELS = { | |
| 32 | + "numberbedrooms": "Chambres", "numberbathrooms": "Salles de bain", | |
| 33 | + "areainfeet": "Superficie (pi²)", "forsalebyhousing": "À vendre par", | |
| 34 | + "yearbuilt": "Année de construction", "sizesqft": "Superficie (pi²)", | |
| 35 | +} | |
| 36 | + | |
| 37 | + | |
| 38 | +def _parse_kijiji_detail(html: str) -> dict: | |
| 39 | + """Fiche Kijiji : description complète, attributs, galerie haute résolution.""" | |
| 40 | + m = re.search( | |
| 41 | + r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', | |
| 42 | + html, re.S) | |
| 43 | + if not m: | |
| 44 | + return {} | |
| 45 | + try: | |
| 46 | + data = json.loads(m.group(1)) | |
| 47 | + except ValueError: | |
| 48 | + return {} | |
| 49 | + apollo = data.get("props", {}).get("pageProps", {}).get("__APOLLO_STATE__", {}) | |
| 50 | + it = next((v for k, v in apollo.items() | |
| 51 | + if k.startswith("StandardListing:") and isinstance(v, dict) | |
| 52 | + and v.get("description")), None) | |
| 53 | + if not it: | |
| 54 | + return {} | |
| 55 | + out: dict = {} | |
| 56 | + if it.get("description"): | |
| 57 | + out["description"] = str(it["description"]).strip()[:6000] | |
| 58 | + imgs = [re.sub(r"rule=kijijica-\d+-\w+", "rule=kijijica-1600-jpg", u) | |
| 59 | + for u in it.get("imageUrls") or []] | |
| 60 | + if imgs: | |
| 61 | + out["images"] = imgs | |
| 62 | + features, details = [], {} | |
| 63 | + for a in (it.get("attributes") or {}).get("all") or []: | |
| 64 | + cn = a.get("canonicalName") or "" | |
| 65 | + val = ", ".join(str(v) for v in a.get("values") or []) | |
| 66 | + if not val: | |
| 67 | + continue | |
| 68 | + label = _ATTR_LABELS.get(cn, a.get("name") or cn) | |
| 69 | + features.append(f"{label} : {val}") | |
| 70 | + details[label] = val | |
| 71 | + if cn == "numberbedrooms" and val.isdigit(): | |
| 72 | + out["bedrooms"] = int(val) | |
| 73 | + elif cn == "numberbathrooms" and val.isdigit(): | |
| 74 | + out["bathrooms"] = int(val) | |
| 75 | + elif cn in ("areainfeet", "sizesqft"): | |
| 76 | + mn = re.search(r"[\d.]+", val.replace(",", "")) | |
| 77 | + if mn: | |
| 78 | + out["area_sqft"] = float(mn.group(0)) | |
| 79 | + elif cn == "yearbuilt" and val.isdigit(): | |
| 80 | + out["year_built"] = int(val) | |
| 81 | + if features: | |
| 82 | + out["features"] = features | |
| 83 | + if details: | |
| 84 | + out["details"] = details | |
| 85 | + loc = it.get("location") or {} | |
| 86 | + addr = (loc.get("address") or "").replace(", Canada", "") | |
| 87 | + if re.match(r"\s*\d", addr): | |
| 88 | + out["address"] = addr.split(",")[0] | |
| 89 | + return out | |
| 27 | 90 | |
| 28 | 91 | |
| 29 | 92 | class KijijiConnector(BaseConnector): |
@@ -99,4 +162,6 @@ class KijijiConnector(BaseConnector): | ||
| 99 | 162 | # plus rien de neuf (page de fin remplie de topAds répétés) |
| 100 | 163 | if fresh == 0 or len(items) < 10: |
| 101 | 164 | break |
| 102 | − return list(out.values()) | |
| 165 | + listings = list(out.values()) | |
| 166 | + du.enrich(self, listings, DETAIL_LIMIT, _parse_kijiji_detail, key="v1") | |
| 167 | + return listings | |
modified
immoka/connectors/lespac.py
+63 −1
@@ -10,13 +10,18 @@ | ||
| 10 | 10 | # ----------------------------------------------------------------------------- |
| 11 | 11 | from __future__ import annotations |
| 12 | 12 | |
| 13 | +import html as _html | |
| 13 | 14 | import json |
| 15 | +import os | |
| 14 | 16 | import re |
| 15 | 17 | |
| 16 | 18 | from ..schema import PropertyListing |
| 17 | 19 | from .base import BaseConnector |
| 18 | 20 | |
| 21 | +from . import _detailutil as du | |
| 22 | + | |
| 19 | 23 | BASE = "https://www.lespac.com" |
| 24 | +DETAIL_LIMIT = int(os.environ.get("IMMOKA_LESPAC_DETAIL_LIMIT", "400")) | |
| 20 | 25 | CATEGORIES = [ |
| 21 | 26 | (37, "immobilier-achat-vente-residentiel", "Maison"), |
| 22 | 27 | (38, "immobilier-achat-vente-terrains", "Terrain"), |
@@ -89,4 +94,61 @@ class LesPacConnector(BaseConnector): | ||
| 89 | 94 | if lst is not None: |
| 90 | 95 | out.setdefault(lst.uid, lst) |
| 91 | 96 | page += 1 |
| 92 | − return list(out.values()) | |
| 97 | + listings = list(out.values()) | |
| 98 | + du.enrich(self, listings, DETAIL_LIMIT, _parse_lespac_detail, key="v1") | |
| 99 | + return listings | |
| 100 | + | |
| 101 | + | |
| 102 | +def _clean(s: str) -> str: | |
| 103 | + return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", s))).strip() | |
| 104 | + | |
| 105 | + | |
| 106 | +def _parse_lespac_detail(html: str) -> dict: | |
| 107 | + """Fiche LesPAC : description complète, adresse civique, caractéristiques | |
| 108 | + (boîte « Caractéristiques » : <p><span>Label</span><span>Valeur</span></p>) | |
| 109 | + et galerie pleine taille (binary/basephoto).""" | |
| 110 | + out: dict = {} | |
| 111 | + md = re.search(r'class="description"[^>]*>(.*?)</(?:p|div)>', html, re.S | re.I) | |
| 112 | + if md: | |
| 113 | + desc = _clean(md.group(1)) | |
| 114 | + if desc: | |
| 115 | + out["description"] = desc[:6000] | |
| 116 | + features, details = [], {} | |
| 117 | + mbox = re.search(r'>Caractéristiques</p>\s*<div class="box">(.*?)</div>', | |
| 118 | + html, re.S) | |
| 119 | + if mbox: | |
| 120 | + for lm, vm in re.findall(r"<p><span>(.*?)</span>\s*<span>(.*?)</span>", | |
| 121 | + mbox.group(1), re.S): | |
| 122 | + label, value = _clean(lm), _clean(vm) | |
| 123 | + if not label or not value: | |
| 124 | + continue | |
| 125 | + features.append(f"{label} : {value}") | |
| 126 | + details[label] = value | |
| 127 | + if label == "Adresse" and re.match(r"\s*\d", value): | |
| 128 | + out["address"] = value | |
| 129 | + elif label == "Année": | |
| 130 | + my = re.search(r"(18|19|20)\d{2}", value) | |
| 131 | + if my: | |
| 132 | + out["year_built"] = int(my.group(0)) | |
| 133 | + elif label == "Type de propriété": | |
| 134 | + out["property_type"] = value | |
| 135 | + elif "chambre" in label.lower(): | |
| 136 | + mn = re.search(r"\d+", value) | |
| 137 | + if mn: | |
| 138 | + out["bedrooms"] = int(mn.group(0)) | |
| 139 | + elif "salle" in label.lower() and "bain" in label.lower(): | |
| 140 | + mn = re.search(r"\d+", value) | |
| 141 | + if mn: | |
| 142 | + out["bathrooms"] = int(mn.group(0)) | |
| 143 | + if features: | |
| 144 | + out["features"] = features | |
| 145 | + if details: | |
| 146 | + out["details"] = details | |
| 147 | + imgs, seen = [], set() | |
| 148 | + for u in re.findall(r'https://cdn\.lespac\.com/binary/basephoto/\d+\.jpg', html): | |
| 149 | + if u not in seen: | |
| 150 | + seen.add(u) | |
| 151 | + imgs.append(u) | |
| 152 | + if imgs: | |
| 153 | + out["images"] = imgs | |
| 154 | + return out | |
modified
immoka/connectors/ubee.py
+8 −1
@@ -72,7 +72,14 @@ class UbeeConnector(BaseConnector): | ||
| 72 | 72 | lot_sqft=round(land * M2_TO_SQFT) if land else None, |
| 73 | 73 | year_built=it.get("yearBuilt"), |
| 74 | 74 | details={k: it.get(k) for k in |
| 75 | − ("propertyType", "buildingType", "toBuild") if it.get(k)}, | |
| 75 | + ("propertyType", "buildingType", "toBuild", "taxable", | |
| 76 | + "openHouseDetail", "isOnlineSince") if it.get(k)}, | |
| 77 | + features=[f for f in ( | |
| 78 | + f"Type de bâtiment : {it['buildingType']}" if it.get("buildingType") else "", | |
| 79 | + f"Sous-type : {it['propertyType']}" if it.get("propertyType") else "", | |
| 80 | + "Neuf / à construire" if it.get("toBuild") else "", | |
| 81 | + "Prix taxable (+tx)" if it.get("taxable") else "", | |
| 82 | + ) if f], | |
| 76 | 83 | images=images, |
| 77 | 84 | lat=it.get("latitude"), |
| 78 | 85 | lng=it.get("longitude"), |
| 79 | 86 | |