SPB Git forge

spb/immo-ka

Public

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

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

feat(ontario v1): connecteur générique RealtyPress — 13 sites d'agences ON (CREA DDF)

- connectors/realtypress.py : archive /listing/page/N/?posts_per_page=100,
  cookie disclaimer=accepted (mur CREA), 2 variantes de thème (ville avec/sans
  <strong>, ft²), fiches DDF (MLS®, tableaux, GPS JSON-LD, photos ddfcdn),
  external_id ddf<id> + infixe _ag_ → dédup inter-sites par external_id
- data/ontario_agencies.json : registre 13 sites (Ottawa, KW, Kingston,
  Grey-Bruce, Collingwood, PEC, Pembroke, Huron-Perth, Thunder Bay, Sault,
  North Bay) ; sources.json : 13 entrées rp_ag_*
- pipeline Ontario-aware : schema.finalize bbox 41.6–63/-95.5–-56,
  normalize._TYPE_MAP mots-clés anglais (detached, semi-detached, row house,
  multi-family, mobile home, farm, retail…), geocode _BBOX_KA + fallback
  Nominatim « city, Ontario, Canada » + run_batch exclut region='Ontario'
  (Adresses Québec ne couvre pas l'ON ; le DDF fournit lat/lng)

Testé : cbnorthbay ingéré (503 fiches, 150 enrichies publiées score 94,
stubs en quarantaine jusqu'à enrichissement), labrosse/sutton spot-checkés,
API /api/listings?region=Ontario OK, parc QC intact (94 528 publiées).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 27, 2026) parent b0cfba6

6 changed files +569 −40

added data/ontario_agencies.json +80 −0
@@ -0,0 +1,80 @@
1 +[
2 + {
3 + "id": "rp_ag_suttonottawa",
4 + "name": "Sutton Group — Ottawa Realty",
5 + "site": "https://suttonottawa.ca",
6 + "note": "Recensement ON 2026-08-27 : ~10 080 fiches (OREB+). RealtyPress/DDF."
7 + },
8 + {
9 + "id": "rp_ag_helensteam",
10 + "name": "Helen's Team (Kitchener-Waterloo)",
11 + "site": "https://helensteam.ca",
12 + "note": "Recensement ON 2026-08-27 : ~9 910 fiches (Kitchener-Waterloo). RealtyPress/DDF."
13 + },
14 + {
15 + "id": "rp_ag_greybruce",
16 + "name": "Grey Bruce Real Estate",
17 + "site": "https://greybrucerealestate.ca",
18 + "note": "Recensement ON 2026-08-27 : ~8 268 fiches (Grey-Bruce/Georgian Bay). RealtyPress/DDF. Même feed que collaborativerealestate.ca (fallback)."
19 + },
20 + {
21 + "id": "rp_ag_remaxfinest",
22 + "name": "RE/MAX Finest Realty (Kingston)",
23 + "site": "https://remaxfinestrealty.com",
24 + "note": "Recensement ON 2026-08-27 : ~8 217 fiches (Kingston). RealtyPress/DDF."
25 + },
26 + {
27 + "id": "rp_ag_riouxbaker",
28 + "name": "Rioux Baker Real Estate Team (Collingwood)",
29 + "site": "https://riouxbakerteam.com",
30 + "note": "Recensement ON 2026-08-27 : ~7 716 fiches (Collingwood/South Georgian Bay). RealtyPress/DDF."
31 + },
32 + {
33 + "id": "rp_ag_countyguys",
34 + "name": "The County Guys (Prince Edward County)",
35 + "site": "https://thecountyguys.com",
36 + "note": "Recensement ON 2026-08-27 : ~6 878 fiches (Prince Edward County/Quinte). RealtyPress/DDF."
37 + },
38 + {
39 + "id": "rp_ag_labrosse",
40 + "name": "Labrosse Real Estate (Ottawa/Orléans)",
41 + "site": "https://labrosserealestate.com",
42 + "note": "Recensement ON 2026-08-27 : ~6 873 fiches (Ottawa/Orléans, équipe FRANCOPHONE). RealtyPress/DDF."
43 + },
44 + {
45 + "id": "rp_ag_ryanpattinson",
46 + "name": "Ryan Pattinson (Pembroke/Renfrew)",
47 + "site": "https://ryanpattinson.com",
48 + "note": "Recensement ON 2026-08-27 : ~6 601 fiches (Pembroke/vallée de l'Outaouais ON). RealtyPress/DDF."
49 + },
50 + {
51 + "id": "rp_ag_grapevine",
52 + "name": "Grapevine (Ottawa)",
53 + "site": "https://grapevine.ca",
54 + "note": "Recensement ON 2026-08-27 : ~6 550 fiches (Ottawa). RealtyPress/DDF. Suivre les redirections www/apex."
55 + },
56 + {
57 + "id": "rp_ag_rlpheartland",
58 + "name": "Royal LePage Heartland Realty",
59 + "site": "https://rlpheartland.ca",
60 + "note": "Recensement ON 2026-08-27 : ~5 577 fiches (Midwestern Ontario — Huron/Perth). RealtyPress/DDF."
61 + },
62 + {
63 + "id": "rp_ag_signaturenorth",
64 + "name": "Signature North Realty (Thunder Bay)",
65 + "site": "https://signaturenorthrealty.ca",
66 + "note": "Recensement ON 2026-08-27 : ~1 023 fiches (Thunder Bay). RealtyPress/DDF."
67 + },
68 + {
69 + "id": "rp_ag_saultstemarie",
70 + "name": "Sault Ste. Marie Real Estate (Century 21 Choice)",
71 + "site": "https://saultstemarierealestate.com",
72 + "note": "Recensement ON 2026-08-27 : ~864 fiches (Sault Ste. Marie). RealtyPress/DDF."
73 + },
74 + {
75 + "id": "rp_ag_cbnorthbay",
76 + "name": "Coldwell Banker Peter Minogue (North Bay)",
77 + "site": "https://cbnorthbay.com",
78 + "note": "Recensement ON 2026-08-27 : ~307 fiches (North Bay). RealtyPress/DDF."
79 + }
80 +]
modified data/sources.json +144 −1
@@ -1,5 +1,5 @@
1 1 {
2 − "_comment": "Immo-Ka — Registre des sources (agences de courtage immobilier, province de Québec). Un connecteur par agence. Auteur : Simon-Pierre Boucher — contact@spboucher.ai",
2 + "_comment": "Immo-Ka — Registre des sources (agences de courtage immobilier, Québec + Ontario). Un connecteur par agence. Auteur : Simon-Pierre Boucher — contact@spboucher.ai",
3 3 "sources": [
4 4 {
5 5 "id": "remax_quebec",
@@ -867,6 +867,149 @@
867 867 "status": "actif",
868 868 "type": "agence",
869 869 "note": "Site web « classique » server-rendered, connecteur générique agences_web.py (registre data/web_agencies.json). Découverte via recensement OACIQ 2026-08-24. Fiches Next.js rendues via Scrapfly. OACIQ 2026-08 : 9 courtiers (Montréal). Next.js : fiches rendues via Scrapfly."
870 + },
871 + {
872 + "id": "rp_ag_suttonottawa",
873 + "name": "Sutton Group — Ottawa Realty",
874 + "url": "https://suttonottawa.ca",
875 + "listing_url": "https://suttonottawa.ca/listing/",
876 + "coverage": "Ottawa et l'Est ontarien (board OREB) — ~10 000 fiches DDF",
877 + "connector": "realtypress",
878 + "status": "actif",
879 + "type": "agence",
880 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~10 080 fiches (OREB+). RealtyPress/DDF."
881 + },
882 + {
883 + "id": "rp_ag_helensteam",
884 + "name": "Helen's Team (Kitchener-Waterloo)",
885 + "url": "https://helensteam.ca",
886 + "listing_url": "https://helensteam.ca/listing/",
887 + "coverage": "Kitchener-Waterloo et région (ITSO) — ~9 900 fiches DDF",
888 + "connector": "realtypress",
889 + "status": "actif",
890 + "type": "agence",
891 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~9 910 fiches (Kitchener-Waterloo). RealtyPress/DDF."
892 + },
893 + {
894 + "id": "rp_ag_greybruce",
895 + "name": "Grey Bruce Real Estate",
896 + "url": "https://greybrucerealestate.ca",
897 + "listing_url": "https://greybrucerealestate.ca/listing/",
898 + "coverage": "Grey-Bruce / Georgian Bay (ITSO) — ~8 300 fiches DDF",
899 + "connector": "realtypress",
900 + "status": "actif",
901 + "type": "agence",
902 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~8 268 fiches (Grey-Bruce/Georgian Bay). RealtyPress/DDF. Même feed que collaborativerealestate.ca (fallback)."
903 + },
904 + {
905 + "id": "rp_ag_remaxfinest",
906 + "name": "RE/MAX Finest Realty (Kingston)",
907 + "url": "https://remaxfinestrealty.com",
908 + "listing_url": "https://remaxfinestrealty.com/listing/",
909 + "coverage": "Kingston et région (KAREA) — ~8 200 fiches DDF",
910 + "connector": "realtypress",
911 + "status": "actif",
912 + "type": "agence",
913 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~8 217 fiches (Kingston). RealtyPress/DDF."
914 + },
915 + {
916 + "id": "rp_ag_riouxbaker",
917 + "name": "Rioux Baker Real Estate Team (Collingwood)",
918 + "url": "https://riouxbakerteam.com",
919 + "listing_url": "https://riouxbakerteam.com/listing/",
920 + "coverage": "Collingwood / South Georgian Bay — ~7 700 fiches DDF",
921 + "connector": "realtypress",
922 + "status": "actif",
923 + "type": "agence",
924 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~7 716 fiches (Collingwood/South Georgian Bay). RealtyPress/DDF."
925 + },
926 + {
927 + "id": "rp_ag_countyguys",
928 + "name": "The County Guys (Prince Edward County)",
929 + "url": "https://thecountyguys.com",
930 + "listing_url": "https://thecountyguys.com/listing/",
931 + "coverage": "Prince Edward County / Quinte — ~6 900 fiches DDF",
932 + "connector": "realtypress",
933 + "status": "actif",
934 + "type": "agence",
935 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~6 878 fiches (Prince Edward County/Quinte). RealtyPress/DDF."
936 + },
937 + {
938 + "id": "rp_ag_labrosse",
939 + "name": "Labrosse Real Estate (Ottawa/Orléans)",
940 + "url": "https://labrosserealestate.com",
941 + "listing_url": "https://labrosserealestate.com/listing/",
942 + "coverage": "Ottawa / Orléans (équipe francophone, OREB) — ~6 900 fiches DDF",
943 + "connector": "realtypress",
944 + "status": "actif",
945 + "type": "agence",
946 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~6 873 fiches (Ottawa/Orléans, équipe FRANCOPHONE). RealtyPress/DDF."
947 + },
948 + {
949 + "id": "rp_ag_ryanpattinson",
950 + "name": "Ryan Pattinson (Pembroke/Renfrew)",
951 + "url": "https://ryanpattinson.com",
952 + "listing_url": "https://ryanpattinson.com/listing/",
953 + "coverage": "Pembroke / vallée de l'Outaouais ontarienne — ~6 600 fiches DDF",
954 + "connector": "realtypress",
955 + "status": "actif",
956 + "type": "agence",
957 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~6 601 fiches (Pembroke/vallée de l'Outaouais ON). RealtyPress/DDF."
958 + },
959 + {
960 + "id": "rp_ag_grapevine",
961 + "name": "Grapevine (Ottawa)",
962 + "url": "https://grapevine.ca",
963 + "listing_url": "https://grapevine.ca/listing/",
964 + "coverage": "Ottawa (OREB) — ~6 600 fiches DDF",
965 + "connector": "realtypress",
966 + "status": "actif",
967 + "type": "agence",
968 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~6 550 fiches (Ottawa). RealtyPress/DDF. Suivre les redirections www/apex."
969 + },
970 + {
971 + "id": "rp_ag_rlpheartland",
972 + "name": "Royal LePage Heartland Realty",
973 + "url": "https://rlpheartland.ca",
974 + "listing_url": "https://rlpheartland.ca/listing/",
975 + "coverage": "Midwestern Ontario (Huron-Perth) — ~5 600 fiches DDF",
976 + "connector": "realtypress",
977 + "status": "actif",
978 + "type": "agence",
979 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~5 577 fiches (Midwestern Ontario — Huron/Perth). RealtyPress/DDF."
980 + },
981 + {
982 + "id": "rp_ag_signaturenorth",
983 + "name": "Signature North Realty (Thunder Bay)",
984 + "url": "https://signaturenorthrealty.ca",
985 + "listing_url": "https://signaturenorthrealty.ca/listing/",
986 + "coverage": "Thunder Bay — ~1 000 fiches DDF",
987 + "connector": "realtypress",
988 + "status": "actif",
989 + "type": "agence",
990 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~1 023 fiches (Thunder Bay). RealtyPress/DDF."
991 + },
992 + {
993 + "id": "rp_ag_saultstemarie",
994 + "name": "Sault Ste. Marie Real Estate (Century 21 Choice)",
995 + "url": "https://saultstemarierealestate.com",
996 + "listing_url": "https://saultstemarierealestate.com/listing/",
997 + "coverage": "Sault Ste. Marie — ~860 fiches DDF",
998 + "connector": "realtypress",
999 + "status": "actif",
1000 + "type": "agence",
1001 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~864 fiches (Sault Ste. Marie). RealtyPress/DDF."
1002 + },
1003 + {
1004 + "id": "rp_ag_cbnorthbay",
1005 + "name": "Coldwell Banker Peter Minogue (North Bay)",
1006 + "url": "https://cbnorthbay.com",
1007 + "listing_url": "https://cbnorthbay.com/listing/",
1008 + "coverage": "North Bay / Nipissing — ~300 fiches DDF",
1009 + "connector": "realtypress",
1010 + "status": "actif",
1011 + "type": "agence",
1012 + "note": "Extension ONTARIO 2026-08 — connecteur générique realtypress.py (registre data/ontario_agencies.json). Plugin WordPress RealtyPress branché sur le flux CREA DDF : chaque site expose l'inventaire complet de son board. Infixe _ag_ : external_id ddf<id> partagé entre sites → la dédup masque les doublons inter-sites. Recensement ON 2026-08-27 : ~307 fiches (North Bay). RealtyPress/DDF."
870 1013 }
871 1014 ]
872 1015 }
added immoka/connectors/realtypress.py +292 −0
@@ -0,0 +1,292 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (Québec + Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/realtypress.py : connecteur GÉNÉRIQUE RealtyPress (Ontario)
5 +# RealtyPress = plugin WordPress branché sur le flux CREA DDF ; ~35 sites
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
8 +# de son board (OREB, ITSO, KAREA…) en HTML server-rendered, sans anti-bot :
9 +# un seul parseur couvre quasi toute la province.
10 +#
11 +# - liste : archive /listing/page/N/?posts_per_page=100 (100 cartes/page ;
12 +# ⚠ ?posts_per_page directement sur /listing = 301/vide) ; cartes
13 +# class="rps-property-result" (ruban For sale/For rent, prix, adresse,
14 +# ville, caractéristiques) ;
15 +# - fiche : mur CREA « I Accept The Terms » contourné par le cookie
16 +# `disclaimer=accepted` ; tableaux <strong>Label</strong>/valeur (MLS®
17 +# Number, Property Type, Bedrooms…), description « … (id:NNNNN) »,
18 +# lat/lng JSON-LD, photos ddfcdn.realtor.ca ;
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
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).
23 +# -----------------------------------------------------------------------------
24 +from __future__ import annotations
25 +
26 +import html as _html
27 +import json
28 +import os
29 +import re
30 +import urllib.parse
31 +from pathlib import Path
32 +
33 +from .base import BaseConnector
34 +from . import _detailutil as du
35 +from ..schema import PropertyListing
36 +
37 +REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "ontario_agencies.json"
38 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_RP_DETAIL_LIMIT",
39 + os.environ.get("IMMOKA_DETAIL_LIMIT", "150")))
40 +
41 +_CARD_RE = re.compile(r'<div class="rps-property-result">')
42 +_LINK_RE = re.compile(r'href="(https?://[^"]+/listings?/[^"]+?-(\d{6,10})/?)"')
43 +_RIBBON_RE = re.compile(r'rps-ribbon[^>]*>\s*([^<]+?)\s*<')
44 +_PRICE_RE = re.compile(r'rps-price[^>]*>\s*\$\s*([\d,]+)')
45 +_H4_RE = re.compile(r"<h4>\s*(.*?)\s*</h4>", re.S)
46 +# avec ou sans <strong> selon le thème du site
47 +_CITY_RE = re.compile(r'city-province-postalcode[^>]*>\s*(?:<strong>\s*)?([^<]+?)\s*<', re.S)
48 +_FEAT_RE = re.compile(r'rps-result-feature-label[^>]*>\s*([^<]+?)\s*<')
49 +_CARD_BROKER_RE = re.compile(r'text-muted[^>]*>\s*<small>\s*([^<]+?)\s*(?:<br|</small>)', re.S)
50 +_DDFIMG_RE = re.compile(r'https://ddfcdn\.realtor\.ca/[^")\'\s\\]+')
51 +_ROW_RE = re.compile(r"<td[^>]*>\s*<strong>([^<]{2,45})</strong>\s*</td>\s*"
52 + r"<td[^>]*>(.*?)</td>", re.S)
53 +_DESC_RE = re.compile(r'<!--\s*Description\s*-->\s*<p[^>]*>(.*?)</p>', re.S)
54 +_DESC_RE2 = re.compile(r'<p itemprop="description"[^>]*>(.*?)</p>', re.S)
55 +# ville depuis <title> « 3383 Romeo Street, Greater Sudbury (Valley East), Ontario … »
56 +_TITLE_CITY_RE = re.compile(r",\s*([^,<>|]{2,60}),\s*Ontario\b")
57 +_PRICING_RE = re.compile(r'rps-pricing[^>]*>\s*\$\s*([\d,]+)')
58 +_ID_TAIL_RE = re.compile(r"\s*\(id:\d{4,9}\)\s*$")
59 +_TAG_RE = re.compile(r"<[^>]+>")
60 +_NUM_RE = re.compile(r"[\d,]+(?:\.\d+)?")
61 +_YEAR_RE = re.compile(r"\b(1[6-9]\d{2}|20\d{2})\b")
62 +# territoire couvert (Québec + Ontario) — même boîte que schema.finalize()
63 +_BBOX = (41.6, 63.0, -95.5, -56.0)
64 +
65 +
66 +def _num(s: str) -> float | None:
67 + m = _NUM_RE.search(s or "")
68 + if not m:
69 + return None
70 + try:
71 + return float(m.group(0).replace(",", ""))
72 + except ValueError:
73 + return None
74 +
75 +
76 +class _RealtyPress(BaseConnector):
77 + """Connecteur générique de site RealtyPress (voir data/ontario_agencies.json)."""
78 +
79 + agency_name = ""
80 + site_url = ""
81 + max_pages = 150 # 100 cartes/page → jusqu'à 15 000 fiches par site
82 + request_delay = 0.6
83 +
84 + def fetch(self) -> list[PropertyListing]:
85 + # mur CREA des fiches détail : le cookie suffit (posé pour tout domaine,
86 + # les redirections www/apex restent couvertes)
87 + self.session.cookies.set("disclaimer", "accepted")
88 + by_id: dict[str, PropertyListing] = {}
89 + base = self.site_url.rstrip("/")
90 + dry = 0
91 + for page in range(1, self.max_pages + 1):
92 + url = f"{base}/listing/page/{page}/?posts_per_page=100"
93 + try:
94 + body = self.get(url).text
95 + except Exception:
96 + break
97 + cards = self._cards(body)
98 + if not cards:
99 + break
100 + before = len(by_id)
101 + for card in cards:
102 + self._parse_card(card, by_id)
103 + dry = dry + 1 if len(by_id) == before else 0
104 + if dry >= 2:
105 + break
106 + listings = list(by_id.values())
107 + du.enrich(self, listings, DETAIL_LIMIT, parse_rp_detail, key="v1")
108 + for lst in listings:
109 + # n° MLS du board (fiche détail) — utile à la dédup inter-plateformes
110 + if not lst.mls and lst.details.get("MLS® Number"):
111 + lst.mls = str(lst.details["MLS® Number"])
112 + if not lst.title:
113 + lst.title = ", ".join(filter(None, (lst.address, lst.city))) \
114 + or "Propriété à vendre"
115 + return listings
116 +
117 + def _cards(self, body: str) -> list[str]:
118 + marks = list(_CARD_RE.finditer(body))
119 + return [body[m.start():(marks[i + 1].start() if i + 1 < len(marks)
120 + else m.start() + 6000)]
121 + for i, m in enumerate(marks)]
122 +
123 + def _parse_card(self, card: str, by_id: dict) -> None:
124 + ml = _LINK_RE.search(card)
125 + if not ml:
126 + return
127 + url, ddf = ml.group(1), ml.group(2)
128 + eid = f"ddf{ddf}"
129 + if eid in by_id:
130 + return
131 + mr = _RIBBON_RE.search(card)
132 + ribbon = (mr.group(1) if mr else "").strip().lower()
133 + if "rent" in ribbon or "lease" in ribbon:
134 + return # locations : hors périmètre
135 + lst = PropertyListing(source=self.source_id, external_id=eid, url=url,
136 + region="Ontario", agency=self.agency_name,
137 + broker_name=self.agency_name)
138 + ma = _H4_RE.search(card)
139 + if ma:
140 + lst.address = _html.unescape(_TAG_RE.sub(" ", ma.group(1))).strip()
141 + mc = _CITY_RE.search(card)
142 + if mc:
143 + city = _html.unescape(mc.group(1)).strip().rstrip(",")
144 + city = re.sub(r",?\s*Ontario\b.*$", "", city, flags=re.I)
145 + lst.city = city.split("(")[0].strip()
146 + mp = _PRICE_RE.search(card)
147 + if mp:
148 + lst.price = _num(mp.group(1))
149 + lst.price_label = f"{mp.group(1)} $"
150 + for feat in _FEAT_RE.findall(card):
151 + f = _html.unescape(feat).strip()
152 + low = f.lower()
153 + n = _num(f)
154 + if not n:
155 + continue
156 + if "bedroom" in low:
157 + lst.bedrooms = int(n)
158 + elif "bathroom" in low:
159 + lst.bathrooms = int(n)
160 + elif "sqft" in low or "sq ft" in low or "ft" in low:
161 + lst.area_sqft = n # plage « 1,100 - 1,500 ft² » : borne basse
162 + mbk = _CARD_BROKER_RE.search(card)
163 + if mbk:
164 + lst.broker_name = _html.unescape(mbk.group(1)).strip()[:120]
165 + mi = _DDFIMG_RE.search(card)
166 + if mi:
167 + lst.images = [mi.group(0)]
168 + by_id[lst.external_id] = lst
169 +
170 +
171 +def parse_rp_detail(html: str) -> dict:
172 + """Fiche RealtyPress : tableaux DDF, description, GPS, galerie, courtier."""
173 + out: dict = {}
174 + details: dict = {}
175 +
176 + for lab, val in _ROW_RE.findall(html):
177 + label = _html.unescape(lab).strip().rstrip(":")
178 + value = re.sub(r"\s+", " ", _html.unescape(_TAG_RE.sub(" ", val))).strip()
179 + if label and value and len(value) <= 300:
180 + details.setdefault(label, value)
181 +
182 + def dv(*labels: str) -> str:
183 + for lb in labels:
184 + if details.get(lb):
185 + return details[lb]
186 + return ""
187 +
188 + b = _num(dv("Bedrooms Total", "Bedrooms", "Bedrooms Above Ground"))
189 + if b is not None and 0 < b <= 30:
190 + out["bedrooms"] = int(b)
191 + b = _num(dv("Bathroom Total", "Bathrooms"))
192 + if b is not None and 0 < b <= 30:
193 + out["bathrooms"] = int(b)
194 + b = _num(dv("Half Bath Total"))
195 + if b is not None and 0 < b <= 10:
196 + out["powder_rooms"] = int(b)
197 + my = _YEAR_RE.search(dv("Constructed Date", "Construction Year", "Age"))
198 + if my:
199 + out["year_built"] = int(my.group(1))
200 + si = dv("Size Interior")
201 + if si and "sqft" in si.lower().replace(" ", ""):
202 + a = _num(si) # « 7,901 Sqft » / « 1200 - 1399 sqft »
203 + if a and a >= 100:
204 + out["area_sqft"] = a
205 + pt = dv("Property Type", "Building Type", "Type")
206 + if pt:
207 + out["property_type"] = pt # anglais DDF — normalisé par finalize()
208 + sec = dv("Neigbourhood", "Neighbourhood", "Community Name")
209 + if sec:
210 + out["sector"] = sec
211 +
212 + mp = _PRICING_RE.search(html)
213 + if mp:
214 + out["price"] = _num(mp.group(1))
215 + out["price_label"] = f"{mp.group(1)} $"
216 +
217 + md = _DESC_RE.search(html) or _DESC_RE2.search(html)
218 + if md:
219 + desc = _html.unescape(_TAG_RE.sub(" ", md.group(1)))
220 + desc = re.sub(r"\s+", " ", desc).strip()
221 + out["description"] = _ID_TAIL_RE.sub("", desc)[:6000]
222 +
223 + mt = re.search(r"<title>(.*?)</title>", html, re.S)
224 + if mt:
225 + mc = _TITLE_CITY_RE.search(_html.unescape(mt.group(1)))
226 + if mc:
227 + # « Greater Sudbury (Valley East) » : le secteur part dans sector
228 + city = mc.group(1).split("(")[0].strip()
229 + if city and not any(c.isdigit() for c in city):
230 + out["city"] = city
231 + msec = re.search(r"\(([^)]{2,45})\)", mc.group(1))
232 + if msec and "sector" not in out:
233 + out["sector"] = msec.group(1).strip()
234 +
235 + for n in du.ld_nodes(html):
236 + t = n.get("@type")
237 + types = set(t if isinstance(t, list) else [t])
238 + geo = n.get("geo") or {}
239 + if isinstance(geo, dict) and "lat" not in out:
240 + try:
241 + lat, lng = float(geo["latitude"]), float(geo["longitude"])
242 + if _BBOX[0] <= lat <= _BBOX[1] and _BBOX[2] <= lng <= _BBOX[3]:
243 + out["lat"], out["lng"] = lat, lng
244 + except (KeyError, TypeError, ValueError):
245 + pass
246 + if types & {"RealEstateAgent", "Organization"}:
247 + name = str(n.get("name") or "").strip()
248 + if name and "broker_name" not in out:
249 + out["broker_name"] = name[:120]
250 + tel = str(n.get("telephone") or "").strip()
251 + if tel and "broker_phone" not in out:
252 + out["broker_phone"] = tel[:40]
253 + if "lat" not in out:
254 + m = re.search(r'"latitude"\s*:\s*"?(-?\d{1,2}\.\d{3,})"?\s*,\s*'
255 + r'"longitude"\s*:\s*"?(-?\d{2,3}\.\d{3,})"?', html)
256 + if m:
257 + lat, lng = float(m.group(1)), float(m.group(2))
258 + if _BBOX[0] <= lat <= _BBOX[1] and _BBOX[2] <= lng <= _BBOX[3]:
259 + out["lat"], out["lng"] = lat, lng
260 +
261 + gal = [u for u in dict.fromkeys(_DDFIMG_RE.findall(html))
262 + if "/listings/" in u.lower()]
263 + if gal:
264 + out["images"] = gal[:60]
265 +
266 + if details:
267 + out["details"] = details
268 + return out
269 +
270 +
271 +def _load() -> list[dict]:
272 + try:
273 + return json.loads(REGISTRY.read_text(encoding="utf-8"))
274 + except Exception:
275 + return []
276 +
277 +
278 +# Génère une classe par site du registre.
279 +for _ag in _load():
280 + if not all(_ag.get(k) for k in ("id", "site")):
281 + continue
282 + _sid = _ag["id"]
283 + globals()[f"REALTYPRESS_{_sid.upper()}"] = type(
284 + "RealtyPress" + "".join(p.title() for p in _sid.split("_")),
285 + (_RealtyPress,),
286 + {
287 + "source_id": _sid,
288 + "site_url": _ag["site"],
289 + "agency_name": _ag.get("name", _sid),
290 + "max_pages": int(_ag.get("max_pages", 150)),
291 + },
292 + )
modified immoka/geocode.py +34 −26
@@ -45,8 +45,11 @@ RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours
45 45 # Bounding boxes (lat_min, lat_max, lng_min, lng_max)
46 46 _BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin
47 47 # garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord
48 −# et la Gaspésie (expansion provinciale) — même boîte que schema.finalize()
48 +# et la Gaspésie (expansion provinciale)
49 49 _BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00)
50 +# territoire complet Groupe Ka : Québec + Ontario (Windsor -83°, Kenora -94.5°)
51 +# — même boîte que schema.finalize()
52 +_BBOX_KA = (41.60, 63.00, -95.50, -56.00)
50 53
51 54 _VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures",
52 55 "l'ancienne-lorette", "ancienne-lorette"}
@@ -65,7 +68,7 @@ CLUSTER_MIN = 3 # une grappe de fiches devient une référence de confiance d
65 68
66 69 def _bbox_for(city: str) -> tuple[float, float, float, float]:
67 70 key = strip_accents((city or "").strip().lower())
68 − return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC
71 + return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_KA
69 72
70 73
71 74 def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool:
@@ -264,31 +267,35 @@ class Geocoder:
264 267 return None
265 268 if not allow_api:
266 269 return None # pas de mémoire : on réessaiera quand l'API sera permise
267 − self.ville_api_calls += 1
268 − wait = REQUEST_DELAY - (time.time() - self._last)
269 − if wait > 0:
270 − time.sleep(wait)
271 270 v = None
272 − try:
273 − resp = self.session.get(NOMINATIM_URL, params={
274 − "q": f"{city}, Québec, Canada", "format": "jsonv2", "limit": 1,
275 − "countrycodes": "ca", "addressdetails": 1}, timeout=20)
276 − self._last = time.time()
277 − resp.raise_for_status()
278 − hits = resp.json()
279 − except Exception:
280 − self._last = time.time()
281 − return None # erreur réseau : ne pas cacher un échec définitif
282 − if hits:
271 + # priorité au Québec ; à défaut, l'Ontario (extension immo-ka)
272 + for prov in ("Québec", "Ontario"):
273 + self.ville_api_calls += 1
274 + wait = REQUEST_DELAY - (time.time() - self._last)
275 + if wait > 0:
276 + time.sleep(wait)
283 277 try:
284 − lat, lng = float(hits[0]["lat"]), float(hits[0]["lon"])
285 − if _in_bbox(lat, lng, _BBOX_SUD_QC):
286 − a = hits[0].get("address") or {}
287 − muni = (a.get("city") or a.get("town") or a.get("village")
288 − or a.get("municipality"))
289 − v = {"lat": lat, "lng": lng, "muni": muni}
290 − except (KeyError, ValueError, TypeError):
291 − v = None
278 + resp = self.session.get(NOMINATIM_URL, params={
279 + "q": f"{city}, {prov}, Canada", "format": "jsonv2", "limit": 1,
280 + "countrycodes": "ca", "addressdetails": 1}, timeout=20)
281 + self._last = time.time()
282 + resp.raise_for_status()
283 + hits = resp.json()
284 + except Exception:
285 + self._last = time.time()
286 + return None # erreur réseau : ne pas cacher un échec définitif
287 + if hits:
288 + try:
289 + lat, lng = float(hits[0]["lat"]), float(hits[0]["lon"])
290 + if _in_bbox(lat, lng, _BBOX_KA):
291 + a = hits[0].get("address") or {}
292 + muni = (a.get("city") or a.get("town") or a.get("village")
293 + or a.get("municipality"))
294 + v = {"lat": lat, "lng": lng, "muni": muni}
295 + except (KeyError, ValueError, TypeError):
296 + v = None
297 + if v:
298 + break
292 299 self.con.execute(
293 300 "INSERT INTO geocode_cache (address, lat, lng, muni, provider, failed, ts)"
294 301 " VALUES (?,?,?,?,?,?,?) ON CONFLICT(address) DO UPDATE SET"
@@ -474,7 +481,8 @@ def run_batch(limit: int | None = None) -> dict:
474 481 rows = con.execute(
475 482 """SELECT uid, address, city FROM listings
476 483 WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0
477 − AND dup_hidden=0 ORDER BY address""").fetchall()
484 + AND dup_hidden=0 AND COALESCE(region,'')<>'Ontario'
485 + ORDER BY address""").fetchall()
478 486 # 1 entrée par immeuble (adresse normalisée) ; on saute les échecs en cache
479 487 uniq: dict[str, dict] = {}
480 488 for r in rows:
modified immoka/normalize.py +16 −10
@@ -193,21 +193,26 @@ _TYPE_MAP = [
193 193 # (mots-clés dans le texte source normalisé, type canonique)
194 194 (("plain-pied", "bungalow",), "Maison"),
195 195 (("maison a etages", "a etage", "deux etages", "cottage",), "Maison"),
196 − (("maison mobile", "unimodulaire",), "Maison mobile"),
197 − (("jumele", "semi-detache", "semi detache",), "Jumelé"),
198 − (("maison de ville", "townhouse", "en rangee",), "Maison de ville"),
199 − (("unifamiliale", "maison", "house", "residence", "split",), "Maison"),
196 + (("maison mobile", "unimodulaire", "mobile home", "manufactured home",),
197 + "Maison mobile"),
198 + (("jumele", "semi-detache", "semi detache", "semi-detached", "semi detached",),
199 + "Jumelé"),
200 + (("maison de ville", "townhouse", "en rangee", "row house", "row / town",),
201 + "Maison de ville"),
202 + (("unifamiliale", "maison", "house", "residence", "split", "detached",
203 + "single family",), "Maison"),
200 204 (("condo", "copropriete", "appartement", "apartment", "loft", "penthouse",
201 205 "studio",), "Condo"),
202 206 (("duplex",), "Duplex"),
203 207 (("triplex",), "Triplex"),
204 208 (("quadruplex", "quintuplex", "multiplex", "multilogement", "multi-logement",
205 − "immeuble a revenus", "revenus",), "Multiplex"),
206 − (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau",),
207 − "Chalet"),
209 + "immeuble a revenus", "revenus", "multi-family", "multi family",
210 + "fourplex",), "Multiplex"),
211 + (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau",
212 + "recreational",), "Chalet"),
208 213 (("terre", "terrain", "lot ", "land",), "Terrain"),
209 − (("ferme", "fermette", "agricole", "agriculture", "hobby farm",),
210 − "Fermette/Agricole"),
214 + (("ferme", "fermette", "agricole", "agriculture", "hobby farm", "farm",
215 + "acreage",), "Fermette/Agricole"),
211 216 # vocabulaire des fiches enrichies : « 4 logements », « propriété à revenu »
212 217 (("logements", "logement/", "unites et +", "revenu",), "Multiplex"),
213 218 (("bi generation", "bi-generation", "bigeneration", "intergeneration",),
@@ -215,7 +220,8 @@ _TYPE_MAP = [
215 220 (("domaine et villa", "villa", "domaine",), "Maison"),
216 221 (("commercial", "commerce", "industriel", "industrie", "bureau", "local",
217 222 "entreprise", "batisse", "restaurant", "depanneur", "hotel", "motel",
218 − "garage/", "concessionnaire", "coiffure", "esthetique", "camping",),
223 + "garage/", "concessionnaire", "coiffure", "esthetique", "camping",
224 + "retail", "office", "industrial", "warehouse", "business",),
219 225 "Commercial"),
220 226 ]
221 227
modified immoka/schema.py +3 −3
@@ -134,10 +134,10 @@ class PropertyListing:
134 134 if not self.agency:
135 135 self.agency = self.broker_name
136 136
137 − # coordonnées fournies par la source : rejeter tout point hors de la
138 − # province (lat/lng inversés, 0/0, coquilles)
137 + # coordonnées fournies par la source : rejeter tout point hors du
138 + # territoire couvert — Québec + Ontario (lat/lng inversés, 0/0, coquilles)
139 139 if self.lat is not None and self.lng is not None:
140 − if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):
140 + if not (41.6 <= self.lat <= 63.0 and -95.5 <= self.lng <= -56.0):
141 141 self.lat = self.lng = None
142 142
143 143 return self
144 144