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.
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|m²|mc)", re.IGNORECASE) | |
| 144 | + | |
| 145 | + | |
| 146 | +def parse_area_sqft(text: str) -> float | None: | |
| 147 | + """Superficie habitable en pi² (convertit les m² au besoin).""" | |
| 148 | + if not text: | |
| 149 | + return None | |
| 150 | + t = text.replace(" ", " ") | |
| 151 | + m = _SQFT_RE.search(t) | |
| 152 | + if m: | |
| 153 | + v = parse_float(m.group(1)) | |
| 154 | + return round(v) if v and v > 50 else None | |
| 155 | + m = _SQM_RE.search(t) | |
| 156 | + if m: | |
| 157 | + v = parse_float(m.group(1)) | |
| 158 | + return round(v * 10.7639) if v and v > 5 else None | |
| 159 | + return None | |
| 160 | + | |
| 161 | + | |
| 162 | +def parse_lot_sqft(text: str) -> float | None: | |
| 163 | + """Superficie de terrain en pi² — mêmes unités que parse_area_sqft.""" | |
| 164 | + return parse_area_sqft(text) | |
| 165 | + | |
| 166 | + | |
| 167 | +# « 1959 », « 2018 (Neuf) », « 1975, rénové »… mais JAMAIS « 20' X 34' irr. » | |
| 168 | +# ni « À construire » : la valeur doit COMMENCER par une année plausible. | |
| 169 | +_YEAR_RE = re.compile(r"^\s*(1[6-9]\d{2}|20[0-4]\d)\s*(?:$|[(,])") | |
| 170 | + | |
| 171 | + | |
| 172 | +def parse_year(text) -> int | None: | |
| 173 | + """Année de construction plausible (1600-2049) depuis une valeur `details`. | |
| 174 | + | |
| 175 | + Volontairement strict (année en tête de valeur, seule ou suivie d'une | |
| 176 | + parenthèse/virgule) pour ne jamais promouvoir un libellé parasite vers la | |
| 177 | + colonne year_built.""" | |
| 178 | + if text is None: | |
| 179 | + return None | |
| 180 | + if isinstance(text, (int, float)): | |
| 181 | + y = int(text) | |
| 182 | + return y if 1600 <= y <= 2049 else None | |
| 183 | + m = _YEAR_RE.match(str(text)) | |
| 184 | + return int(m.group(1)) if m else None | |
| 185 | + | |
| 186 | + | |
| 187 | +# --------------------------------------------------------------------------- | |
| 188 | +# Type de propriété | |
| 189 | +# --------------------------------------------------------------------------- | |
| 190 | + | |
| 191 | +# House-Ka canonical vocabulary (frontend filters) — ENGLISH. | |
| 192 | +# Sources are CREA DDF sites (English labels), plus the odd French label. | |
| 193 | +_TYPE_MAP = [ | |
| 194 | + # (keywords in the normalized source text, canonical type) | |
| 195 | + (("maison mobile", "unimodulaire", "mobile home", "manufactured home", | |
| 196 | + "modular",), "Mobile home"), | |
| 197 | + (("jumele", "semi-detache", "semi detache", "semi-detached", "semi detached",), | |
| 198 | + "Semi-detached"), | |
| 199 | + (("maison de ville", "townhouse", "town house", "en rangee", "row house", | |
| 200 | + "row / town",), "Townhouse"), | |
| 201 | + (("condo", "copropriete", "appartement", "apartment", "loft", "penthouse", | |
| 202 | + "studio", "strata",), "Condo"), | |
| 203 | + (("duplex",), "Duplex"), | |
| 204 | + (("triplex",), "Triplex"), | |
| 205 | + (("quadruplex", "quintuplex", "multiplex", "multilogement", "multi-logement", | |
| 206 | + "immeuble a revenus", "revenus", "multi-family", "multi family", | |
| 207 | + "multifamily", "fourplex",), "Multi-family"), | |
| 208 | + (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau", | |
| 209 | + "recreational", "cabin",), "Cottage"), | |
| 210 | + (("terre", "terrain", "lot ", "vacant land", "land",), "Land"), | |
| 211 | + (("ferme", "fermette", "agricole", "agriculture", "hobby farm", "farm", | |
| 212 | + "acreage",), "Farm"), | |
| 213 | + (("plain-pied", "bungalow",), "House"), | |
| 214 | + (("maison a etages", "a etage", "deux etages", "cottage",), "House"), | |
| 215 | + (("unifamiliale", "maison", "house", "residence", "residential", "split", | |
| 216 | + "detached", "single family", "single-family",), "House"), | |
| 217 | + # enriched-sheet vocabulary: « 4 logements », « propriété à revenu » | |
| 218 | + (("logements", "logement/", "unites et +", "revenu",), "Multi-family"), | |
| 219 | + (("bi generation", "bi-generation", "bigeneration", "intergeneration",), | |
| 220 | + "House"), | |
| 221 | + (("domaine et villa", "villa", "domaine",), "House"), | |
| 222 | + (("parking",), "Parking"), | |
| 223 | + (("commercial", "commerce", "industriel", "industrie", "bureau", "local", | |
| 224 | + "entreprise", "batisse", "restaurant", "depanneur", "hotel", "motel", | |
| 225 | + "garage/", "concessionnaire", "coiffure", "esthetique", "camping", | |
| 226 | + "retail", "office", "industrial", "warehouse", "business", | |
| 227 | + "institutional",), "Commercial"), | |
| 228 | +] | |
| 229 | + | |
| 230 | + | |
| 231 | +def normalize_property_type(text: str) -> str: | |
| 232 | + import html as _html | |
| 233 | + key = strip_accents(_html.unescape(text or "").strip().lower()) | |
| 234 | + if not key: | |
| 235 | + return "" | |
| 236 | + for keywords, canon in _TYPE_MAP: | |
| 237 | + if any(k in key for k in keywords): | |
| 238 | + return canon | |
| 239 | + return _html.unescape(text).strip().capitalize() | |
| 240 | + | |
| 241 | + | |
| 242 | +# --------------------------------------------------------------------------- | |
| 243 | +# Chambres / salles de bains depuis du texte libre | |
| 244 | +# --------------------------------------------------------------------------- | |
| 245 | + | |
| 246 | +_BED_RE = re.compile(r"(\d+)\s*(?:ch(?:ambre)?s?|cac|bed(?:room)?s?)\b", | |
| 247 | + re.IGNORECASE) | |
| 248 | +_BATH_RE = re.compile(r"(\d+)\s*(?:sdb|salle?s?\s+de\s+bains?|bath(?:room)?s?)", | |
| 249 | + re.IGNORECASE) | |
| 250 | + | |
| 251 | + | |
| 252 | +def extract_bedrooms_bathrooms(text: str) -> tuple[int | None, int | None]: | |
| 253 | + if not text: | |
| 254 | + return None, None | |
| 255 | + beds = _BED_RE.search(text) | |
| 256 | + baths = _BATH_RE.search(text) | |
| 257 | + return (int(beds.group(1)) if beds else None, | |
| 258 | + int(baths.group(1)) if baths else None) | |
added
scripts/merge_canada_candidates.py
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ----------------------------------------------------------------------------- | |
| 3 | +# House-Ka — fusion des candidats RealtyPress reste-du-Canada (2026-08-27) | |
| 4 | +# scripts/merge_canada_candidates.py : lit /tmp/rp_canada_candidates.json | |
| 5 | +# (sortie du scout), filtre (volume, Québec/Ontario), et ajoute les nouveaux | |
| 6 | +# sites à data/canada_agencies.json + data/sources.json. | |
| 7 | +# Usage : .venv/bin/python scripts/merge_canada_candidates.py [--min-cards 10] | |
| 8 | +# ----------------------------------------------------------------------------- | |
| 9 | +import json | |
| 10 | +import re | |
| 11 | +import sys | |
| 12 | +from pathlib import Path | |
| 13 | + | |
| 14 | +ROOT = Path(__file__).resolve().parent.parent | |
| 15 | +CANDIDATES = Path("/tmp/rp_canada_candidates.json") | |
| 16 | +MIN_CARDS = int(sys.argv[sys.argv.index("--min-cards") + 1]) if "--min-cards" in sys.argv else 10 | |
| 17 | + | |
| 18 | +cands = json.loads(CANDIDATES.read_text()) | |
| 19 | +reg_p = ROOT / "data" / "canada_agencies.json" | |
| 20 | +src_p = ROOT / "data" / "sources.json" | |
| 21 | +registry = json.loads(reg_p.read_text()) | |
| 22 | +sources = json.loads(src_p.read_text()) | |
| 23 | +known_domains = {re.sub(r"^https?://(www\.)?", "", e["site"]).rstrip("/") | |
| 24 | + for e in registry} | |
| 25 | +known_ids = {s["id"] for s in sources["sources"]} | |
| 26 | + | |
| 27 | +added = [] | |
| 28 | +for c in sorted(cands, key=lambda x: -x.get("approx_volume_gte", 0)): | |
| 29 | + dom = c["domain"] | |
| 30 | + if dom in known_domains: | |
| 31 | + continue | |
| 32 | + provs = c.get("provinces", {}) | |
| 33 | + total = sum(provs.values()) or 1 | |
| 34 | + # province dominante observée sur la page 1 | |
| 35 | + top = max(provs, key=provs.get) if provs else c.get("province_hint", "") | |
| 36 | + # hors périmètre : sites majoritairement québécois ou ontariens (l'Ontario | |
| 37 | + # est déjà couvert par les 15 sources existantes — n'ajouter que si gros) | |
| 38 | + if re.search(r"qu[ée]bec", top, re.I): | |
| 39 | + continue | |
| 40 | + if top == "Ontario" and c.get("approx_volume_gte", 0) < 5000: | |
| 41 | + continue | |
| 42 | + if c.get("cards_page1", 0) < MIN_CARDS: | |
| 43 | + continue | |
| 44 | + slug = re.sub(r"[^a-z0-9]+", "", dom.split(".")[0])[:24] | |
| 45 | + sid = f"rp_ag_{slug}" | |
| 46 | + if sid in known_ids: | |
| 47 | + continue | |
| 48 | + pages = max(10, min(1500, c.get("approx_pages_gte", 1) * 2)) | |
| 49 | + entry = { | |
| 50 | + "id": sid, | |
| 51 | + "name": f"{dom} ({top or 'Canada'})", | |
| 52 | + "site": f"https://{dom}", | |
| 53 | + "archive": c.get("archive", "listing"), | |
| 54 | + "max_pages": pages, | |
| 55 | + "province": top or "Ontario", | |
| 56 | + "note": (f"Recensement CANADA 2026-08-27 : ~≥{c.get('approx_volume_gte', '?')} fiches, " | |
| 57 | + f"provinces page 1 : {provs}. RealtyPress/DDF."), | |
| 58 | + } | |
| 59 | + registry.append(entry) | |
| 60 | + sources["sources"].append({ | |
| 61 | + "id": sid, | |
| 62 | + "name": entry["name"], | |
| 63 | + "url": entry["site"], | |
| 64 | + "listing_url": f"{entry['site']}/{entry['archive']}/", | |
| 65 | + "coverage": f"{top or 'Canada'} — ~≥{c.get('approx_volume_gte', '?')} fiches DDF", | |
| 66 | + "connector": "realtypress", | |
| 67 | + "status": "actif", | |
| 68 | + "type": "agence", | |
| 69 | + "note": "House-Ka — generic RealtyPress connector (registry data/canada_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid).", | |
| 70 | + }) | |
| 71 | + known_ids.add(sid) | |
| 72 | + known_domains.add(dom) | |
| 73 | + added.append((sid, top, c.get("approx_volume_gte"))) | |
| 74 | + | |
| 75 | +reg_p.write_text(json.dumps(registry, ensure_ascii=False, indent=1)) | |
| 76 | +src_p.write_text(json.dumps(sources, ensure_ascii=False, indent=2)) | |
| 77 | +print(f"{len(added)} nouvelles sources :") | |
| 78 | +for sid, top, vol in added: | |
| 79 | + print(f" {sid:34s} {top:24s} ~≥{vol}") | |
added
scripts/scout_canada_rp.py
+145 −0
@@ -0,0 +1,145 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ----------------------------------------------------------------------------- | |
| 3 | +# House-Ka — scout RealtyPress reste-du-Canada (2026-08-27) | |
| 4 | +# scripts/scout_canada_rp.py : découverte de sites d'agences RealtyPress/CREA | |
| 5 | +# DDF hors Ontario/Québec via Serper (Google), puis validation live par | |
| 6 | +# empreinte (`rps-property-result`) et estimation de volume par sondage de | |
| 7 | +# pagination. Sortie : /tmp/rp_canada_candidates.json | |
| 8 | +# Usage : SERPER_API_KEY dans ~/.claude/.env — .venv/bin/python scripts/scout_canada_rp.py | |
| 9 | +# ----------------------------------------------------------------------------- | |
| 10 | +import json | |
| 11 | +import os | |
| 12 | +import re | |
| 13 | +import time | |
| 14 | +from pathlib import Path | |
| 15 | +from urllib.parse import urlparse | |
| 16 | + | |
| 17 | +import requests | |
| 18 | + | |
| 19 | +# clé Serper (~/.claude/.env du nœud) | |
| 20 | +env = Path.home() / ".claude" / ".env" | |
| 21 | +for line in env.read_text().splitlines(): | |
| 22 | + if line.startswith("SERPER_API_KEY="): | |
| 23 | + os.environ.setdefault("SERPER_API_KEY", line.split("=", 1)[1].strip()) | |
| 24 | +KEY = os.environ["SERPER_API_KEY"] | |
| 25 | + | |
| 26 | +UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " | |
| 27 | + "(KHTML, like Gecko) Chrome/126.0 Safari/537.36") | |
| 28 | + | |
| 29 | +PROVINCES = { | |
| 30 | + "British Columbia": ["Vancouver", "Victoria", "Kelowna", "Kamloops", "Nanaimo", "Prince George"], | |
| 31 | + "Alberta": ["Calgary", "Edmonton", "Red Deer", "Lethbridge", "Medicine Hat", "Grande Prairie"], | |
| 32 | + "Saskatchewan": ["Saskatoon", "Regina", "Prince Albert", "Moose Jaw"], | |
| 33 | + "Manitoba": ["Winnipeg", "Brandon", "Steinbach"], | |
| 34 | + "New Brunswick": ["Moncton", "Fredericton", "Saint John", "Miramichi"], | |
| 35 | + "Nova Scotia": ["Halifax", "Sydney", "Truro", "Yarmouth"], | |
| 36 | + "Prince Edward Island": ["Charlottetown", "Summerside"], | |
| 37 | + "Newfoundland and Labrador": ["St. John's", "Corner Brook", "Gander"], | |
| 38 | + "Yukon": ["Whitehorse"], | |
| 39 | + "Northwest Territories": ["Yellowknife"], | |
| 40 | +} | |
| 41 | + | |
| 42 | +SKIP_DOMAINS = re.compile( | |
| 43 | + r"realtor\.ca|realtypress\.ca|zillow|point2homes|remax\.ca|royallepage\.ca|" | |
| 44 | + r"centris|duproprio|kijiji|facebook|youtube|instagram|linkedin|wikipedia|" | |
| 45 | + r"realtylink|housesigma|zolo\.ca|honestdoor|wowa\.ca|estately|redfin|" | |
| 46 | + r"strata\.ca|rew\.ca|ovlix|homefinder|propertyguys") | |
| 47 | + | |
| 48 | +sess = requests.Session() | |
| 49 | +sess.headers["User-Agent"] = UA | |
| 50 | +sess.cookies.set("disclaimer", "accepted") | |
| 51 | + | |
| 52 | + | |
| 53 | +def serper(q: str) -> list[str]: | |
| 54 | + try: | |
| 55 | + r = requests.post("https://google.serper.dev/search", | |
| 56 | + headers={"X-API-KEY": KEY, "Content-Type": "application/json"}, | |
| 57 | + json={"q": q, "gl": "ca", "hl": "en", "num": 30}, timeout=20) | |
| 58 | + r.raise_for_status() | |
| 59 | + out = [] | |
| 60 | + for it in r.json().get("organic", []): | |
| 61 | + host = urlparse(it.get("link", "")).netloc.lower().removeprefix("www.") | |
| 62 | + if host and not SKIP_DOMAINS.search(host): | |
| 63 | + out.append(host) | |
| 64 | + return out | |
| 65 | + except Exception as e: | |
| 66 | + print(f" serper KO ({q[:50]}…): {e}") | |
| 67 | + return [] | |
| 68 | + | |
| 69 | + | |
| 70 | +def fetch(url: str) -> str: | |
| 71 | + try: | |
| 72 | + r = sess.get(url, timeout=18, allow_redirects=True) | |
| 73 | + return r.text if r.status_code == 200 else "" | |
| 74 | + except Exception: | |
| 75 | + return "" | |
| 76 | + | |
| 77 | + | |
| 78 | +CARD = "rps-property-result" | |
| 79 | +PROV_RE = re.compile(r",\s*(British Columbia|Alberta|Saskatchewan|Manitoba|" | |
| 80 | + r"New Brunswick|Nova Scotia|Prince Edward Island|" | |
| 81 | + r"Newfoundland and Labrador|Ontario|Qu[ée]bec|Yukon|" | |
| 82 | + r"Northwest Territories|Nunavut)\b", re.I) | |
| 83 | + | |
| 84 | + | |
| 85 | +def probe(domain: str) -> dict | None: | |
| 86 | + """Valide l'empreinte RealtyPress et estime le volume par sondage de pages.""" | |
| 87 | + for archive in ("listing", "listings", "all-regional-listings"): | |
| 88 | + base = f"https://{domain}/{archive}" | |
| 89 | + body = fetch(f"{base}/?posts_per_page=100") | |
| 90 | + n = body.count(CARD) | |
| 91 | + if n < 3: | |
| 92 | + continue | |
| 93 | + # provinces observées sur la 1re page | |
| 94 | + provs: dict[str, int] = {} | |
| 95 | + for m in PROV_RE.finditer(body): | |
| 96 | + p = m.group(1).title().replace("Nd", "nd").replace(" And ", " and ") | |
| 97 | + provs[p] = provs.get(p, 0) + 1 | |
| 98 | + # volume : dernière page non vide parmi des jalons croissants | |
| 99 | + last_ok, per_page = 1, n | |
| 100 | + for pg in (5, 20, 50, 100, 200, 400, 800, 1200): | |
| 101 | + b = fetch(f"{base}/page/{pg}/?posts_per_page=100") | |
| 102 | + c = b.count(CARD) | |
| 103 | + if c >= 3: | |
| 104 | + last_ok, per_page = pg, max(per_page, c) | |
| 105 | + else: | |
| 106 | + break | |
| 107 | + time.sleep(0.4) | |
| 108 | + return {"domain": domain, "archive": archive, "cards_page1": n, | |
| 109 | + "provinces": provs, "approx_pages_gte": last_ok, | |
| 110 | + "approx_volume_gte": last_ok * per_page} | |
| 111 | + return None | |
| 112 | + | |
| 113 | + | |
| 114 | +def main() -> None: | |
| 115 | + domains: dict[str, str] = {} | |
| 116 | + for prov, cities in PROVINCES.items(): | |
| 117 | + queries = [ | |
| 118 | + f'"MLS® Number" "{prov}" inurl:listing', | |
| 119 | + f'"Powered by RealtyPress" "{prov}"', | |
| 120 | + ] + [f'inurl:/listing/ "MLS®" "{c}" "{prov}"' for c in cities[:3]] | |
| 121 | + for q in queries: | |
| 122 | + for d in serper(q): | |
| 123 | + domains.setdefault(d, prov) | |
| 124 | + time.sleep(0.4) | |
| 125 | + print(f"[scout] {prov}: {sum(1 for v in domains.values() if v == prov)} candidats cumulés") | |
| 126 | + | |
| 127 | + print(f"[scout] {len(domains)} domaines candidats — validation live…") | |
| 128 | + results = [] | |
| 129 | + for i, (d, prov_hint) in enumerate(sorted(domains.items()), 1): | |
| 130 | + r = probe(d) | |
| 131 | + if r: | |
| 132 | + r["province_hint"] = prov_hint | |
| 133 | + results.append(r) | |
| 134 | + print(f" ✅ {d} ({r['archive']}) ~≥{r['approx_volume_gte']} — {r['provinces']}") | |
| 135 | + if i % 20 == 0: | |
| 136 | + print(f" … {i}/{len(domains)}") | |
| 137 | + time.sleep(0.3) | |
| 138 | + | |
| 139 | + out = Path("/tmp/rp_canada_candidates.json") | |
| 140 | + out.write_text(json.dumps(results, indent=1, ensure_ascii=False)) | |
| 141 | + print(f"[scout] {len(results)} sites RealtyPress validés → {out}") | |
| 142 | + | |
| 143 | + | |
| 144 | +if __name__ == "__main__": | |
| 145 | + main() | |
added
scripts/scout_canada_rp2.py
+142 −0
@@ -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 | ||