SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 798091f

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