SPB Git

spb/house-ka

Public

feat(canada): couverture pan-canadienne hors Québec

- connecteur c21_canada.py : API MoxiWorks corporative de c21.ca (tous les
  bureaux Century 21 du pays), 1 source par province (c21_bc … c21_nt).
  Rétro-ingénierie : attribution obligatoire, startidx inerte, pgsize≤500 →
  tranches de prix adaptatives ≤480 fiches, 1 requête/tranche, réponse liste
  complète (prix, GPS, photo, description, MLS) sans passe détail.
- realtypress.py pan-canadien : provinces dans les regex, region par fiche,
  exclusion des fiches Québec (immo-ka), registre data/canada_agencies.json
  (renommé, champ province) + 4 sites hors ON (hanlonrealty NL ~22k,
  denisedunn BC, reddoor NS, raymondanthony AB).
- scouts scripts/scout_canada_rp*.py (Serper + validation live) + merge.
- frontend : héro/footer « coast to coast, every province except Québec ».
- normalize : residential -> House.
Simon-Pierre Boucher committed (Aug 27, 2026) parent bc58598 Browse files

15 changed files +1,695 −136

modified README.md +1 −1
@@ -63,5 +63,5 @@ the source of truth, `origin` = spbgit (`gitsrv:house-ka.git`).
63 63
64 RealtyPress sites exist across Canada. Census & instructions: 64 RealtyPress sites exist across Canada. Census & instructions:
65 `docs/ontario-agencies.md` (method transposes to any province). Add the site 65 `docs/ontario-agencies.md` (method transposes to any province). Add the site
66−to `data/ontario_agencies.json` + an entry in `data/sources.json`, then 66+to `data/canada_agencies.json` + an entry in `data/sources.json`, then
67 `python run.py sync <id>`. The coordinate guard covers all of Canada. 67 `python run.py sync <id>`. The coordinate guard covers all of Canada.
added c21_canada.py +230 −0
@@ -0,0 +1,230 @@
1+# -----------------------------------------------------------------------------
2+# House-Ka — Agrégateur de maisons à vendre (Canada hors Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/c21_canada.py : Century 21 Canada — API MoxiWorks corporative.
5+# c21.ca (tous les bureaux C21 du pays) sert sa recherche via l'API JSONP
6+# svc.moxiworks.com `/service/v1/listing/search_v2`. La réponse LISTE contient
7+# déjà tout (prix, chambres, GPS, photo, description, MLS®) : aucune passe
8+# détail nécessaire. Un connecteur par PROVINCE (source c21_bc, c21_ab, …).
9+#
10+# Rétro-ingénierie 2026-08-27 (sniff Playwright sur c21.ca/search) :
11+# - les paramètres d'ATTRIBUTION (`send_from_agent`, `from_aws`, `from_app`,
12+# `site_owner_uuid`) sont OBLIGATOIRES — sans eux `result_list` reste vide ;
13+# - `location_search_field=<Province>` filtre par province, pagination
14+# `pgsize` (200 max utile) + `startidx` ;
15+# - la liste n'est retournée QUE si `number_found` ≲ 6-10 k → découpage
16+# ADAPTATIF par tranches de prix (`pricemin`/`pricemax`) jusqu'à ≤ SLICE_MAX,
17+# puis pagination de chaque tranche ;
18+# - `pstatus=1,11` (active + coming soon), `ptype=1..9` (tous types résidentiels).
19+# Fiche publique : https://www.c21.ca/listing<url_slug>.
20+# -----------------------------------------------------------------------------
21+from __future__ import annotations
22+
23+import json
24+import re
25+import time
26+
27+from .base import BaseConnector
28+from ..schema import PropertyListing
29+
30+API = "https://svc.moxiworks.com/service/v1/listing/search_v2"
31+COMPANY_UUID = "3341197" # CENTURY 21 Canada
32+SITE_OWNER = "825ef2f8-0716-4a50-862c-ddad45234e9c" # c21.ca (config publique)
33+
34+SLICE_MAX = 5500 # au-delà, l'API ne retourne plus la liste → scinder
35+PGSIZE = 200
36+PRICE_CEIL = 100_000_000
37+
38+_CB_RE = re.compile(r"^/\*\*/cb\(|\)\s*$")
39+
40+BASE_PARAMS = {
41+ "status": "active",
42+ "pstatus": "1,11",
43+ "sort_by": "10",
44+ "company_uuid": COMPANY_UUID,
45+ "ptype": "1,2,3,4,5,7,9,8",
46+ "searchType": "criteria",
47+ "omit_hidden": "true",
48+ "ex_pend": "true",
49+ "currency": "CAD",
50+ "callback": "cb",
51+ # attribution : indispensable pour obtenir result_list
52+ "send_from_agent": "true",
53+ "from_aws": "true",
54+ "from_app": "aws:https://www.c21.ca",
55+ "source": "agent website",
56+ "site_type": "Brokerage Website",
57+ "site_owner_uuid": SITE_OWNER,
58+}
59+
60+
61+def _num(v) -> float | None:
62+ try:
63+ f = float(v)
64+ return f if f == f else None
65+ except (TypeError, ValueError):
66+ return None
67+
68+
69+class _C21Province(BaseConnector):
70+ """Century 21 Canada — une province (voir les sous-classes en bas)."""
71+
72+ province = "" # libellé exact pour location_search_field
73+ state_code = "" # code à 2 lettres attendu dans location.state
74+ request_delay = 0.35
75+
76+ # ------------------------------------------------------------------ API --
77+ def _query(self, pricemin: int | None, pricemax: int | None,
78+ startidx: int = 0, pgsize: int = PGSIZE) -> dict:
79+ params = dict(BASE_PARAMS)
80+ params["location_search_field"] = self.province
81+ params["pgsize"] = str(pgsize)
82+ params["startidx"] = str(startidx)
83+ if pricemin is not None and pricemin > 0:
84+ params["pricemin"] = str(pricemin)
85+ if pricemax is not None:
86+ params["pricemax"] = str(pricemax)
87+ resp = self.get(API, params=params)
88+ body = _CB_RE.sub("", resp.text.strip())
89+ d = json.loads(body)
90+ if d.get("status") != "success":
91+ raise RuntimeError(f"c21 API: {d.get('message', d.get('status'))}")
92+ return d["data"]
93+
94+ def _count(self, lo: int | None, hi: int | None) -> int:
95+ d = self._query(lo, hi, startidx=0, pgsize=1)
96+ return int(d.get("number_found") or 0)
97+
98+ # ------------------------------------------------------ tranches de prix --
99+ def _slices(self) -> list[tuple[int | None, int | None]]:
100+ """Tranches [lo, hi] dont number_found ≤ SLICE_MAX (scission dichotomique)."""
101+ out: list[tuple[int | None, int | None]] = []
102+ stack: list[tuple[int, int]] = [(0, PRICE_CEIL)]
103+ while stack:
104+ lo, hi = stack.pop()
105+ n = self._count(lo or None, hi)
106+ time.sleep(self.request_delay)
107+ if n == 0:
108+ continue
109+ if n <= SLICE_MAX or hi - lo <= 5000:
110+ out.append((lo or None, hi))
111+ continue
112+ # scission au point médian géométrique (les prix sont log-normaux)
113+ mid = int((max(lo, 10_000) * hi) ** 0.5)
114+ if mid <= lo or mid >= hi:
115+ mid = (lo + hi) // 2
116+ stack.append((lo, mid))
117+ stack.append((mid + 1, hi))
118+ return out
119+
120+ # ------------------------------------------------------------- mapping --
121+ def _to_listing(self, r: dict) -> PropertyListing | None:
122+ loc = r.get("location") or {}
123+ state = (loc.get("state") or "").upper()
124+ if state == "QC": # périmètre House-Ka : hors Québec
125+ return None
126+ if self.state_code and state and state != self.state_code:
127+ return None
128+ lid = r.get("listingid")
129+ if not lid:
130+ return None
131+ slug = r.get("url_slug") or ""
132+ url = f"https://www.c21.ca/listing{slug}" if slug else "https://www.c21.ca"
133+
134+ # salles de bains Moxi : « 6.1 » = 6 complètes + 1 d'eau
135+ baths_raw = _num(r.get("bathrooms"))
136+ baths = powder = None
137+ if baths_raw is not None:
138+ baths = int(baths_raw)
139+ dec = round((baths_raw - baths) * 10)
140+ powder = dec if 0 < dec <= 5 else None
141+
142+ images = []
143+ for img in (r.get("image") or []):
144+ u = img.get("full_url") or img.get("gallery_url")
145+ if u:
146+ images.append(u)
147+
148+ details = {k: v for k, v in {
149+ "Property Type": r.get("property_type"),
150+ "County": loc.get("county"),
151+ "Postal code": loc.get("zip"),
152+ "Listed date": r.get("listed_date"),
153+ "Subdivision": r.get("subdivision"),
154+ "MLS® Number": r.get("mlsnumber"),
155+ "Listing office": r.get("officename") or r.get("listing_office"),
156+ }.items() if v}
157+
158+ lst = PropertyListing(
159+ source=self.source_id,
160+ external_id=f"c21{lid}",
161+ url=url,
162+ address=(loc.get("address") or "").strip(),
163+ city=(loc.get("city") or "").strip(),
164+ region=self.province,
165+ property_type=r.get("property_type") or "",
166+ price=_num(r.get("list_price")),
167+ price_label=(f"${int(r['list_price']):,}"
168+ if _num(r.get("list_price")) else ""),
169+ bedrooms=int(_num(r.get("bedrooms")) or 0) or None,
170+ bathrooms=baths,
171+ powder_rooms=powder,
172+ area_sqft=_num(r.get("sqr_footage")) or _num(r.get("living_area")),
173+ lot_sqft=_num(r.get("lot_sqr_footage")),
174+ year_built=int(_num(r.get("year_build")) or 0) or None,
175+ mls=str(r.get("mlsnumber") or ""),
176+ broker_name=(r.get("agentname") or r.get("officename") or
177+ "Century 21").strip(),
178+ agency=(r.get("officename") or "Century 21 Canada").strip(),
179+ description=(r.get("comments") or "").strip()[:6000],
180+ details=details,
181+ images=images,
182+ lat=_num(loc.get("latitude")),
183+ lng=_num(loc.get("longitude")),
184+ )
185+ return lst
186+
187+ # --------------------------------------------------------------- fetch --
188+ def fetch(self) -> list[PropertyListing]:
189+ by_id: dict[str, PropertyListing] = {}
190+ for lo, hi in self._slices():
191+ start = 0
192+ while True:
193+ d = self._query(lo, hi, startidx=start)
194+ rows = d.get("result_list") or []
195+ if not rows:
196+ break
197+ for r in rows:
198+ lst = self._to_listing(r)
199+ if lst is not None and lst.external_id not in by_id:
200+ by_id[lst.external_id] = lst
201+ n_found = int(d.get("number_found") or 0)
202+ start += len(rows)
203+ if start >= min(n_found, 100_000) or len(rows) < PGSIZE:
204+ break
205+ time.sleep(self.request_delay)
206+ time.sleep(self.request_delay)
207+ return list(by_id.values())
208+
209+
210+# --- une source par province (le Québec vit sur immo-ka) ----------------------
211+_PROVINCES = [
212+ ("c21_bc", "British Columbia", "BC"),
213+ ("c21_ab", "Alberta", "AB"),
214+ ("c21_sk", "Saskatchewan", "SK"),
215+ ("c21_mb", "Manitoba", "MB"),
216+ ("c21_on", "Ontario", "ON"),
217+ ("c21_nb", "New Brunswick", "NB"),
218+ ("c21_ns", "Nova Scotia", "NS"),
219+ ("c21_pe", "Prince Edward Island", "PE"),
220+ ("c21_nl", "Newfoundland and Labrador", "NL"),
221+ ("c21_yt", "Yukon", "YT"),
222+ ("c21_nt", "Northwest Territories", "NT"),
223+]
224+
225+for _sid, _prov, _code in _PROVINCES:
226+ globals()[f"C21_{_code}"] = type(
227+ f"C21{_code}",
228+ (_C21Province,),
229+ {"source_id": _sid, "province": _prov, "state_code": _code},
230+ )
added data/canada_agencies.json +147 −0
@@ -0,0 +1,147 @@
1+[
2+ {
3+ "id": "rp_ag_revelrealty",
4+ "name": "Revel Realty (Niagara & provincial)",
5+ "site": "https://revelrealty.ca",
6+ "archive": "listings",
7+ "max_pages": 1300,
8+ "note": "Recensement ON 2026-08-27 : ~110 145 fiches — pool DDF quasi provincial. Archive /listings/ (⚠ /listing/ = carousel 8 cartes), 108 cartes/page avec posts_per_page=100. RealtyPress/DDF.",
9+ "province": "Ontario"
10+ },
11+ {
12+ "id": "rp_ag_codygroup",
13+ "name": "The Cody Group (London/ITSO)",
14+ "site": "https://codygroup.ca",
15+ "archive": "all-regional-listings",
16+ "max_pages": 700,
17+ "note": "Recensement ON 2026-08-27 : ~58 062 fiches (London + ITSO élargi). Archive /all-regional-listings/, fiches sous le même chemin. RealtyPress/DDF.",
18+ "province": "Ontario"
19+ },
20+ {
21+ "id": "rp_ag_suttonottawa",
22+ "name": "Sutton Group — Ottawa Realty",
23+ "site": "https://suttonottawa.ca",
24+ "note": "Recensement ON 2026-08-27 : ~10 080 fiches (OREB+). RealtyPress/DDF.",
25+ "province": "Ontario"
26+ },
27+ {
28+ "id": "rp_ag_helensteam",
29+ "name": "Helen's Team (Kitchener-Waterloo)",
30+ "site": "https://helensteam.ca",
31+ "note": "Recensement ON 2026-08-27 : ~9 910 fiches (Kitchener-Waterloo). RealtyPress/DDF.",
32+ "province": "Ontario"
33+ },
34+ {
35+ "id": "rp_ag_greybruce",
36+ "name": "Grey Bruce Real Estate",
37+ "site": "https://greybrucerealestate.ca",
38+ "note": "Recensement ON 2026-08-27 : ~8 268 fiches (Grey-Bruce/Georgian Bay). RealtyPress/DDF. Même feed que collaborativerealestate.ca (fallback).",
39+ "province": "Ontario"
40+ },
41+ {
42+ "id": "rp_ag_remaxfinest",
43+ "name": "RE/MAX Finest Realty (Kingston)",
44+ "site": "https://remaxfinestrealty.com",
45+ "note": "Recensement ON 2026-08-27 : ~8 217 fiches (Kingston). RealtyPress/DDF.",
46+ "province": "Ontario"
47+ },
48+ {
49+ "id": "rp_ag_riouxbaker",
50+ "name": "Rioux Baker Real Estate Team (Collingwood)",
51+ "site": "https://riouxbakerteam.com",
52+ "note": "Recensement ON 2026-08-27 : ~7 716 fiches (Collingwood/South Georgian Bay). RealtyPress/DDF.",
53+ "province": "Ontario"
54+ },
55+ {
56+ "id": "rp_ag_countyguys",
57+ "name": "The County Guys (Prince Edward County)",
58+ "site": "https://thecountyguys.com",
59+ "note": "Recensement ON 2026-08-27 : ~6 878 fiches (Prince Edward County/Quinte). RealtyPress/DDF.",
60+ "province": "Ontario"
61+ },
62+ {
63+ "id": "rp_ag_labrosse",
64+ "name": "Labrosse Real Estate (Ottawa/Orléans)",
65+ "site": "https://labrosserealestate.com",
66+ "note": "Recensement ON 2026-08-27 : ~6 873 fiches (Ottawa/Orléans, équipe FRANCOPHONE). RealtyPress/DDF.",
67+ "province": "Ontario"
68+ },
69+ {
70+ "id": "rp_ag_ryanpattinson",
71+ "name": "Ryan Pattinson (Pembroke/Renfrew)",
72+ "site": "https://ryanpattinson.com",
73+ "note": "Recensement ON 2026-08-27 : ~6 601 fiches (Pembroke/vallée de l'Outaouais ON). RealtyPress/DDF.",
74+ "province": "Ontario"
75+ },
76+ {
77+ "id": "rp_ag_grapevine",
78+ "name": "Grapevine (Ottawa)",
79+ "site": "https://grapevine.ca",
80+ "note": "Recensement ON 2026-08-27 : ~6 550 fiches (Ottawa). RealtyPress/DDF. Suivre les redirections www/apex.",
81+ "province": "Ontario"
82+ },
83+ {
84+ "id": "rp_ag_rlpheartland",
85+ "name": "Royal LePage Heartland Realty",
86+ "site": "https://rlpheartland.ca",
87+ "note": "Recensement ON 2026-08-27 : ~5 577 fiches (Midwestern Ontario — Huron/Perth). RealtyPress/DDF.",
88+ "province": "Ontario"
89+ },
90+ {
91+ "id": "rp_ag_signaturenorth",
92+ "name": "Signature North Realty (Thunder Bay)",
93+ "site": "https://signaturenorthrealty.ca",
94+ "note": "Recensement ON 2026-08-27 : ~1 023 fiches (Thunder Bay). RealtyPress/DDF.",
95+ "province": "Ontario"
96+ },
97+ {
98+ "id": "rp_ag_saultstemarie",
99+ "name": "Sault Ste. Marie Real Estate (Century 21 Choice)",
100+ "site": "https://saultstemarierealestate.com",
101+ "note": "Recensement ON 2026-08-27 : ~864 fiches (Sault Ste. Marie). RealtyPress/DDF.",
102+ "province": "Ontario"
103+ },
104+ {
105+ "id": "rp_ag_cbnorthbay",
106+ "name": "Coldwell Banker Peter Minogue (North Bay)",
107+ "site": "https://cbnorthbay.com",
108+ "note": "Recensement ON 2026-08-27 : ~307 fiches (North Bay). RealtyPress/DDF.",
109+ "province": "Ontario"
110+ },
111+ {
112+ "id": "rp_ag_hanlonrealty",
113+ "name": "hanlonrealty.ca (Newfoundland and Labrador)",
114+ "site": "https://hanlonrealty.ca",
115+ "archive": "listing",
116+ "max_pages": 400,
117+ "province": "Newfoundland and Labrador",
118+ "note": "Recensement CANADA 2026-08-27 : ~≥22000 fiches, provinces page 1 : {}. RealtyPress/DDF."
119+ },
120+ {
121+ "id": "rp_ag_denisedunnrealtor",
122+ "name": "denisedunnrealtor.com (British Columbia)",
123+ "site": "https://denisedunnrealtor.com",
124+ "archive": "listing",
125+ "max_pages": 400,
126+ "province": "British Columbia",
127+ "note": "Recensement CANADA 2026-08-27 : ~≥2400 fiches, provinces page 1 : {'British Columbia': 24}. RealtyPress/DDF."
128+ },
129+ {
130+ "id": "rp_ag_reddoorrealty",
131+ "name": "reddoorrealty.ca (Nova Scotia)",
132+ "site": "https://reddoorrealty.ca",
133+ "archive": "listing",
134+ "max_pages": 10,
135+ "province": "Nova Scotia",
136+ "note": "Recensement CANADA 2026-08-27 : ~≥580 fiches, provinces page 1 : {'Nova Scotia': 202}. RealtyPress/DDF."
137+ },
138+ {
139+ "id": "rp_ag_raymondanthony",
140+ "name": "raymondanthony.com (Alberta)",
141+ "site": "https://raymondanthony.com",
142+ "archive": "listing",
143+ "max_pages": 10,
144+ "province": "Alberta",
145+ "note": "Recensement CANADA 2026-08-27 : ~≥60 fiches, provinces page 1 : {'Alberta': 25}. RealtyPress/DDF."
146+ }
147+]
\ No newline at end of file
deleted data/ontario_agencies.json +0 −96
@@ -1,96 +0,0 @@
1−[
2− {
3− "id": "rp_ag_revelrealty",
4− "name": "Revel Realty (Niagara & provincial)",
5− "site": "https://revelrealty.ca",
6− "archive": "listings",
7− "max_pages": 1300,
8− "note": "Recensement ON 2026-08-27 : ~110 145 fiches — pool DDF quasi provincial. Archive /listings/ (⚠ /listing/ = carousel 8 cartes), 108 cartes/page avec posts_per_page=100. RealtyPress/DDF."
9− },
10− {
11− "id": "rp_ag_codygroup",
12− "name": "The Cody Group (London/ITSO)",
13− "site": "https://codygroup.ca",
14− "archive": "all-regional-listings",
15− "max_pages": 700,
16− "note": "Recensement ON 2026-08-27 : ~58 062 fiches (London + ITSO élargi). Archive /all-regional-listings/, fiches sous le même chemin. RealtyPress/DDF."
17− },
18− {
19− "id": "rp_ag_suttonottawa",
20− "name": "Sutton Group — Ottawa Realty",
21− "site": "https://suttonottawa.ca",
22− "note": "Recensement ON 2026-08-27 : ~10 080 fiches (OREB+). RealtyPress/DDF."
23− },
24− {
25− "id": "rp_ag_helensteam",
26− "name": "Helen's Team (Kitchener-Waterloo)",
27− "site": "https://helensteam.ca",
28− "note": "Recensement ON 2026-08-27 : ~9 910 fiches (Kitchener-Waterloo). RealtyPress/DDF."
29− },
30− {
31− "id": "rp_ag_greybruce",
32− "name": "Grey Bruce Real Estate",
33− "site": "https://greybrucerealestate.ca",
34− "note": "Recensement ON 2026-08-27 : ~8 268 fiches (Grey-Bruce/Georgian Bay). RealtyPress/DDF. Même feed que collaborativerealestate.ca (fallback)."
35− },
36− {
37− "id": "rp_ag_remaxfinest",
38− "name": "RE/MAX Finest Realty (Kingston)",
39− "site": "https://remaxfinestrealty.com",
40− "note": "Recensement ON 2026-08-27 : ~8 217 fiches (Kingston). RealtyPress/DDF."
41− },
42− {
43− "id": "rp_ag_riouxbaker",
44− "name": "Rioux Baker Real Estate Team (Collingwood)",
45− "site": "https://riouxbakerteam.com",
46− "note": "Recensement ON 2026-08-27 : ~7 716 fiches (Collingwood/South Georgian Bay). RealtyPress/DDF."
47− },
48− {
49− "id": "rp_ag_countyguys",
50− "name": "The County Guys (Prince Edward County)",
51− "site": "https://thecountyguys.com",
52− "note": "Recensement ON 2026-08-27 : ~6 878 fiches (Prince Edward County/Quinte). RealtyPress/DDF."
53− },
54− {
55− "id": "rp_ag_labrosse",
56− "name": "Labrosse Real Estate (Ottawa/Orléans)",
57− "site": "https://labrosserealestate.com",
58− "note": "Recensement ON 2026-08-27 : ~6 873 fiches (Ottawa/Orléans, équipe FRANCOPHONE). RealtyPress/DDF."
59− },
60− {
61− "id": "rp_ag_ryanpattinson",
62− "name": "Ryan Pattinson (Pembroke/Renfrew)",
63− "site": "https://ryanpattinson.com",
64− "note": "Recensement ON 2026-08-27 : ~6 601 fiches (Pembroke/vallée de l'Outaouais ON). RealtyPress/DDF."
65− },
66− {
67− "id": "rp_ag_grapevine",
68− "name": "Grapevine (Ottawa)",
69− "site": "https://grapevine.ca",
70− "note": "Recensement ON 2026-08-27 : ~6 550 fiches (Ottawa). RealtyPress/DDF. Suivre les redirections www/apex."
71− },
72− {
73− "id": "rp_ag_rlpheartland",
74− "name": "Royal LePage Heartland Realty",
75− "site": "https://rlpheartland.ca",
76− "note": "Recensement ON 2026-08-27 : ~5 577 fiches (Midwestern Ontario — Huron/Perth). RealtyPress/DDF."
77− },
78− {
79− "id": "rp_ag_signaturenorth",
80− "name": "Signature North Realty (Thunder Bay)",
81− "site": "https://signaturenorthrealty.ca",
82− "note": "Recensement ON 2026-08-27 : ~1 023 fiches (Thunder Bay). RealtyPress/DDF."
83− },
84− {
85− "id": "rp_ag_saultstemarie",
86− "name": "Sault Ste. Marie Real Estate (Century 21 Choice)",
87− "site": "https://saultstemarierealestate.com",
88− "note": "Recensement ON 2026-08-27 : ~864 fiches (Sault Ste. Marie). RealtyPress/DDF."
89− },
90− {
91− "id": "rp_ag_cbnorthbay",
92− "name": "Coldwell Banker Peter Minogue (North Bay)",
93− "site": "https://cbnorthbay.com",
94− "note": "Recensement ON 2026-08-27 : ~307 fiches (North Bay). RealtyPress/DDF."
95− }
96−]
modified data/sources.json +136 −15
@@ -9,7 +9,7 @@
9 "connector": "realtypress", 9 "connector": "realtypress",
10 "status": "actif", 10 "status": "actif",
11 "type": "agence", 11 "type": "agence",
12− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 12+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
13 }, 13 },
14 { 14 {
15 "id": "rp_ag_codygroup", 15 "id": "rp_ag_codygroup",
@@ -20,7 +20,7 @@
20 "connector": "realtypress", 20 "connector": "realtypress",
21 "status": "actif", 21 "status": "actif",
22 "type": "agence", 22 "type": "agence",
23− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 23+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
24 }, 24 },
25 { 25 {
26 "id": "rp_ag_suttonottawa", 26 "id": "rp_ag_suttonottawa",
@@ -31,7 +31,7 @@
31 "connector": "realtypress", 31 "connector": "realtypress",
32 "status": "actif", 32 "status": "actif",
33 "type": "agence", 33 "type": "agence",
34− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 34+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
35 }, 35 },
36 { 36 {
37 "id": "rp_ag_helensteam", 37 "id": "rp_ag_helensteam",
@@ -42,7 +42,7 @@
42 "connector": "realtypress", 42 "connector": "realtypress",
43 "status": "actif", 43 "status": "actif",
44 "type": "agence", 44 "type": "agence",
45− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 45+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
46 }, 46 },
47 { 47 {
48 "id": "rp_ag_greybruce", 48 "id": "rp_ag_greybruce",
@@ -53,7 +53,7 @@
53 "connector": "realtypress", 53 "connector": "realtypress",
54 "status": "actif", 54 "status": "actif",
55 "type": "agence", 55 "type": "agence",
56− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 56+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
57 }, 57 },
58 { 58 {
59 "id": "rp_ag_remaxfinest", 59 "id": "rp_ag_remaxfinest",
@@ -64,7 +64,7 @@
64 "connector": "realtypress", 64 "connector": "realtypress",
65 "status": "actif", 65 "status": "actif",
66 "type": "agence", 66 "type": "agence",
67− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 67+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
68 }, 68 },
69 { 69 {
70 "id": "rp_ag_riouxbaker", 70 "id": "rp_ag_riouxbaker",
@@ -75,7 +75,7 @@
75 "connector": "realtypress", 75 "connector": "realtypress",
76 "status": "actif", 76 "status": "actif",
77 "type": "agence", 77 "type": "agence",
78− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 78+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
79 }, 79 },
80 { 80 {
81 "id": "rp_ag_countyguys", 81 "id": "rp_ag_countyguys",
@@ -86,7 +86,7 @@
86 "connector": "realtypress", 86 "connector": "realtypress",
87 "status": "actif", 87 "status": "actif",
88 "type": "agence", 88 "type": "agence",
89− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 89+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
90 }, 90 },
91 { 91 {
92 "id": "rp_ag_labrosse", 92 "id": "rp_ag_labrosse",
@@ -97,7 +97,7 @@
97 "connector": "realtypress", 97 "connector": "realtypress",
98 "status": "actif", 98 "status": "actif",
99 "type": "agence", 99 "type": "agence",
100− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 100+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
101 }, 101 },
102 { 102 {
103 "id": "rp_ag_ryanpattinson", 103 "id": "rp_ag_ryanpattinson",
@@ -108,7 +108,7 @@
108 "connector": "realtypress", 108 "connector": "realtypress",
109 "status": "actif", 109 "status": "actif",
110 "type": "agence", 110 "type": "agence",
111− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 111+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
112 }, 112 },
113 { 113 {
114 "id": "rp_ag_grapevine", 114 "id": "rp_ag_grapevine",
@@ -119,7 +119,7 @@
119 "connector": "realtypress", 119 "connector": "realtypress",
120 "status": "actif", 120 "status": "actif",
121 "type": "agence", 121 "type": "agence",
122− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 122+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
123 }, 123 },
124 { 124 {
125 "id": "rp_ag_rlpheartland", 125 "id": "rp_ag_rlpheartland",
@@ -130,7 +130,7 @@
130 "connector": "realtypress", 130 "connector": "realtypress",
131 "status": "actif", 131 "status": "actif",
132 "type": "agence", 132 "type": "agence",
133− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 133+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
134 }, 134 },
135 { 135 {
136 "id": "rp_ag_signaturenorth", 136 "id": "rp_ag_signaturenorth",
@@ -141,7 +141,7 @@
141 "connector": "realtypress", 141 "connector": "realtypress",
142 "status": "actif", 142 "status": "actif",
143 "type": "agence", 143 "type": "agence",
144− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 144+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
145 }, 145 },
146 { 146 {
147 "id": "rp_ag_saultstemarie", 147 "id": "rp_ag_saultstemarie",
@@ -152,7 +152,7 @@
152 "connector": "realtypress", 152 "connector": "realtypress",
153 "status": "actif", 153 "status": "actif",
154 "type": "agence", 154 "type": "agence",
155− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 155+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
156 }, 156 },
157 { 157 {
158 "id": "rp_ag_cbnorthbay", 158 "id": "rp_ag_cbnorthbay",
@@ -163,7 +163,128 @@
163 "connector": "realtypress", 163 "connector": "realtypress",
164 "status": "actif", 164 "status": "actif",
165 "type": "agence", 165 "type": "agence",
166− "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)." 166+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
167+ },
168+ {
169+ "id": "c21_bc",
170+ "name": "Century 21 Canada — British Columbia",
171+ "url": "https://www.c21.ca",
172+ "listing_url": "https://www.c21.ca/search/CA/",
173+ "coverage": "British Columbia — réseau C21 national, ~57 500 fiches",
174+ "connector": "c21_canada",
175+ "status": "actif",
176+ "type": "reseau",
177+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
178+ },
179+ {
180+ "id": "c21_ab",
181+ "name": "Century 21 Canada — Alberta",
182+ "url": "https://www.c21.ca",
183+ "listing_url": "https://www.c21.ca/search/CA/",
184+ "coverage": "Alberta — réseau C21 national, ~27 300 fiches",
185+ "connector": "c21_canada",
186+ "status": "actif",
187+ "type": "reseau",
188+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
189+ },
190+ {
191+ "id": "c21_sk",
192+ "name": "Century 21 Canada — Saskatchewan",
193+ "url": "https://www.c21.ca",
194+ "listing_url": "https://www.c21.ca/search/CA/",
195+ "coverage": "Saskatchewan — réseau C21 national, ~6 700 fiches",
196+ "connector": "c21_canada",
197+ "status": "actif",
198+ "type": "reseau",
199+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
200+ },
201+ {
202+ "id": "c21_mb",
203+ "name": "Century 21 Canada — Manitoba",
204+ "url": "https://www.c21.ca",
205+ "listing_url": "https://www.c21.ca/search/CA/",
206+ "coverage": "Manitoba — réseau C21 national, ~800 fiches",
207+ "connector": "c21_canada",
208+ "status": "actif",
209+ "type": "reseau",
210+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
211+ },
212+ {
213+ "id": "c21_on",
214+ "name": "Century 21 Canada — Ontario",
215+ "url": "https://www.c21.ca",
216+ "listing_url": "https://www.c21.ca/search/CA/",
217+ "coverage": "Ontario — réseau C21 national, (volume à mesurer) fiches",
218+ "connector": "c21_canada",
219+ "status": "actif",
220+ "type": "reseau",
221+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
222+ },
223+ {
224+ "id": "c21_nb",
225+ "name": "Century 21 Canada — New Brunswick",
226+ "url": "https://www.c21.ca",
227+ "listing_url": "https://www.c21.ca/search/CA/",
228+ "coverage": "New Brunswick — réseau C21 national, ~12 000 fiches",
229+ "connector": "c21_canada",
230+ "status": "actif",
231+ "type": "reseau",
232+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
233+ },
234+ {
235+ "id": "c21_ns",
236+ "name": "Century 21 Canada — Nova Scotia",
237+ "url": "https://www.c21.ca",
238+ "listing_url": "https://www.c21.ca/search/CA/",
239+ "coverage": "Nova Scotia — réseau C21 national, ~24 500 fiches",
240+ "connector": "c21_canada",
241+ "status": "actif",
242+ "type": "reseau",
243+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
244+ },
245+ {
246+ "id": "c21_pe",
247+ "name": "Century 21 Canada — Prince Edward Island",
248+ "url": "https://www.c21.ca",
249+ "listing_url": "https://www.c21.ca/search/CA/",
250+ "coverage": "Prince Edward Island — réseau C21 national, ~3 100 fiches",
251+ "connector": "c21_canada",
252+ "status": "actif",
253+ "type": "reseau",
254+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
255+ },
256+ {
257+ "id": "c21_nl",
258+ "name": "Century 21 Canada — Newfoundland and Labrador",
259+ "url": "https://www.c21.ca",
260+ "listing_url": "https://www.c21.ca/search/CA/",
261+ "coverage": "Newfoundland and Labrador — réseau C21 national, ~5 500 fiches",
262+ "connector": "c21_canada",
263+ "status": "actif",
264+ "type": "reseau",
265+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
266+ },
267+ {
268+ "id": "c21_yt",
269+ "name": "Century 21 Canada — Yukon",
270+ "url": "https://www.c21.ca",
271+ "listing_url": "https://www.c21.ca/search/CA/",
272+ "coverage": "Yukon — réseau C21 national, (faible) fiches",
273+ "connector": "c21_canada",
274+ "status": "actif",
275+ "type": "reseau",
276+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
277+ },
278+ {
279+ "id": "c21_nt",
280+ "name": "Century 21 Canada — Northwest Territories",
281+ "url": "https://www.c21.ca",
282+ "listing_url": "https://www.c21.ca/search/CA/",
283+ "coverage": "Northwest Territories — réseau C21 national, (faible) fiches",
284+ "connector": "c21_canada",
285+ "status": "actif",
286+ "type": "reseau",
287+ "note": "API MoxiWorks corporative de c21.ca (svc.moxiworks.com listing/search_v2, JSONP). Paramètres d'attribution obligatoires, découpage adaptatif par tranches de prix (liste retournée seulement si number_found ≤ ~6k). Réponse liste complète : AUCUNE passe détail nécessaire. Rétro-conçue 2026-08-27."
167 } 288 }
168 ] 289 ]
169 } 290 }
\ No newline at end of file
modified frontend/src/App.tsx +4 −4
@@ -171,10 +171,10 @@ function Footer() {
171 <div className="hk-foot-brand">House<span className="ka">Ka</span></div> 171 <div className="hk-foot-brand">House<span className="ka">Ka</span></div>
172 <p className="hk-foot-desc"> 172 <p className="hk-foot-desc">
173 House-Ka continuously aggregates homes for sale publicly listed by 173 House-Ka continuously aggregates homes for sale publicly listed by
174− Canadian real-estate brokerages and teams on the CREA DDF feed — 174+ Canadian real-estate brokerages, teams and national networks — every
175− starting with Ontario and growing across the rest of Canada. Every 175+ province and territory except Québec, which lives on our sister site
176− listing links back to the brokerage's original page. House-Ka is a 176+ Immo-Ka. Every listing links back to the source's original page.
177− service of <b>Groupe KA</b>. 177+ House-Ka is a service of <b>Groupe KA</b>.
178 </p> 178 </p>
179 <p className="hk-foot-notice"> 179 <p className="hk-foot-notice">
180 House-Ka is an independent aggregator: it is not a brokerage, does not 180 House-Ka is an independent aggregator: it is not a brokerage, does not
modified frontend/src/pages/Home.tsx +5 −5
@@ -216,17 +216,17 @@ export default function Home() {
216 <section className="hero"> 216 <section className="hero">
217 <div className="hero-wrap"> 217 <div className="hero-wrap">
218 <div className="hero-main"> 218 <div className="hero-main">
219− <span className="kicker">Aggregator — Canadian brokerages, Ontario first</span> 219+ <span className="kicker">Aggregator — Canadian brokerages, coast to coast</span>
220 <h1 className="hero-display" aria-label="Every home for sale. One place."> 220 <h1 className="hero-display" aria-label="Every home for sale. One place.">
221 <span className="hd-l1" aria-hidden="true">Every home</span> 221 <span className="hd-l1" aria-hidden="true">Every home</span>
222 <span className="hd-l2" aria-hidden="true">for sale.</span> 222 <span className="hd-l2" aria-hidden="true">for sale.</span>
223 <span className="hd-l4" aria-hidden="true">One <em className="signal">place</em>.</span> 223 <span className="hd-l4" aria-hidden="true">One <em className="signal">place</em>.</span>
224 </h1> 224 </h1>
225 <p className="lede"> 225 <p className="lede">
226− Homes listed by Canadian real-estate brokerages and teams on the 226+ Homes listed by Canadian brokerages and national networks —
227− CREA DDF feed — aggregated continuously, full photos and details, 227+ every province except Québec (that's our sister site Immo-Ka),
228− direct link to the original listing. Ontario today, the rest of 228+ aggregated continuously with photos, details and a direct link
229− Canada next. 229+ to the original listing.
230 </p> 230 </p>
231 <div className="live-line" aria-label="Live data"> 231 <div className="live-line" aria-label="Live data">
232 <span className="live-flag"><span className="live-dot" /> live</span> 232 <span className="live-flag"><span className="live-dot" /> live</span>
added immoka/connectors/c21_canada.py +222 −0
@@ -0,0 +1,222 @@
1+# -----------------------------------------------------------------------------
2+# House-Ka — Agrégateur de maisons à vendre (Canada hors Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/c21_canada.py : Century 21 Canada — API MoxiWorks corporative.
5+# c21.ca (tous les bureaux C21 du pays) sert sa recherche via l'API JSONP
6+# svc.moxiworks.com `/service/v1/listing/search_v2`. La réponse LISTE contient
7+# déjà tout (prix, chambres, GPS, photo, description, MLS®) : aucune passe
8+# détail nécessaire. Un connecteur par PROVINCE (source c21_bc, c21_ab, …).
9+#
10+# Rétro-ingénierie 2026-08-27 (sniff Playwright sur c21.ca/search) :
11+# - les paramètres d'ATTRIBUTION (`send_from_agent`, `from_aws`, `from_app`,
12+# `site_owner_uuid`) sont OBLIGATOIRES — sans eux `result_list` reste vide ;
13+# - `location_search_field=<Province>` filtre par province ;
14+# - `startidx` est IGNORÉ par l'API (toutes les « pages » renvoient les mêmes
15+# fiches) et `pgsize` plafonne à 500 → découpage ADAPTATIF par tranches de
16+# prix (`pricemin`/`pricemax`) jusqu'à number_found ≤ SLICE_MAX, puis UNE
17+# requête pgsize=500 récupère la tranche entière ;
18+# - `pstatus=1,11` (active + coming soon), `ptype=1..9` (tous types résidentiels).
19+# Fiche publique : https://www.c21.ca/listing<url_slug>.
20+# -----------------------------------------------------------------------------
21+from __future__ import annotations
22+
23+import json
24+import re
25+import time
26+
27+from .base import BaseConnector
28+from ..schema import PropertyListing
29+
30+API = "https://svc.moxiworks.com/service/v1/listing/search_v2"
31+COMPANY_UUID = "3341197" # CENTURY 21 Canada
32+SITE_OWNER = "825ef2f8-0716-4a50-862c-ddad45234e9c" # c21.ca (config publique)
33+
34+SLICE_MAX = 480 # pgsize plafonne à 500 : la tranche doit tenir en 1 requête
35+PGSIZE = 500
36+PRICE_CEIL = 100_000_000
37+
38+_CB_RE = re.compile(r"^/\*\*/cb\(|\)\s*$")
39+
40+BASE_PARAMS = {
41+ "status": "active",
42+ "pstatus": "1,11",
43+ "sort_by": "10",
44+ "company_uuid": COMPANY_UUID,
45+ "ptype": "1,2,3,4,5,7,9,8",
46+ "searchType": "criteria",
47+ "omit_hidden": "true",
48+ "ex_pend": "true",
49+ "currency": "CAD",
50+ "callback": "cb",
51+ # attribution : indispensable pour obtenir result_list
52+ "send_from_agent": "true",
53+ "from_aws": "true",
54+ "from_app": "aws:https://www.c21.ca",
55+ "source": "agent website",
56+ "site_type": "Brokerage Website",
57+ "site_owner_uuid": SITE_OWNER,
58+}
59+
60+
61+def _num(v) -> float | None:
62+ try:
63+ f = float(v)
64+ return f if f == f else None
65+ except (TypeError, ValueError):
66+ return None
67+
68+
69+class _C21Province(BaseConnector):
70+ """Century 21 Canada — une province (voir les sous-classes en bas)."""
71+
72+ province = "" # libellé exact pour location_search_field
73+ state_code = "" # code à 2 lettres attendu dans location.state
74+ request_delay = 0.35
75+
76+ # ------------------------------------------------------------------ API --
77+ def _query(self, pricemin: int | None, pricemax: int | None,
78+ startidx: int = 0, pgsize: int = PGSIZE) -> dict:
79+ params = dict(BASE_PARAMS)
80+ params["location_search_field"] = self.province
81+ params["pgsize"] = str(pgsize)
82+ params["startidx"] = str(startidx)
83+ if pricemin is not None and pricemin > 0:
84+ params["pricemin"] = str(pricemin)
85+ if pricemax is not None:
86+ params["pricemax"] = str(pricemax)
87+ resp = self.get(API, params=params)
88+ body = _CB_RE.sub("", resp.text.strip())
89+ d = json.loads(body)
90+ if d.get("status") != "success":
91+ raise RuntimeError(f"c21 API: {d.get('message', d.get('status'))}")
92+ return d["data"]
93+
94+ def _count(self, lo: int | None, hi: int | None) -> int:
95+ d = self._query(lo, hi, startidx=0, pgsize=1)
96+ return int(d.get("number_found") or 0)
97+
98+ # ------------------------------------------------------ tranches de prix --
99+ def _slices(self) -> list[tuple[int | None, int | None]]:
100+ """Tranches [lo, hi] dont number_found ≤ SLICE_MAX (scission dichotomique)."""
101+ out: list[tuple[int | None, int | None]] = []
102+ stack: list[tuple[int, int]] = [(0, PRICE_CEIL)]
103+ while stack:
104+ lo, hi = stack.pop()
105+ n = self._count(lo or None, hi)
106+ time.sleep(self.request_delay)
107+ if n == 0:
108+ continue
109+ if n <= SLICE_MAX or hi - lo <= 200:
110+ # tranche insécable > 500 : tronquée à pgsize (cas rarissime —
111+ # plus de 500 fiches au même prix à 200 $ près)
112+ out.append((lo or None, hi))
113+ continue
114+ # scission au point médian géométrique (les prix sont log-normaux)
115+ mid = int((max(lo, 10_000) * hi) ** 0.5)
116+ if mid <= lo or mid >= hi:
117+ mid = (lo + hi) // 2
118+ stack.append((lo, mid))
119+ stack.append((mid + 1, hi))
120+ return out
121+
122+ # ------------------------------------------------------------- mapping --
123+ def _to_listing(self, r: dict) -> PropertyListing | None:
124+ loc = r.get("location") or {}
125+ state = (loc.get("state") or "").upper()
126+ if state == "QC": # périmètre House-Ka : hors Québec
127+ return None
128+ if self.state_code and state and state != self.state_code:
129+ return None
130+ lid = r.get("listingid")
131+ if not lid:
132+ return None
133+ slug = r.get("url_slug") or ""
134+ url = f"https://www.c21.ca/listing{slug}" if slug else "https://www.c21.ca"
135+
136+ # salles de bains Moxi : « 6.1 » = 6 complètes + 1 d'eau
137+ baths_raw = _num(r.get("bathrooms"))
138+ baths = powder = None
139+ if baths_raw is not None:
140+ baths = int(baths_raw)
141+ dec = round((baths_raw - baths) * 10)
142+ powder = dec if 0 < dec <= 5 else None
143+
144+ images = []
145+ for img in (r.get("image") or []):
146+ u = img.get("full_url") or img.get("gallery_url")
147+ if u:
148+ images.append(u)
149+
150+ details = {k: v for k, v in {
151+ "Property Type": r.get("property_type"),
152+ "County": loc.get("county"),
153+ "Postal code": loc.get("zip"),
154+ "Listed date": r.get("listed_date"),
155+ "Subdivision": r.get("subdivision"),
156+ "MLS® Number": r.get("mlsnumber"),
157+ "Listing office": r.get("officename") or r.get("listing_office"),
158+ }.items() if v}
159+
160+ lst = PropertyListing(
161+ source=self.source_id,
162+ external_id=f"c21{lid}",
163+ url=url,
164+ address=(loc.get("address") or "").strip(),
165+ city=(loc.get("city") or "").strip(),
166+ region=self.province,
167+ property_type=r.get("property_type") or "",
168+ price=_num(r.get("list_price")),
169+ price_label=(f"${int(r['list_price']):,}"
170+ if _num(r.get("list_price")) else ""),
171+ bedrooms=int(_num(r.get("bedrooms")) or 0) or None,
172+ bathrooms=baths,
173+ powder_rooms=powder,
174+ area_sqft=_num(r.get("sqr_footage")) or _num(r.get("living_area")),
175+ lot_sqft=_num(r.get("lot_sqr_footage")),
176+ year_built=int(_num(r.get("year_build")) or 0) or None,
177+ mls=str(r.get("mlsnumber") or ""),
178+ broker_name=(r.get("agentname") or r.get("officename") or
179+ "Century 21").strip(),
180+ agency=(r.get("officename") or "Century 21 Canada").strip(),
181+ description=(r.get("comments") or "").strip()[:6000],
182+ details=details,
183+ images=images,
184+ lat=_num(loc.get("latitude")),
185+ lng=_num(loc.get("longitude")),
186+ )
187+ return lst
188+
189+ # --------------------------------------------------------------- fetch --
190+ def fetch(self) -> list[PropertyListing]:
191+ by_id: dict[str, PropertyListing] = {}
192+ for lo, hi in self._slices():
193+ d = self._query(lo, hi, pgsize=PGSIZE)
194+ for r in (d.get("result_list") or []):
195+ lst = self._to_listing(r)
196+ if lst is not None and lst.external_id not in by_id:
197+ by_id[lst.external_id] = lst
198+ time.sleep(self.request_delay)
199+ return list(by_id.values())
200+
201+
202+# --- une source par province (le Québec vit sur immo-ka) ----------------------
203+_PROVINCES = [
204+ ("c21_bc", "British Columbia", "BC"),
205+ ("c21_ab", "Alberta", "AB"),
206+ ("c21_sk", "Saskatchewan", "SK"),
207+ ("c21_mb", "Manitoba", "MB"),
208+ ("c21_on", "Ontario", "ON"),
209+ ("c21_nb", "New Brunswick", "NB"),
210+ ("c21_ns", "Nova Scotia", "NS"),
211+ ("c21_pe", "Prince Edward Island", "PE"),
212+ ("c21_nl", "Newfoundland and Labrador", "NL"),
213+ ("c21_yt", "Yukon", "YT"),
214+ ("c21_nt", "Northwest Territories", "NT"),
215+]
216+
217+for _sid, _prov, _code in _PROVINCES:
218+ globals()[f"C21_{_code}"] = type(
219+ f"C21{_code}",
220+ (_C21Province,),
221+ {"source_id": _sid, "province": _prov, "state_code": _code},
222+ )
modified immoka/connectors/realtypress.py +34 −13
@@ -1,7 +1,7 @@
1 # ----------------------------------------------------------------------------- 1 # -----------------------------------------------------------------------------
2−# Immo-Ka — Agrégateur de maisons à vendre (Québec + Ontario) 2+# House-Ka — Agrégateur de maisons à vendre (Canada hors Québec)
3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4−# connectors/realtypress.py : connecteur GÉNÉRIQUE RealtyPress (Ontario) 4+# connectors/realtypress.py : connecteur GÉNÉRIQUE RealtyPress (Canada)
5 # RealtyPress = plugin WordPress branché sur le flux CREA DDF ; ~35 sites 5 # RealtyPress = plugin WordPress branché sur le flux CREA DDF ; ~35 sites
6 # d'agences/équipes ontariennes confirmés (recensement 2026-08-27, voir 6 # d'agences/équipes ontariennes confirmés (recensement 2026-08-27, voir
7 # docs/ontario-agences-connecteurs.md). Chaque site expose l'IDX/DDF complet 7 # docs/ontario-agences-connecteurs.md). Chaque site expose l'IDX/DDF complet
@@ -19,7 +19,7 @@
19 # - external_id = ddf<id> (préfixe : jamais de collision avec les n° Centris 19 # - external_id = ddf<id> (préfixe : jamais de collision avec les n° Centris
20 # QC) ; sources avec infixe _ag_ → la dédup par external_id masque les 20 # QC) ; sources avec infixe _ag_ → la dédup par external_id masque les
21 # doublons inter-sites (le même bien DDF publié sur plusieurs sites). 21 # doublons inter-sites (le même bien DDF publié sur plusieurs sites).
22−# Sites générés depuis data/ontario_agencies.json (un source_id par site). 22+# Sites générés depuis data/canada_agencies.json (un source_id par site).
23 # ----------------------------------------------------------------------------- 23 # -----------------------------------------------------------------------------
24 from __future__ import annotations 24 from __future__ import annotations
25 25
@@ -34,7 +34,7 @@ from .base import BaseConnector
34 from . import _detailutil as du 34 from . import _detailutil as du
35 from ..schema import PropertyListing 35 from ..schema import PropertyListing
36 36
37−REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "ontario_agencies.json" 37+REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "canada_agencies.json"
38 DETAIL_LIMIT = int(os.environ.get("IMMOKA_RP_DETAIL_LIMIT", 38 DETAIL_LIMIT = int(os.environ.get("IMMOKA_RP_DETAIL_LIMIT",
39 os.environ.get("IMMOKA_DETAIL_LIMIT", "150"))) 39 os.environ.get("IMMOKA_DETAIL_LIMIT", "150")))
40 40
@@ -54,8 +54,16 @@ _ROW_RE = re.compile(r"<td[^>]*>\s*<strong>([^<]{2,45})</strong>\s*</td>\s*"
54 r"<td[^>]*>(.*?)</td>", re.S) 54 r"<td[^>]*>(.*?)</td>", re.S)
55 _DESC_RE = re.compile(r'<!--\s*Description\s*-->\s*<p[^>]*>(.*?)</p>', re.S) 55 _DESC_RE = re.compile(r'<!--\s*Description\s*-->\s*<p[^>]*>(.*?)</p>', re.S)
56 _DESC_RE2 = re.compile(r'<p itemprop="description"[^>]*>(.*?)</p>', re.S) 56 _DESC_RE2 = re.compile(r'<p itemprop="description"[^>]*>(.*?)</p>', re.S)
57−# ville depuis <title> « 3383 Romeo Street, Greater Sudbury (Valley East), Ontario … » 57+# provinces couvertes (House-Ka = Canada HORS Québec — le Québec vit sur immo-ka)
58−_TITLE_CITY_RE = re.compile(r",\s*([^,<>|]{2,60}),\s*Ontario\b") 58+_PROVINCES = ("Ontario", "British Columbia", "Alberta", "Saskatchewan",
59+ "Manitoba", "New Brunswick", "Nova Scotia",
60+ "Prince Edward Island", "Newfoundland and Labrador",
61+ "Newfoundland & Labrador", "Yukon", "Northwest Territories",
62+ "Nunavut")
63+_PROV_ALT = "|".join(_PROVINCES)
64+_QC_RE = re.compile(r"\bQu[ée]bec\b", re.I)
65+# ville + province depuis <title> « 3383 Romeo Street, Greater Sudbury (Valley East), Ontario … »
66+_TITLE_CITY_RE = re.compile(r",\s*([^,<>|]{2,60}),\s*(" + _PROV_ALT + r")\b")
59 _PRICING_RE = re.compile(r'rps-pricing[^>]*>\s*\$\s*([\d,]+)') 67 _PRICING_RE = re.compile(r'rps-pricing[^>]*>\s*\$\s*([\d,]+)')
60 _ID_TAIL_RE = re.compile(r"\s*\(id:\d{4,9}\)\s*$") 68 _ID_TAIL_RE = re.compile(r"\s*\(id:\d{4,9}\)\s*$")
61 _TAG_RE = re.compile(r"<[^>]+>") 69 _TAG_RE = re.compile(r"<[^>]+>")
@@ -76,10 +84,11 @@ def _num(s: str) -> float | None:
76 84
77 85
78 class _RealtyPress(BaseConnector): 86 class _RealtyPress(BaseConnector):
79− """Connecteur générique de site RealtyPress (voir data/ontario_agencies.json).""" 87+ """Connecteur générique de site RealtyPress (voir data/canada_agencies.json)."""
80 88
81 agency_name = "" 89 agency_name = ""
82 site_url = "" 90 site_url = ""
91+ province = "Ontario" # région par défaut des fiches (registre : "province")
83 archive = "listing" # chemin de l'archive (revelrealty: "listings", 92 archive = "listing" # chemin de l'archive (revelrealty: "listings",
84 # codygroup: "all-regional-listings") 93 # codygroup: "all-regional-listings")
85 max_pages = 150 # 100 cartes/page → jusqu'à 15 000 fiches par site 94 max_pages = 150 # 100 cartes/page → jusqu'à 15 000 fiches par site
@@ -137,16 +146,25 @@ class _RealtyPress(BaseConnector):
137 if "rent" in ribbon or "lease" in ribbon: 146 if "rent" in ribbon or "lease" in ribbon:
138 return # locations : hors périmètre 147 return # locations : hors périmètre
139 lst = PropertyListing(source=self.source_id, external_id=eid, url=url, 148 lst = PropertyListing(source=self.source_id, external_id=eid, url=url,
140− region="Ontario", agency=self.agency_name, 149+ region=self.province, agency=self.agency_name,
141 broker_name=self.agency_name) 150 broker_name=self.agency_name)
142 ma = _H4_RE.search(card) 151 ma = _H4_RE.search(card)
143 if ma: 152 if ma:
144 lst.address = _html.unescape(_TAG_RE.sub(" ", ma.group(1))).strip() 153 lst.address = _html.unescape(_TAG_RE.sub(" ", ma.group(1))).strip()
145 mc = _CITY_RE.search(card) 154 mc = _CITY_RE.search(card)
146 if mc: 155 if mc:
147− city = _html.unescape(mc.group(1)).strip().rstrip(",") 156+ raw = _html.unescape(mc.group(1)).strip().rstrip(",")
148− city = re.sub(r",?\s*Ontario\b.*$", "", city, flags=re.I) 157+ # fiches québécoises (Gatineau… dans les pools DDF frontaliers) :
149− lst.city = city.split("(")[0].strip() 158+ # hors périmètre House-Ka — elles vivent sur immo-ka
159+ if _QC_RE.search(raw):
160+ return
161+ for prov in _PROVINCES:
162+ if prov.lower() in raw.lower():
163+ lst.region = prov
164+ raw = re.sub(r",?\s*" + re.escape(prov) + r"\b.*$", "",
165+ raw, flags=re.I)
166+ break
167+ lst.city = raw.split("(")[0].strip()
150 mp = _PRICE_RE.search(card) 168 mp = _PRICE_RE.search(card)
151 if mp: 169 if mp:
152 lst.price = _num(mp.group(1)) 170 lst.price = _num(mp.group(1))
@@ -226,8 +244,10 @@ def parse_rp_detail(html: str) -> dict:
226 244
227 mt = re.search(r"<title>(.*?)</title>", html, re.S) 245 mt = re.search(r"<title>(.*?)</title>", html, re.S)
228 if mt: 246 if mt:
229− mc = _TITLE_CITY_RE.search(_html.unescape(mt.group(1))) 247+ title_txt = _html.unescape(mt.group(1))
230− if mc: 248+ # (fiches québécoises : déjà filtrées au niveau des cartes de liste)
249+ mc = _TITLE_CITY_RE.search(title_txt)
250+ if mc and not _QC_RE.search(title_txt):
231 # « Greater Sudbury (Valley East) » : le secteur part dans sector 251 # « Greater Sudbury (Valley East) » : le secteur part dans sector
232 city = mc.group(1).split("(")[0].strip() 252 city = mc.group(1).split("(")[0].strip()
233 if city and not any(c.isdigit() for c in city): 253 if city and not any(c.isdigit() for c in city):
@@ -294,6 +314,7 @@ for _ag in _load():
294 "source_id": _sid, 314 "source_id": _sid,
295 "site_url": _ag["site"], 315 "site_url": _ag["site"],
296 "agency_name": _ag.get("name", _sid), 316 "agency_name": _ag.get("name", _sid),
317+ "province": _ag.get("province", "Ontario"),
297 "archive": _ag.get("archive", "listing"), 318 "archive": _ag.get("archive", "listing"),
298 "max_pages": int(_ag.get("max_pages", 150)), 319 "max_pages": int(_ag.get("max_pages", 150)),
299 }, 320 },
modified immoka/normalize.py +2 −2
@@ -212,8 +212,8 @@ _TYPE_MAP = [
212 "acreage",), "Farm"), 212 "acreage",), "Farm"),
213 (("plain-pied", "bungalow",), "House"), 213 (("plain-pied", "bungalow",), "House"),
214 (("maison a etages", "a etage", "deux etages", "cottage",), "House"), 214 (("maison a etages", "a etage", "deux etages", "cottage",), "House"),
215− (("unifamiliale", "maison", "house", "residence", "split", "detached", 215+ (("unifamiliale", "maison", "house", "residence", "residential", "split",
216− "single family", "single-family",), "House"), 216+ "detached", "single family", "single-family",), "House"),
217 # enriched-sheet vocabulary: « 4 logements », « propriété à revenu » 217 # enriched-sheet vocabulary: « 4 logements », « propriété à revenu »
218 (("logements", "logement/", "unites et +", "revenu",), "Multi-family"), 218 (("logements", "logement/", "unites et +", "revenu",), "Multi-family"),
219 (("bi generation", "bi-generation", "bigeneration", "intergeneration",), 219 (("bi generation", "bi-generation", "bigeneration", "intergeneration",),
added normalize.py +258 −0
@@ -0,0 +1,258 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# normalize.py : couche de normalisation commune (prix, types, adresses…)
5+# -----------------------------------------------------------------------------
6+"""Fonctions de normalisation partagées par tous les connecteurs.
7+
8+Les connecteurs remplissent les champs bruts tels que vus sur le site source ;
9+`PropertyListing.finalize()` (schema.py) appelle ces fonctions pour produire
10+des valeurs canoniques comparables entre agences.
11+"""
12+from __future__ import annotations
13+
14+import re
15+import unicodedata
16+
17+__all__ = [
18+ "strip_accents", "clean_address", "parse_price", "price_is_from",
19+ "parse_int", "parse_float", "parse_area_sqft", "parse_lot_sqft",
20+ "parse_year", "normalize_property_type", "extract_bedrooms_bathrooms",
21+ "clean_title", "clean_description",
22+]
23+
24+
25+def strip_accents(text: str) -> str:
26+ return "".join(c for c in unicodedata.normalize("NFD", text or "")
27+ if unicodedata.category(c) != "Mn")
28+
29+
30+_SMALL_WORDS = {"a", "à", "au", "aux", "avec", "de", "des", "du", "en", "et",
31+ "la", "le", "les", "ou", "pour", "sur", "sous", "un", "une"}
32+
33+
34+def clean_title(text: str) -> str:
35+ """Titre propre : espaces normalisés, et les titres CRIÉS EN MAJUSCULES
36+ (fréquents chez certaines sources) ramenés en casse naturelle — chaque mot
37+ capitalisé sauf les mots-outils (les noms de villes restent capitalisés)."""
38+ t = re.sub(r"\s+", " ", (text or "")).strip()
39+ letters = [c for c in t if c.isalpha()]
40+ if len(letters) >= 8 and sum(c.isupper() for c in letters) / len(letters) > 0.85:
41+ words = []
42+ for i, w in enumerate(t.lower().split(" ")):
43+ words.append(w if (i and w in _SMALL_WORDS) else w[:1].upper() + w[1:])
44+ t = " ".join(words)
45+ return t
46+
47+
48+_TAG_RE = re.compile(r"<[^>]+>")
49+_BR_RE = re.compile(r"<br\s*/?>|</p>|</div>|</li>", re.I)
50+
51+
52+def clean_description(text: str) -> str:
53+ """Description sans HTML brut visible : balises retirées (sauts de ligne
54+ préservés), entités décodées, espaces/blancs normalisés."""
55+ import html as _html
56+ t = text or ""
57+ if "<" in t and ">" in t:
58+ t = _BR_RE.sub("\n", t)
59+ t = _TAG_RE.sub(" ", t)
60+ t = _html.unescape(t)
61+ t = re.sub(r"[ \t]+", " ", t)
62+ t = re.sub(r" ?\n ?", "\n", t)
63+ t = re.sub(r"\n{3,}", "\n\n", t)
64+ return t.strip()
65+
66+
67+def clean_address(text: str) -> str:
68+ """Nettoie une adresse civique (espaces, virgules doublées, apostrophes)."""
69+ t = re.sub(r"\s+", " ", (text or "").replace("’", "'")).strip()
70+ t = re.sub(r"\s*,\s*", ", ", t)
71+ t = re.sub(r"(, )+", ", ", t).strip(", ")
72+ return t
73+
74+
75+# ---------------------------------------------------------------------------
76+# Prix
77+# ---------------------------------------------------------------------------
78+
79+_PRICE_RE = re.compile(r"(\d[\d\s  .,]*)\s*(?:\$|CAD)?", re.UNICODE)
80+
81+
82+def parse_price(label: str) -> float | None:
83+ """Extrait un prix de vente d'un libellé source.
84+
85+ Gère « 459 000 $ », « $459,000 », « 1 249 000$ +tx », « À partir de 399 900 $ ».
86+ Retourne None si aucun montant plausible (>= 10 000 $) n'est trouvé.
87+ """
88+ if not label:
89+ return None
90+ m = _PRICE_RE.search(label.replace(" ", " ").replace(" ", " "))
91+ if not m:
92+ return None
93+ raw = m.group(1).strip()
94+ # « 459 000 » / « 459,000 » / « 459000.00 » — retirer les séparateurs de milliers
95+ raw = raw.replace(" ", "")
96+ if "," in raw and "." in raw:
97+ raw = raw.replace(",", "") # 459,000.00
98+ elif raw.count(",") == 1 and len(raw.split(",")[1]) == 2:
99+ raw = raw.replace(",", ".") # 459000,00 (décimale FR)
100+ else:
101+ raw = raw.replace(",", "")
102+ try:
103+ value = float(raw)
104+ except ValueError:
105+ return None
106+ return value if value >= 10_000 else None
107+
108+
109+def price_is_from(label: str) -> bool:
110+ key = strip_accents((label or "").lower())
111+ return any(k in key for k in ("a partir", "starting", "from", "des "))
112+
113+
114+# ---------------------------------------------------------------------------
115+# Nombres génériques
116+# ---------------------------------------------------------------------------
117+
118+def parse_int(text) -> int | None:
119+ if text is None:
120+ return None
121+ if isinstance(text, (int, float)):
122+ return int(text)
123+ m = re.search(r"\d+", str(text))
124+ return int(m.group()) if m else None
125+
126+
127+def parse_float(text) -> float | None:
128+ if text is None:
129+ return None
130+ if isinstance(text, (int, float)):
131+ return float(text)
132+ m = re.search(r"\d[\d\s]*(?:[.,]\d+)?", str(text))
133+ if not m:
134+ return None
135+ try:
136+ return float(m.group().replace(" ", "").replace(",", "."))
137+ except ValueError:
138+ return None
139+
140+
141+_SQFT_RE = re.compile(r"([\d\s ,.]+)\s*(pi2|pi²|pc|sq\.?\s*?ft|ft2|ft²)",
142+ re.IGNORECASE)
143+_SQM_RE = re.compile(r"([\d\s ,.]+)\s*(m2|m²|mc)", re.IGNORECASE)
144+
145+
146+def parse_area_sqft(text: str) -> float | None:
147+ """Superficie habitable en pi² (convertit les m² au besoin)."""
148+ if not text:
149+ return None
150+ t = text.replace(" ", " ")
151+ m = _SQFT_RE.search(t)
152+ if m:
153+ v = parse_float(m.group(1))
154+ return round(v) if v and v > 50 else None
155+ m = _SQM_RE.search(t)
156+ if m:
157+ v = parse_float(m.group(1))
158+ return round(v * 10.7639) if v and v > 5 else None
159+ return None
160+
161+
162+def parse_lot_sqft(text: str) -> float | None:
163+ """Superficie de terrain en pi² — mêmes unités que parse_area_sqft."""
164+ return parse_area_sqft(text)
165+
166+
167+# « 1959 », « 2018 (Neuf) », « 1975, rénové »… mais JAMAIS « 20' X 34' irr. »
168+# ni « À construire » : la valeur doit COMMENCER par une année plausible.
169+_YEAR_RE = re.compile(r"^\s*(1[6-9]\d{2}|20[0-4]\d)\s*(?:$|[(,])")
170+
171+
172+def parse_year(text) -> int | None:
173+ """Année de construction plausible (1600-2049) depuis une valeur `details`.
174+
175+ Volontairement strict (année en tête de valeur, seule ou suivie d'une
176+ parenthèse/virgule) pour ne jamais promouvoir un libellé parasite vers la
177+ colonne year_built."""
178+ if text is None:
179+ return None
180+ if isinstance(text, (int, float)):
181+ y = int(text)
182+ return y if 1600 <= y <= 2049 else None
183+ m = _YEAR_RE.match(str(text))
184+ return int(m.group(1)) if m else None
185+
186+
187+# ---------------------------------------------------------------------------
188+# Type de propriété
189+# ---------------------------------------------------------------------------
190+
191+# House-Ka canonical vocabulary (frontend filters) — ENGLISH.
192+# Sources are CREA DDF sites (English labels), plus the odd French label.
193+_TYPE_MAP = [
194+ # (keywords in the normalized source text, canonical type)
195+ (("maison mobile", "unimodulaire", "mobile home", "manufactured home",
196+ "modular",), "Mobile home"),
197+ (("jumele", "semi-detache", "semi detache", "semi-detached", "semi detached",),
198+ "Semi-detached"),
199+ (("maison de ville", "townhouse", "town house", "en rangee", "row house",
200+ "row / town",), "Townhouse"),
201+ (("condo", "copropriete", "appartement", "apartment", "loft", "penthouse",
202+ "studio", "strata",), "Condo"),
203+ (("duplex",), "Duplex"),
204+ (("triplex",), "Triplex"),
205+ (("quadruplex", "quintuplex", "multiplex", "multilogement", "multi-logement",
206+ "immeuble a revenus", "revenus", "multi-family", "multi family",
207+ "multifamily", "fourplex",), "Multi-family"),
208+ (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau",
209+ "recreational", "cabin",), "Cottage"),
210+ (("terre", "terrain", "lot ", "vacant land", "land",), "Land"),
211+ (("ferme", "fermette", "agricole", "agriculture", "hobby farm", "farm",
212+ "acreage",), "Farm"),
213+ (("plain-pied", "bungalow",), "House"),
214+ (("maison a etages", "a etage", "deux etages", "cottage",), "House"),
215+ (("unifamiliale", "maison", "house", "residence", "residential", "split",
216+ "detached", "single family", "single-family",), "House"),
217+ # enriched-sheet vocabulary: « 4 logements », « propriété à revenu »
218+ (("logements", "logement/", "unites et +", "revenu",), "Multi-family"),
219+ (("bi generation", "bi-generation", "bigeneration", "intergeneration",),
220+ "House"),
221+ (("domaine et villa", "villa", "domaine",), "House"),
222+ (("parking",), "Parking"),
223+ (("commercial", "commerce", "industriel", "industrie", "bureau", "local",
224+ "entreprise", "batisse", "restaurant", "depanneur", "hotel", "motel",
225+ "garage/", "concessionnaire", "coiffure", "esthetique", "camping",
226+ "retail", "office", "industrial", "warehouse", "business",
227+ "institutional",), "Commercial"),
228+]
229+
230+
231+def normalize_property_type(text: str) -> str:
232+ import html as _html
233+ key = strip_accents(_html.unescape(text or "").strip().lower())
234+ if not key:
235+ return ""
236+ for keywords, canon in _TYPE_MAP:
237+ if any(k in key for k in keywords):
238+ return canon
239+ return _html.unescape(text).strip().capitalize()
240+
241+
242+# ---------------------------------------------------------------------------
243+# Chambres / salles de bains depuis du texte libre
244+# ---------------------------------------------------------------------------
245+
246+_BED_RE = re.compile(r"(\d+)\s*(?:ch(?:ambre)?s?|cac|bed(?:room)?s?)\b",
247+ re.IGNORECASE)
248+_BATH_RE = re.compile(r"(\d+)\s*(?:sdb|salle?s?\s+de\s+bains?|bath(?:room)?s?)",
249+ re.IGNORECASE)
250+
251+
252+def extract_bedrooms_bathrooms(text: str) -> tuple[int | None, int | None]:
253+ if not text:
254+ return None, None
255+ beds = _BED_RE.search(text)
256+ baths = _BATH_RE.search(text)
257+ return (int(beds.group(1)) if beds else None,
258+ int(baths.group(1)) if baths else None)
added scripts/merge_canada_candidates.py +79 −0
@@ -0,0 +1,79 @@
1+#!/usr/bin/env python3
2+# -----------------------------------------------------------------------------
3+# House-Ka — fusion des candidats RealtyPress reste-du-Canada (2026-08-27)
4+# scripts/merge_canada_candidates.py : lit /tmp/rp_canada_candidates.json
5+# (sortie du scout), filtre (volume, Québec/Ontario), et ajoute les nouveaux
6+# sites à data/canada_agencies.json + data/sources.json.
7+# Usage : .venv/bin/python scripts/merge_canada_candidates.py [--min-cards 10]
8+# -----------------------------------------------------------------------------
9+import json
10+import re
11+import sys
12+from pathlib import Path
13+
14+ROOT = Path(__file__).resolve().parent.parent
15+CANDIDATES = Path("/tmp/rp_canada_candidates.json")
16+MIN_CARDS = int(sys.argv[sys.argv.index("--min-cards") + 1]) if "--min-cards" in sys.argv else 10
17+
18+cands = json.loads(CANDIDATES.read_text())
19+reg_p = ROOT / "data" / "canada_agencies.json"
20+src_p = ROOT / "data" / "sources.json"
21+registry = json.loads(reg_p.read_text())
22+sources = json.loads(src_p.read_text())
23+known_domains = {re.sub(r"^https?://(www\.)?", "", e["site"]).rstrip("/")
24+ for e in registry}
25+known_ids = {s["id"] for s in sources["sources"]}
26+
27+added = []
28+for c in sorted(cands, key=lambda x: -x.get("approx_volume_gte", 0)):
29+ dom = c["domain"]
30+ if dom in known_domains:
31+ continue
32+ provs = c.get("provinces", {})
33+ total = sum(provs.values()) or 1
34+ # province dominante observée sur la page 1
35+ top = max(provs, key=provs.get) if provs else c.get("province_hint", "")
36+ # hors périmètre : sites majoritairement québécois ou ontariens (l'Ontario
37+ # est déjà couvert par les 15 sources existantes — n'ajouter que si gros)
38+ if re.search(r"qu[ée]bec", top, re.I):
39+ continue
40+ if top == "Ontario" and c.get("approx_volume_gte", 0) < 5000:
41+ continue
42+ if c.get("cards_page1", 0) < MIN_CARDS:
43+ continue
44+ slug = re.sub(r"[^a-z0-9]+", "", dom.split(".")[0])[:24]
45+ sid = f"rp_ag_{slug}"
46+ if sid in known_ids:
47+ continue
48+ pages = max(10, min(1500, c.get("approx_pages_gte", 1) * 2))
49+ entry = {
50+ "id": sid,
51+ "name": f"{dom} ({top or 'Canada'})",
52+ "site": f"https://{dom}",
53+ "archive": c.get("archive", "listing"),
54+ "max_pages": pages,
55+ "province": top or "Ontario",
56+ "note": (f"Recensement CANADA 2026-08-27 : ~≥{c.get('approx_volume_gte', '?')} fiches, "
57+ f"provinces page 1 : {provs}. RealtyPress/DDF."),
58+ }
59+ registry.append(entry)
60+ sources["sources"].append({
61+ "id": sid,
62+ "name": entry["name"],
63+ "url": entry["site"],
64+ "listing_url": f"{entry['site']}/{entry['archive']}/",
65+ "coverage": f"{top or 'Canada'} — ~≥{c.get('approx_volume_gte', '?')} fiches DDF",
66+ "connector": "realtypress",
67+ "status": "actif",
68+ "type": "agence",
69+ "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid).",
70+ })
71+ known_ids.add(sid)
72+ known_domains.add(dom)
73+ added.append((sid, top, c.get("approx_volume_gte")))
74+
75+reg_p.write_text(json.dumps(registry, ensure_ascii=False, indent=1))
76+src_p.write_text(json.dumps(sources, ensure_ascii=False, indent=2))
77+print(f"{len(added)} nouvelles sources :")
78+for sid, top, vol in added:
79+ print(f" {sid:34s} {top:24s} ~≥{vol}")
added scripts/scout_canada_rp.py +145 −0
@@ -0,0 +1,145 @@
1+#!/usr/bin/env python3
2+# -----------------------------------------------------------------------------
3+# House-Ka — scout RealtyPress reste-du-Canada (2026-08-27)
4+# scripts/scout_canada_rp.py : découverte de sites d'agences RealtyPress/CREA
5+# DDF hors Ontario/Québec via Serper (Google), puis validation live par
6+# empreinte (`rps-property-result`) et estimation de volume par sondage de
7+# pagination. Sortie : /tmp/rp_canada_candidates.json
8+# Usage : SERPER_API_KEY dans ~/.claude/.env — .venv/bin/python scripts/scout_canada_rp.py
9+# -----------------------------------------------------------------------------
10+import json
11+import os
12+import re
13+import time
14+from pathlib import Path
15+from urllib.parse import urlparse
16+
17+import requests
18+
19+# clé Serper (~/.claude/.env du nœud)
20+env = Path.home() / ".claude" / ".env"
21+for line in env.read_text().splitlines():
22+ if line.startswith("SERPER_API_KEY="):
23+ os.environ.setdefault("SERPER_API_KEY", line.split("=", 1)[1].strip())
24+KEY = os.environ["SERPER_API_KEY"]
25+
26+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
27+ "(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
28+
29+PROVINCES = {
30+ "British Columbia": ["Vancouver", "Victoria", "Kelowna", "Kamloops", "Nanaimo", "Prince George"],
31+ "Alberta": ["Calgary", "Edmonton", "Red Deer", "Lethbridge", "Medicine Hat", "Grande Prairie"],
32+ "Saskatchewan": ["Saskatoon", "Regina", "Prince Albert", "Moose Jaw"],
33+ "Manitoba": ["Winnipeg", "Brandon", "Steinbach"],
34+ "New Brunswick": ["Moncton", "Fredericton", "Saint John", "Miramichi"],
35+ "Nova Scotia": ["Halifax", "Sydney", "Truro", "Yarmouth"],
36+ "Prince Edward Island": ["Charlottetown", "Summerside"],
37+ "Newfoundland and Labrador": ["St. John's", "Corner Brook", "Gander"],
38+ "Yukon": ["Whitehorse"],
39+ "Northwest Territories": ["Yellowknife"],
40+}
41+
42+SKIP_DOMAINS = re.compile(
43+ r"realtor\.ca|realtypress\.ca|zillow|point2homes|remax\.ca|royallepage\.ca|"
44+ r"centris|duproprio|kijiji|facebook|youtube|instagram|linkedin|wikipedia|"
45+ r"realtylink|housesigma|zolo\.ca|honestdoor|wowa\.ca|estately|redfin|"
46+ r"strata\.ca|rew\.ca|ovlix|homefinder|propertyguys")
47+
48+sess = requests.Session()
49+sess.headers["User-Agent"] = UA
50+sess.cookies.set("disclaimer", "accepted")
51+
52+
53+def serper(q: str) -> list[str]:
54+ try:
55+ r = requests.post("https://google.serper.dev/search",
56+ headers={"X-API-KEY": KEY, "Content-Type": "application/json"},
57+ json={"q": q, "gl": "ca", "hl": "en", "num": 30}, timeout=20)
58+ r.raise_for_status()
59+ out = []
60+ for it in r.json().get("organic", []):
61+ host = urlparse(it.get("link", "")).netloc.lower().removeprefix("www.")
62+ if host and not SKIP_DOMAINS.search(host):
63+ out.append(host)
64+ return out
65+ except Exception as e:
66+ print(f" serper KO ({q[:50]}…): {e}")
67+ return []
68+
69+
70+def fetch(url: str) -> str:
71+ try:
72+ r = sess.get(url, timeout=18, allow_redirects=True)
73+ return r.text if r.status_code == 200 else ""
74+ except Exception:
75+ return ""
76+
77+
78+CARD = "rps-property-result"
79+PROV_RE = re.compile(r",\s*(British Columbia|Alberta|Saskatchewan|Manitoba|"
80+ r"New Brunswick|Nova Scotia|Prince Edward Island|"
81+ r"Newfoundland and Labrador|Ontario|Qu[ée]bec|Yukon|"
82+ r"Northwest Territories|Nunavut)\b", re.I)
83+
84+
85+def probe(domain: str) -> dict | None:
86+ """Valide l'empreinte RealtyPress et estime le volume par sondage de pages."""
87+ for archive in ("listing", "listings", "all-regional-listings"):
88+ base = f"https://{domain}/{archive}"
89+ body = fetch(f"{base}/?posts_per_page=100")
90+ n = body.count(CARD)
91+ if n < 3:
92+ continue
93+ # provinces observées sur la 1re page
94+ provs: dict[str, int] = {}
95+ for m in PROV_RE.finditer(body):
96+ p = m.group(1).title().replace("Nd", "nd").replace(" And ", " and ")
97+ provs[p] = provs.get(p, 0) + 1
98+ # volume : dernière page non vide parmi des jalons croissants
99+ last_ok, per_page = 1, n
100+ for pg in (5, 20, 50, 100, 200, 400, 800, 1200):
101+ b = fetch(f"{base}/page/{pg}/?posts_per_page=100")
102+ c = b.count(CARD)
103+ if c >= 3:
104+ last_ok, per_page = pg, max(per_page, c)
105+ else:
106+ break
107+ time.sleep(0.4)
108+ return {"domain": domain, "archive": archive, "cards_page1": n,
109+ "provinces": provs, "approx_pages_gte": last_ok,
110+ "approx_volume_gte": last_ok * per_page}
111+ return None
112+
113+
114+def main() -> None:
115+ domains: dict[str, str] = {}
116+ for prov, cities in PROVINCES.items():
117+ queries = [
118+ f'"MLS® Number" "{prov}" inurl:listing',
119+ f'"Powered by RealtyPress" "{prov}"',
120+ ] + [f'inurl:/listing/ "MLS®" "{c}" "{prov}"' for c in cities[:3]]
121+ for q in queries:
122+ for d in serper(q):
123+ domains.setdefault(d, prov)
124+ time.sleep(0.4)
125+ print(f"[scout] {prov}: {sum(1 for v in domains.values() if v == prov)} candidats cumulés")
126+
127+ print(f"[scout] {len(domains)} domaines candidats — validation live…")
128+ results = []
129+ for i, (d, prov_hint) in enumerate(sorted(domains.items()), 1):
130+ r = probe(d)
131+ if r:
132+ r["province_hint"] = prov_hint
133+ results.append(r)
134+ print(f" ✅ {d} ({r['archive']}) ~≥{r['approx_volume_gte']} — {r['provinces']}")
135+ if i % 20 == 0:
136+ print(f" … {i}/{len(domains)}")
137+ time.sleep(0.3)
138+
139+ out = Path("/tmp/rp_canada_candidates.json")
140+ out.write_text(json.dumps(results, indent=1, ensure_ascii=False))
141+ print(f"[scout] {len(results)} sites RealtyPress validés → {out}")
142+
143+
144+if __name__ == "__main__":
145+ main()
added scripts/scout_canada_rp2.py +142 −0
Large diff (142 lines) is not loaded by default to keep the page fast.Load diff
added sources.json +290 −0
Large diff (292 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme