SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%

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 27 days ago (Aug 27, 2026) parent bc58598

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 64 RealtyPress sites exist across Canada. Census & instructions:
65 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 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 9 "connector": "realtypress",
10 10 "status": "actif",
11 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 15 "id": "rp_ag_codygroup",
@@ -20,7 +20,7 @@
20 20 "connector": "realtypress",
21 21 "status": "actif",
22 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 26 "id": "rp_ag_suttonottawa",
@@ -31,7 +31,7 @@
31 31 "connector": "realtypress",
32 32 "status": "actif",
33 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 37 "id": "rp_ag_helensteam",
@@ -42,7 +42,7 @@
42 42 "connector": "realtypress",
43 43 "status": "actif",
44 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 48 "id": "rp_ag_greybruce",
@@ -53,7 +53,7 @@
53 53 "connector": "realtypress",
54 54 "status": "actif",
55 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 59 "id": "rp_ag_remaxfinest",
@@ -64,7 +64,7 @@
64 64 "connector": "realtypress",
65 65 "status": "actif",
66 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 70 "id": "rp_ag_riouxbaker",
@@ -75,7 +75,7 @@
75 75 "connector": "realtypress",
76 76 "status": "actif",
77 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 81 "id": "rp_ag_countyguys",
@@ -86,7 +86,7 @@
86 86 "connector": "realtypress",
87 87 "status": "actif",
88 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 92 "id": "rp_ag_labrosse",
@@ -97,7 +97,7 @@
97 97 "connector": "realtypress",
98 98 "status": "actif",
99 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 103 "id": "rp_ag_ryanpattinson",
@@ -108,7 +108,7 @@
108 108 "connector": "realtypress",
109 109 "status": "actif",
110 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 114 "id": "rp_ag_grapevine",
@@ -119,7 +119,7 @@
119 119 "connector": "realtypress",
120 120 "status": "actif",
121 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 125 "id": "rp_ag_rlpheartland",
@@ -130,7 +130,7 @@
130 130 "connector": "realtypress",
131 131 "status": "actif",
132 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 136 "id": "rp_ag_signaturenorth",
@@ -141,7 +141,7 @@
141 141 "connector": "realtypress",
142 142 "status": "actif",
143 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 147 "id": "rp_ag_saultstemarie",
@@ -152,7 +152,7 @@
152 152 "connector": "realtypress",
153 153 "status": "actif",
154 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 158 "id": "rp_ag_cbnorthbay",
@@ -163,7 +163,128 @@
163 163 "connector": "realtypress",
164 164 "status": "actif",
165 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 171 <div className="hk-foot-brand">House<span className="ka">Ka</span></div>
172 172 <p className="hk-foot-desc">
173 173 House-Ka continuously aggregates homes for sale publicly listed by
174 Canadian real-estate brokerages and teams on the CREA DDF feed —
175 starting with Ontario and growing across the rest of Canada. Every
176 listing links back to the brokerage's original page. House-Ka is a
177 service of <b>Groupe KA</b>.
174 + Canadian real-estate brokerages, teams and national networks — every
175 + province and territory except Québec, which lives on our sister site
176 + Immo-Ka. Every listing links back to the source's original page.
177 + House-Ka is a service of <b>Groupe KA</b>.
178 178 </p>
179 179 <p className="hk-foot-notice">
180 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 216 <section className="hero">
217 217 <div className="hero-wrap">
218 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 220 <h1 className="hero-display" aria-label="Every home for sale. One place.">
221 221 <span className="hd-l1" aria-hidden="true">Every home</span>
222 222 <span className="hd-l2" aria-hidden="true">for sale.</span>
223 223 <span className="hd-l4" aria-hidden="true">One <em className="signal">place</em>.</span>
224 224 </h1>
225 225 <p className="lede">
226 Homes listed by Canadian real-estate brokerages and teams on the
227 CREA DDF feed — aggregated continuously, full photos and details,
228 direct link to the original listing. Ontario today, the rest of
229 Canada next.
226 + Homes listed by Canadian brokerages and national networks —
227 + every province except Québec (that's our sister site Immo-Ka),
228 + aggregated continuously with photos, details and a direct link
229 + to the original listing.
230 230 </p>
231 231 <div className="live-line" aria-label="Live data">
232 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 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 5 # RealtyPress = plugin WordPress branché sur le flux CREA DDF ; ~35 sites
6 6 # d'agences/équipes ontariennes confirmés (recensement 2026-08-27, voir
7 7 # docs/ontario-agences-connecteurs.md). Chaque site expose l'IDX/DDF complet
@@ -19,7 +19,7 @@
19 19 # - external_id = ddf<id> (préfixe : jamais de collision avec les n° Centris
20 20 # QC) ; sources avec infixe _ag_ → la dédup par external_id masque les
21 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 24 from __future__ import annotations
25 25
@@ -34,7 +34,7 @@ from .base import BaseConnector
34 34 from . import _detailutil as du
35 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 38 DETAIL_LIMIT = int(os.environ.get("IMMOKA_RP_DETAIL_LIMIT",
39 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 54 r"<td[^>]*>(.*?)</td>", re.S)
55 55 _DESC_RE = re.compile(r'<!--\s*Description\s*-->\s*<p[^>]*>(.*?)</p>', re.S)
56 56 _DESC_RE2 = re.compile(r'<p itemprop="description"[^>]*>(.*?)</p>', re.S)
57 # ville depuis <title> « 3383 Romeo Street, Greater Sudbury (Valley East), Ontario … »
58 _TITLE_CITY_RE = re.compile(r",\s*([^,<>|]{2,60}),\s*Ontario\b")
57 +# provinces couvertes (House-Ka = Canada HORS Québec — le Québec vit sur immo-ka)
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 67 _PRICING_RE = re.compile(r'rps-pricing[^>]*>\s*\$\s*([\d,]+)')
60 68 _ID_TAIL_RE = re.compile(r"\s*\(id:\d{4,9}\)\s*$")
61 69 _TAG_RE = re.compile(r"<[^>]+>")
@@ -76,10 +84,11 @@ def _num(s: str) -> float | None:
76 84
77 85
78 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 89 agency_name = ""
82 90 site_url = ""
91 + province = "Ontario" # région par défaut des fiches (registre : "province")
83 92 archive = "listing" # chemin de l'archive (revelrealty: "listings",
84 93 # codygroup: "all-regional-listings")
85 94 max_pages = 150 # 100 cartes/page → jusqu'à 15 000 fiches par site
@@ -137,16 +146,25 @@ class _RealtyPress(BaseConnector):
137 146 if "rent" in ribbon or "lease" in ribbon:
138 147 return # locations : hors périmètre
139 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 150 broker_name=self.agency_name)
142 151 ma = _H4_RE.search(card)
143 152 if ma:
144 153 lst.address = _html.unescape(_TAG_RE.sub(" ", ma.group(1))).strip()
145 154 mc = _CITY_RE.search(card)
146 155 if mc:
147 city = _html.unescape(mc.group(1)).strip().rstrip(",")
148 city = re.sub(r",?\s*Ontario\b.*$", "", city, flags=re.I)
149 lst.city = city.split("(")[0].strip()
156 + raw = _html.unescape(mc.group(1)).strip().rstrip(",")
157 + # fiches québécoises (Gatineau… dans les pools DDF frontaliers) :
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 168 mp = _PRICE_RE.search(card)
151 169 if mp:
152 170 lst.price = _num(mp.group(1))
@@ -226,8 +244,10 @@ def parse_rp_detail(html: str) -> dict:
226 244
227 245 mt = re.search(r"<title>(.*?)</title>", html, re.S)
228 246 if mt:
229 mc = _TITLE_CITY_RE.search(_html.unescape(mt.group(1)))
230 if mc:
247 + title_txt = _html.unescape(mt.group(1))
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 251 # « Greater Sudbury (Valley East) » : le secteur part dans sector
232 252 city = mc.group(1).split("(")[0].strip()
233 253 if city and not any(c.isdigit() for c in city):
@@ -294,6 +314,7 @@ for _ag in _load():
294 314 "source_id": _sid,
295 315 "site_url": _ag["site"],
296 316 "agency_name": _ag.get("name", _sid),
317 + "province": _ag.get("province", "Ontario"),
297 318 "archive": _ag.get("archive", "listing"),
298 319 "max_pages": int(_ag.get("max_pages", 150)),
299 320 },
modified immoka/normalize.py +2 −2
@@ -212,8 +212,8 @@ _TYPE_MAP = [
212 212 "acreage",), "Farm"),
213 213 (("plain-pied", "bungalow",), "House"),
214 214 (("maison a etages", "a etage", "deux etages", "cottage",), "House"),
215 (("unifamiliale", "maison", "house", "residence", "split", "detached",
216 "single family", "single-family",), "House"),
215 + (("unifamiliale", "maison", "house", "residence", "residential", "split",
216 + "detached", "single family", "single-family",), "House"),
217 217 # enriched-sheet vocabulary: « 4 logements », « propriété à revenu »
218 218 (("logements", "logement/", "unites et +", "revenu",), "Multi-family"),
219 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||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
@@ -0,0 +1,142 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# House-Ka — scout RealtyPress reste-du-Canada, PASSE 2 (2026-08-27)
4 +# Dorks plus discriminants : les fiches RealtyPress/DDF portent « MLS® Number »
5 +# + la queue de description « (id:NNNN) » — combo quasi unique. Ciblage par
6 +# grande ville des provinces encore mal couvertes.
7 +# Sortie : /tmp/rp_canada_candidates2.json
8 +# -----------------------------------------------------------------------------
9 +import json
10 +import os
11 +import re
12 +import time
13 +from pathlib import Path
14 +from urllib.parse import urlparse
15 +
16 +import requests
17 +
18 +env = Path.home() / ".claude" / ".env"
19 +for line in env.read_text().splitlines():
20 + if line.startswith("SERPER_API_KEY="):
21 + os.environ.setdefault("SERPER_API_KEY", line.split("=", 1)[1].strip())
22 +KEY = os.environ["SERPER_API_KEY"]
23 +
24 +UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
25 + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36")
26 +
27 +CITIES = {
28 + "British Columbia": ["Vancouver", "Surrey", "Victoria", "Kelowna", "Kamloops",
29 + "Nanaimo", "Abbotsford", "Prince George", "Chilliwack", "Vernon"],
30 + "Alberta": ["Calgary", "Edmonton", "Red Deer", "Lethbridge", "Fort McMurray",
31 + "Airdrie", "Medicine Hat", "Grande Prairie"],
32 + "Saskatchewan": ["Saskatoon", "Regina", "Prince Albert", "Moose Jaw", "Swift Current"],
33 + "Manitoba": ["Winnipeg", "Brandon", "Steinbach", "Portage la Prairie"],
34 + "New Brunswick": ["Moncton", "Fredericton", "Saint John", "Dieppe", "Bathurst"],
35 + "Nova Scotia": ["Halifax", "Dartmouth", "Sydney", "Truro", "Bridgewater"],
36 + "Prince Edward Island": ["Charlottetown", "Summerside", "Stratford"],
37 + "Newfoundland and Labrador": ["St. John's", "Mount Pearl", "Corner Brook"],
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|fandom|reddit")
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[:60]}…): {e}", flush=True)
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 + for archive in ("listing", "listings", "all-regional-listings", "property-listings"):
87 + base = f"https://{domain}/{archive}"
88 + body = fetch(f"{base}/?posts_per_page=100")
89 + n = body.count(CARD)
90 + if n < 3:
91 + continue
92 + provs: dict[str, int] = {}
93 + for m in PROV_RE.finditer(body):
94 + provs[m.group(1).title()] = provs.get(m.group(1).title(), 0) + 1
95 + last_ok, per_page = 1, n
96 + for pg in (5, 20, 50, 100, 200, 400, 800):
97 + b = fetch(f"{base}/page/{pg}/?posts_per_page=100")
98 + c = b.count(CARD)
99 + if c >= 3:
100 + last_ok, per_page = pg, max(per_page, c)
101 + else:
102 + break
103 + time.sleep(0.4)
104 + return {"domain": domain, "archive": archive, "cards_page1": n,
105 + "provinces": provs, "approx_pages_gte": last_ok,
106 + "approx_volume_gte": last_ok * per_page}
107 + return None
108 +
109 +
110 +def main() -> None:
111 + domains: dict[str, str] = {}
112 + for prov, cities in CITIES.items():
113 + for c in cities:
114 + for q in (f'"MLS® Number" "(id:" "{c}"',
115 + f'"MLS® Number" "Ownership Type" "{c}, {prov}"'):
116 + for d in serper(q):
117 + domains.setdefault(d, prov)
118 + time.sleep(0.35)
119 + print(f"[scout2] {prov}: {sum(1 for v in domains.values() if v == prov)} candidats",
120 + flush=True)
121 +
122 + print(f"[scout2] {len(domains)} domaines — validation live…", flush=True)
123 + results = []
124 + for i, (d, hint) in enumerate(sorted(domains.items()), 1):
125 + r = probe(d)
126 + if r:
127 + r["province_hint"] = hint
128 + results.append(r)
129 + print(f" ✅ {d} ({r['archive']}) ~≥{r['approx_volume_gte']}{r['provinces']}",
130 + flush=True)
131 + if i % 25 == 0:
132 + print(f" … {i}/{len(domains)}", flush=True)
133 + time.sleep(0.3)
134 +
135 + Path("/tmp/rp_canada_candidates2.json").write_text(
136 + json.dumps(results, indent=1, ensure_ascii=False))
137 + print(f"[scout2] {len(results)} sites validés → /tmp/rp_canada_candidates2.json",
138 + flush=True)
139 +
140 +
141 +if __name__ == "__main__":
142 + main()
added sources.json +290 −0
@@ -0,0 +1,290 @@
1 +{
2 + "sources": [
3 + {
4 + "id": "rp_ag_revelrealty",
5 + "name": "Revel Realty (Niagara & provincial)",
6 + "url": "https://revelrealty.ca",
7 + "listing_url": "https://revelrealty.ca/listings/",
8 + "coverage": "Niagara + pool DDF quasi provincial — ~110 000 fiches",
9 + "connector": "realtypress",
10 + "status": "actif",
11 + "type": "agence",
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 + },
14 + {
15 + "id": "rp_ag_codygroup",
16 + "name": "The Cody Group (London/ITSO)",
17 + "url": "https://codygroup.ca",
18 + "listing_url": "https://codygroup.ca/all-regional-listings/",
19 + "coverage": "London + ITSO élargi — ~58 000 fiches DDF",
20 + "connector": "realtypress",
21 + "status": "actif",
22 + "type": "agence",
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 + },
25 + {
26 + "id": "rp_ag_suttonottawa",
27 + "name": "Sutton Group — Ottawa Realty",
28 + "url": "https://suttonottawa.ca",
29 + "listing_url": "https://suttonottawa.ca/listing/",
30 + "coverage": "Ottawa et l'Est ontarien (board OREB) — ~10 000 fiches DDF",
31 + "connector": "realtypress",
32 + "status": "actif",
33 + "type": "agence",
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 + },
36 + {
37 + "id": "rp_ag_helensteam",
38 + "name": "Helen's Team (Kitchener-Waterloo)",
39 + "url": "https://helensteam.ca",
40 + "listing_url": "https://helensteam.ca/listing/",
41 + "coverage": "Kitchener-Waterloo et région (ITSO) — ~9 900 fiches DDF",
42 + "connector": "realtypress",
43 + "status": "actif",
44 + "type": "agence",
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 + },
47 + {
48 + "id": "rp_ag_greybruce",
49 + "name": "Grey Bruce Real Estate",
50 + "url": "https://greybrucerealestate.ca",
51 + "listing_url": "https://greybrucerealestate.ca/listing/",
52 + "coverage": "Grey-Bruce / Georgian Bay (ITSO) — ~8 300 fiches DDF",
53 + "connector": "realtypress",
54 + "status": "actif",
55 + "type": "agence",
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 + },
58 + {
59 + "id": "rp_ag_remaxfinest",
60 + "name": "RE/MAX Finest Realty (Kingston)",
61 + "url": "https://remaxfinestrealty.com",
62 + "listing_url": "https://remaxfinestrealty.com/listing/",
63 + "coverage": "Kingston et région (KAREA) — ~8 200 fiches DDF",
64 + "connector": "realtypress",
65 + "status": "actif",
66 + "type": "agence",
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 + },
69 + {
70 + "id": "rp_ag_riouxbaker",
71 + "name": "Rioux Baker Real Estate Team (Collingwood)",
72 + "url": "https://riouxbakerteam.com",
73 + "listing_url": "https://riouxbakerteam.com/listing/",
74 + "coverage": "Collingwood / South Georgian Bay — ~7 700 fiches DDF",
75 + "connector": "realtypress",
76 + "status": "actif",
77 + "type": "agence",
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 + },
80 + {
81 + "id": "rp_ag_countyguys",
82 + "name": "The County Guys (Prince Edward County)",
83 + "url": "https://thecountyguys.com",
84 + "listing_url": "https://thecountyguys.com/listing/",
85 + "coverage": "Prince Edward County / Quinte — ~6 900 fiches DDF",
86 + "connector": "realtypress",
87 + "status": "actif",
88 + "type": "agence",
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 + },
91 + {
92 + "id": "rp_ag_labrosse",
93 + "name": "Labrosse Real Estate (Ottawa/Orléans)",
94 + "url": "https://labrosserealestate.com",
95 + "listing_url": "https://labrosserealestate.com/listing/",
96 + "coverage": "Ottawa / Orléans (équipe francophone, OREB) — ~6 900 fiches DDF",
97 + "connector": "realtypress",
98 + "status": "actif",
99 + "type": "agence",
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 + },
102 + {
103 + "id": "rp_ag_ryanpattinson",
104 + "name": "Ryan Pattinson (Pembroke/Renfrew)",
105 + "url": "https://ryanpattinson.com",
106 + "listing_url": "https://ryanpattinson.com/listing/",
107 + "coverage": "Pembroke / vallée de l'Outaouais ontarienne — ~6 600 fiches DDF",
108 + "connector": "realtypress",
109 + "status": "actif",
110 + "type": "agence",
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 + },
113 + {
114 + "id": "rp_ag_grapevine",
115 + "name": "Grapevine (Ottawa)",
116 + "url": "https://grapevine.ca",
117 + "listing_url": "https://grapevine.ca/listing/",
118 + "coverage": "Ottawa (OREB) — ~6 600 fiches DDF",
119 + "connector": "realtypress",
120 + "status": "actif",
121 + "type": "agence",
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 + },
124 + {
125 + "id": "rp_ag_rlpheartland",
126 + "name": "Royal LePage Heartland Realty",
127 + "url": "https://rlpheartland.ca",
128 + "listing_url": "https://rlpheartland.ca/listing/",
129 + "coverage": "Midwestern Ontario (Huron-Perth) — ~5 600 fiches DDF",
130 + "connector": "realtypress",
131 + "status": "actif",
132 + "type": "agence",
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 + },
135 + {
136 + "id": "rp_ag_signaturenorth",
137 + "name": "Signature North Realty (Thunder Bay)",
138 + "url": "https://signaturenorthrealty.ca",
139 + "listing_url": "https://signaturenorthrealty.ca/listing/",
140 + "coverage": "Thunder Bay — ~1 000 fiches DDF",
141 + "connector": "realtypress",
142 + "status": "actif",
143 + "type": "agence",
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 + },
146 + {
147 + "id": "rp_ag_saultstemarie",
148 + "name": "Sault Ste. Marie Real Estate (Century 21 Choice)",
149 + "url": "https://saultstemarierealestate.com",
150 + "listing_url": "https://saultstemarierealestate.com/listing/",
151 + "coverage": "Sault Ste. Marie — ~860 fiches DDF",
152 + "connector": "realtypress",
153 + "status": "actif",
154 + "type": "agence",
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 + },
157 + {
158 + "id": "rp_ag_cbnorthbay",
159 + "name": "Coldwell Banker Peter Minogue (North Bay)",
160 + "url": "https://cbnorthbay.com",
161 + "listing_url": "https://cbnorthbay.com/listing/",
162 + "coverage": "North Bay / Nipissing — ~300 fiches DDF",
163 + "connector": "realtypress",
164 + "status": "actif",
165 + "type": "agence",
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."
288 + }
289 + ]
290 +}
\ No newline at end of file
291