SPB Git

spb/immo-ka Public

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

Python 67.2% TypeScript 19.4% CSS 12.9% HTML 0.5%

feat: +agences source.immo (Domicilia…), KW Prestige, Scrapfly (js_scenario), géocodage Adresses Québec, fix double-titre quartier

- 5 agences source.immo (Domicilia 1427, Immodev, Réalta, Gaillardetz…) via balayage clients source.immo
- KW Prestige (277 fiches via sitemap Centris) → 81 connecteurs
- base.py : méthode scrapfly() complète (js_scenario scroll, proxy_pool, rejeu API JSON via ASP)
- geocode : Adresses Québec (MERN) prioritaire + visibles seulement (10× plus rapide)
- fix fiche : bloc « Le quartier » (plus de double titre) déplacé après les infos propriété (ordre mobile)
- Coldwell Banker / eXp : documentés a_faire (feed non-QC / gated), notes techniques

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 h ago (Aug 12, 2026) parent 9970966

Showing 7 changed files with +330 and −20

added data/quartier.db +0 −0

Binary file not shown.

added data/source_immo_agencies.json +47 −0
@@ -0,0 +1,47 @@
1 +[
2 + {
3 + "id": "realta",
4 + "name": "Réalta Agence Immobilière",
5 + "domain": "www.realta.ca",
6 + "account": "e194f20f-f55f-4667-969a-eaa682cf057e",
7 + "api": "68a52c3d-0e3c-46fa-be6a-6a77b53f0941",
8 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
9 + "view": "c2c07cb5-2208-4d99-9011-e79f8e126230"
10 + },
11 + {
12 + "id": "immodev",
13 + "name": "Immodev Agence Immobilière",
14 + "domain": "www.immodev.ca",
15 + "account": "4aabc46f-23a9-4933-8b7e-87fd85c87a18",
16 + "api": "b495482c-5c80-4818-b225-ee79817d25db",
17 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
18 + "view": "18446f15-3781-487e-8758-58a1b9d53465"
19 + },
20 + {
21 + "id": "gaillardetz",
22 + "name": "Gaillardetz Agence Immobilière",
23 + "domain": "www.gaillardetz.ca",
24 + "account": "44654d27-d2ef-4362-a408-8e5bc41ba147",
25 + "api": "bc1adc4c-f206-4129-b3be-ae8b6206a7c2",
26 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
27 + "view": "d5368a27-402b-44b4-9f10-067af7e3f2f2"
28 + },
29 + {
30 + "id": "domicilia",
31 + "name": "Domicilia Agence Immobilière",
32 + "domain": "www.domicilia.com",
33 + "account": "02f9532b-c512-483a-9354-5ddf9fda8468",
34 + "api": "75a0468e-8405-410f-a80a-6ce87a975e80",
35 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
36 + "view": "eb448815-be1d-4592-91a5-12908bdc2e24"
37 + },
38 + {
39 + "id": "niddamour",
40 + "name": "Nid d'Amour Agence Immobilière",
41 + "domain": "www.niddamour.ca",
42 + "account": "08d1a0fd-0673-48d0-bb86-e40fe529bfdb",
43 + "api": "80e7941c-66b1-45a0-b6e0-622b401367e0",
44 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
45 + "view": "25cb8710-6c99-47ce-b137-8c667b63062c"
46 + }
47 +]
\ No newline at end of file
modified data/sources.json +79 −2
@@ -52,7 +52,7 @@
52 52 "connector": "century21",
53 53 "status": "actif",
54 54 "type": "franchise",
55 "note": "Central c21.ca->iframe moxiworks verrouillé (503/WAF). Couvert via sous-agences source.immo (c21_ag_*)."
55 + "note": "Central c21.ca->moxiworks (company_uuid=3341197) : même flux search_v2 anonyme accessible, MAIS données Rest-of-Canada/MLS (pas Centris QC : Québec-ville=0). La couverture QC de C21 vient des sous-agences source.immo (c21_ag_*), déjà actives. Le central moxi n'ajoute rien au QC."
56 56 },
57 57 {
58 58 "id": "proprio_direct",
@@ -74,7 +74,7 @@
74 74 "connector": "exp_quebec",
75 75 "status": "a_faire",
76 76 "type": "agence",
77 "note": "Next.js. Scrapfly ASP+render_js rend le shell mais les inscriptions sont hydratees cote client via BoldTrail/kvCORE (2-4 cartes seulement rendues ; virtualisation). La route _next/data/<buildId>/fr-CA/search.json ne contient PAS les listings. Reste : capturer l'XHR BoldTrail live (js_scenario/scroll Scrapfly ou navigateur instrumente) ou l'API kvCORE authentifiee."
77 + "note": "Next.js + kvCORE/BoldTrail. Scrapfly asp+render_js rend la page mais ?state=QC NE filtre PAS (cartes = ON/Toronto par defaut, data-adm1st=ON) ; cartes marquees data-compliance-requirement=signIn (DDF gated). js_scenario scroll n'ajoute pas de cartes (18 data-listing, 6 uniques, ON). Pages region QC (/montreal-qc-real-estate) rendent 0 carte. Reste: API kvCORE authentifiee (provider-id 30000) filtree state=QC, non contournable sans session."
78 78 },
79 79 {
80 80 "id": "kw_urbain",
@@ -251,6 +251,83 @@
251 251 "status": "actif",
252 252 "type": "fsbo",
253 253 "note": "FSBO. Enumeration via sitemaps FR par region QC (/sitemaps/fr/<region>-listings.xml.gz) : l'URL encode region/ville/type/id. Fiche detail JSON-LD Product (prix, description, galerie photos.duproprio.com). Fetch direct + Scrapfly ASP en secours. agency='DuProprio (sans courtier)'."
254 + },
255 + {
256 + "id": "realta",
257 + "name": "Réalta Agence Immobilière",
258 + "url": "https://www.realta.ca",
259 + "listing_url": "https://www.realta.ca/proprietes/",
260 + "coverage": "Agence indépendante (Québec)",
261 + "connector": "realta",
262 + "status": "actif",
263 + "type": "agence",
264 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 83 propriétés à vendre. Généré par agences_source_immo.py."
265 + },
266 + {
267 + "id": "immodev",
268 + "name": "Immodev Agence Immobilière",
269 + "url": "https://www.immodev.ca",
270 + "listing_url": "https://www.immodev.ca/proprietes/",
271 + "coverage": "Agence indépendante (Québec)",
272 + "connector": "immodev",
273 + "status": "actif",
274 + "type": "agence",
275 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 93 propriétés à vendre. Généré par agences_source_immo.py."
276 + },
277 + {
278 + "id": "gaillardetz",
279 + "name": "Gaillardetz Agence Immobilière",
280 + "url": "https://www.gaillardetz.ca",
281 + "listing_url": "https://www.gaillardetz.ca/proprietes/",
282 + "coverage": "Agence indépendante (Québec)",
283 + "connector": "gaillardetz",
284 + "status": "actif",
285 + "type": "agence",
286 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 13 propriétés à vendre. Généré par agences_source_immo.py."
287 + },
288 + {
289 + "id": "domicilia",
290 + "name": "Domicilia Agence Immobilière",
291 + "url": "https://www.domicilia.com",
292 + "listing_url": "https://www.domicilia.com/proprietes/",
293 + "coverage": "Agence indépendante (Québec)",
294 + "connector": "domicilia",
295 + "status": "actif",
296 + "type": "agence",
297 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 1427 propriétés à vendre. Généré par agences_source_immo.py."
298 + },
299 + {
300 + "id": "niddamour",
301 + "name": "Nid d'Amour Agence Immobilière",
302 + "url": "https://www.niddamour.ca",
303 + "listing_url": "https://www.niddamour.ca/proprietes/",
304 + "coverage": "Agence indépendante (Québec)",
305 + "connector": "niddamour",
306 + "status": "a_faire",
307 + "type": "agence",
308 + "note": "Plateforme source.immo (API publique api-v1.source.immo, config auto _configs.json). 0 propriétés à vendre. Généré par agences_source_immo.py."
309 + },
310 + {
311 + "id": "kw_prestige",
312 + "name": "Keller Williams Prestige",
313 + "url": "https://www.kwprestige.com",
314 + "listing_url": "https://www.kwprestige.com/sitemap.xml",
315 + "coverage": "Grand Montréal — bannière KW prestige",
316 + "connector": "kw_prestige",
317 + "status": "actif",
318 + "type": "franchise",
319 + "note": "Grille/fiche en widget JS (pagination AJAX non exploitable). Enumeration via sitemap.xml (277 fiches a-vendre) ; slug URL = type-a-vendre-ville-secteur-{noCentris}-detail-Fr. Champs liste: type, ville, secteur, n Centris. Prix/photos = widget JS (list cards rendus serveur mais pagination JS ; enrichissement futur via Scrapfly js_scenario)."
320 + },
321 + {
322 + "id": "coldwell_banker",
323 + "name": "Coldwell Banker Québec",
324 + "url": "https://coldwellbanker.ca",
325 + "listing_url": "https://coldwellbanker.ca/",
326 + "coverage": "Québec — franchise (L'Héritage, Prestige…)",
327 + "connector": "coldwell_banker",
328 + "status": "a_faire",
329 + "type": "franchise",
330 + "note": "moxiworks org m102181-1733247042 / company_uuid=4277740. FLUX ANONYME CRACKÉ : GET svc.moxiworks.com/service/v1/listing/search_v2?company_uuid=<id>&bounds_west/east/south/north=..&status=active (pas d'auth ; le WAF n'est que sur listings.moxiworks.com, pas sur svc). MAIS le feed = Rest-of-Canada/MLS (TREB Toronto : MLS C.../T...) ; couverture Québec/Centris ~nulle (Québec-ville=0, Montréal=4 fiches à MLS torontois). Non exploitable pour Immo-Ka (QC). CB Québec quasi inexistant sur ce feed."
254 331 }
255 332 ]
256 333 }
\ No newline at end of file
modified frontend/src/pages/Listing.tsx +3 −6
@@ -203,12 +203,6 @@ export default function ListingPage() {
203 203 </section>
204 204 )}
205 205
206 {l.quartier && (
207 <section className="f-bloc" id="quartier">
208 <h2>Le quartier</h2>
209 <QuartierBlock q={l.quartier} />
210 </section>
211 )}
212 206 </div>
213 207
214 208 {/* -------- colonne droite : synthèse, specs, courtier ---------------- */}
@@ -285,6 +279,9 @@ export default function ListingPage() {
285 279 </div>
286 280 </div>
287 281
282 + {/* quartier : pleine largeur, APRÈS les infos de propriété (ordre mobile correct) */}
283 + {l.quartier && <QuartierBlock q={l.quartier} />}
284 +
288 285 <div className="fine f-foot">
289 286 Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à
290 287 l'annonce originale de l'agence.
added immoka/connectors/agences_source_immo.py +77 −0
@@ -0,0 +1,77 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/agences_source_immo.py : agences INDÉPENDANTES sur la plateforme
5 +# source.immo (plugin WordPress). Beaucoup de petites/moyennes agences QC
6 +# l'utilisent (liste sur source.immo). Chacune expose sa config publique à
7 +# /wp-content/uploads/_sourceimmo/_configs.json → on lit l'API publique
8 +# api-v1.source.immo (voir SourceImmoConnector). Ce sont des agences
9 +# autonomes : leurs inscriptions (leur propre n° Centris) sont ADDITIVES —
10 +# pas de sous-agence d'une bannière déjà couverte → source_id normal (pas _ag_).
11 +#
12 +# Un connecteur par agence est généré depuis data/source_immo_agencies.json.
13 +# -----------------------------------------------------------------------------
14 +from __future__ import annotations
15 +
16 +import json
17 +from pathlib import Path
18 +
19 +from .sourceimmo import SourceImmoConnector
20 +from ..schema import PropertyListing
21 +
22 +REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "source_immo_agencies.json"
23 +
24 +
25 +class _AgenceSourceImmo(SourceImmoConnector):
26 + """Agence indépendante source.immo. Recharge sa config depuis le site
27 + (résilient aux rotations d'UUID) ; agency_name pour l'affichage Sources."""
28 +
29 + agency_name = ""
30 +
31 + def fetch(self) -> list[PropertyListing]:
32 + base = self.site_url.rstrip("/")
33 + try:
34 + cfg = self.get(f"{base}/wp-content/uploads/_sourceimmo/_configs.json").json()
35 + self.account_id = cfg.get("account_id", self.account_id)
36 + self.api_key = cfg.get("api_key", self.api_key)
37 + self.app_id = cfg.get("app_id", self.app_id)
38 + self.view_id = cfg.get("default_view", self.view_id)
39 + except Exception:
40 + pass
41 + return super().fetch()
42 +
43 + def _to_listing(self, it: dict) -> PropertyListing | None:
44 + lst = super()._to_listing(it)
45 + if lst is not None:
46 + lst.broker_name = self.agency_name
47 + lst.agency = self.agency_name
48 + ref = it.get("ref_number") or it.get("id")
49 + if ref and self.site_url:
50 + lst.url = f"{self.site_url.rstrip('/')}/propriete/{ref}"
51 + return lst
52 +
53 +
54 +def _load() -> list[dict]:
55 + try:
56 + return json.loads(REGISTRY.read_text(encoding="utf-8"))
57 + except Exception:
58 + return []
59 +
60 +
61 +# Génère une classe par agence du registre.
62 +for _ag in _load():
63 + if not all(_ag.get(k) for k in ("id", "domain", "account", "api", "app", "view")):
64 + continue
65 + _sid = _ag["id"]
66 + _dom = _ag["domain"].rstrip("/")
67 + globals()[f"AGENCE_{_sid.upper()}"] = type(
68 + "AgenceSI" + "".join(p.title() for p in _sid.split("_")),
69 + (_AgenceSourceImmo,),
70 + {
71 + "source_id": _sid,
72 + "account_id": _ag["account"], "api_key": _ag["api"],
73 + "app_id": _ag["app"], "view_id": _ag["view"],
74 + "site_url": f"https://{_dom}/",
75 + "agency_name": _ag.get("name", _sid.title()),
76 + },
77 + )
modified immoka/connectors/base.py +42 −12
@@ -6,6 +6,7 @@
6 6 # -----------------------------------------------------------------------------
7 7 from __future__ import annotations
8 8
9 +import json
9 10 import os
10 11 import time
11 12
@@ -87,15 +88,20 @@ class BaseConnector:
87 88 data = resp.json()
88 89 return (data.get("data") or {}).get("html", "")
89 90
90 def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
91 rendering_wait: int = 0, country: str = "ca",
92 wait_for_selector: str | None = None) -> str:
93 """HTML rendu via Scrapfly (ASP = bypass anti-bot avancé + rendu JS).
94
95 Nécessite SCRAPFLY_KEY dans l'environnement (.env). Meilleur que Firecrawl
96 sur Cloudflare avancé et sur les SPA chargées via XHR (BoldTrail, etc.).
97 Retourne le HTML (result.content) ou "" en cas d'échec ASP.
91 + def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
92 + rendering_wait: int = 0, country: str = "ca",
93 + wait_for_selector: str | None = None,
94 + js_scenario: list | str | None = None,
95 + proxy_pool: str | None = None, headers: dict | None = None,
96 + method: str = "GET", body: str | None = None) -> dict:
97 + """Appel Scrapfly complet — retourne le dict `result` (content, status_code…).
98 +
99 + - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (encodée base64)
100 + pour charger les listes virtualisées (BoldTrail/kvCORE, etc.).
101 + - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif).
102 + - `headers`/`method`/`body` : pour REJOUER une API JSON interne via ASP.
98 103 """
104 + import base64
99 105 key = os.environ.get("SCRAPFLY_KEY")
100 106 if not key:
101 107 raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")
@@ -108,16 +114,40 @@ class BaseConnector:
108 114 params["rendering_wait"] = rendering_wait
109 115 if wait_for_selector:
110 116 params["wait_for_selector"] = wait_for_selector
117 + if proxy_pool:
118 + params["proxy_pool"] = proxy_pool
119 + if js_scenario is not None:
120 + js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario)
121 + params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode()
122 + if headers:
123 + for k, v in headers.items():
124 + params[f"headers[{k}]"] = v
111 125 wait = self.request_delay - (time.time() - self._last_request)
112 126 if wait > 0:
113 127 time.sleep(wait)
114 resp = requests.get(SCRAPFLY_API, params=params, timeout=180)
128 + if method.upper() == "POST":
129 + resp = requests.post(SCRAPFLY_API, params=params,
130 + data=(body or ""), timeout=180)
131 + else:
132 + resp = requests.get(SCRAPFLY_API, params=params, timeout=180)
115 133 self._last_request = time.time()
116 134 try:
117 data = resp.json()
135 + return resp.json().get("result") or {}
118 136 except ValueError:
119 return ""
120 return ((data.get("result") or {}).get("content")) or ""
137 + return {}
138 +
139 + def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
140 + rendering_wait: int = 0, country: str = "ca",
141 + wait_for_selector: str | None = None,
142 + js_scenario: list | str | None = None,
143 + proxy_pool: str | None = None) -> str:
144 + """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne
145 + le HTML (result.content) ou "" en cas d'échec ASP."""
146 + return self.scrapfly(url, render_js=render_js, asp=asp,
147 + rendering_wait=rendering_wait, country=country,
148 + wait_for_selector=wait_for_selector,
149 + js_scenario=js_scenario, proxy_pool=proxy_pool
150 + ).get("content") or ""
121 151
122 152 def detail(self, external_id: str, key: str, fetch_fn) -> dict:
123 153 """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si
added immoka/connectors/kw_prestige.py +82 −0
@@ -0,0 +1,82 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/kw_prestige.py : Keller Williams Prestige (kwprestige.com)
5 +# Bannière KW du Grand Montréal. La grille /fr/proprietes/ et les fiches détail
6 +# sont rendues par un widget JS (pagination AJAX, pas d'URL exploitable). Mais
7 +# le sitemap.xml expose TOUTES les fiches : URL
8 +# « {type}-a-vendre-{ville-secteur}-{no-centris}-detail-Fr ». On énumère donc
9 +# par le sitemap (rendu serveur, fiable) ; type/ville/secteur/n° Centris depuis
10 +# le slug. external_id = n° Centris → déduplication.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +
16 +from .base import BaseConnector
17 +from ..schema import PropertyListing
18 +
19 +SITEMAP = "https://www.kwprestige.com/sitemap.xml"
20 +# {type}-a-vendre-{ville-secteur}-{centris}-detail-Fr
21 +_URL_RE = re.compile(
22 + r'https://www\.kwprestige\.com/([a-z0-9-]+)-a-vendre-([a-z0-9-]+?)-(\d{7,8})-detail-[A-Za-z]+',
23 + re.I)
24 +
25 +_TYPE = [
26 + ("location-d-espace", "Commercial"), ("location-de", "Commercial"),
27 + ("location", "Commercial"),
28 + ("maison-etages", "Maison"), ("maison-paliers", "Maison"),
29 + ("maison-plain-pied", "Maison"), ("maison-mobile", "Maison mobile"),
30 + ("maison", "Maison"), ("appartement", "Condo"), ("condo", "Condo"),
31 + ("loft", "Condo"), ("duplex", "Duplex"), ("triplex", "Triplex"),
32 + ("quadruplex", "Multiplex"), ("quintuplex", "Multiplex"),
33 + ("propriete-revenu", "Multiplex"), ("terrain", "Terrain"),
34 + ("chalet", "Chalet"), ("fermette", "Fermette/Agricole"),
35 + ("ferme", "Fermette/Agricole"),
36 + ("local-commercial", "Commercial"), ("espace-commercial", "Commercial"),
37 + ("espace-industriel", "Commercial"), ("espace-bureau", "Commercial"),
38 + ("commerce", "Commercial"), ("bureau", "Commercial"),
39 + ("batisse", "Commercial"), ("industriel", "Commercial"),
40 +]
41 +# villes composées fréquentes (sinon 1er token = ville)
42 +_CITY_FIX = {"montreal": "Montréal", "quebec": "Québec", "levis": "Lévis",
43 + "trois": "Trois-Rivières", "saint": "Saint", "sainte": "Sainte"}
44 +
45 +
46 +class KwPrestigeConnector(BaseConnector):
47 + source_id = "kw_prestige"
48 + request_delay = 0.4
49 +
50 + def fetch(self) -> list[PropertyListing]:
51 + try:
52 + xml = self.get(SITEMAP).text
53 + except Exception:
54 + return []
55 + by_id: dict[str, PropertyListing] = {}
56 + for m in _URL_RE.finditer(xml):
57 + typ_slug, loc_slug, centris = m.group(1), m.group(2), m.group(3)
58 + if centris in by_id:
59 + continue
60 + by_id[centris] = self._to_listing(m.group(0), typ_slug, loc_slug, centris)
61 + return list(by_id.values())
62 +
63 + def _to_listing(self, url, typ_slug, loc_slug, centris) -> PropertyListing:
64 + ptype = ""
65 + for key, canon in _TYPE:
66 + if typ_slug.startswith(key):
67 + ptype = canon
68 + break
69 + parts = loc_slug.split("-")
70 + city = _CITY_FIX.get(parts[0], parts[0].title()) if parts else ""
71 + sector = " ".join(parts[1:]).title() if len(parts) > 1 else ""
72 + return PropertyListing(
73 + source=self.source_id, external_id=centris, url=url,
74 + title=_deslug(loc_slug), address="", city=city, sector=sector,
75 + region="", property_type=ptype, mls=centris,
76 + broker_name="Keller Williams Prestige",
77 + agency="Keller Williams Prestige",
78 + )
79 +
80 +
81 +def _deslug(s: str) -> str:
82 + return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()
83