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