SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

feat(ontario): vague 2 — 45 nouvelles sources ON (+2 964 annonces, ~5 800 ON au total)

7 rsgw_* (passerelle website-gateway : sterlingkaramar, drewlo, fitzrovia,
bgo, panoramic, drakepm, arcanos), 12 lift_*, 5 rc_*, 11 bld_* (Buildium),
1 apf_* (AppFolio), 9 sur mesure (starlight, medallion, kggroup,
qresidential, riocan, sifton, accommod8u, greenrock, westdale).
Anti-doublons : exclude_import_ids (Greenrock via sterlingkaramar) +
sterlingkaramar.com dans _SKIP_DOMAINS de starlight. sources.json : 503
entrées, champ connector harmonisé. Tout gaté LOUKA_ONTARIO=1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed 29 days ago (Aug 27, 2026) parent 520c579

19 changed files +4,901 −48

added data/appfolio_clients.json +16 −0
@@ -0,0 +1,16 @@
1 +{
2 + "_comment": "Registre des clients AppFolio (<sous-domaine>.appfolio.com/listings — HTML rendu serveur, cartes .js-listing-item, page unique « Showing all available listings ») — longue traîne Ontario 2026-08. Consommé par louka/connectors/appfolio.py qui génère un connecteur apf_<id> par entrée 'valide'. CONSTAT de la recherche du 2026-08-27 (Serper site:appfolio.com + scan des 273 sites de gestion-immobiliere-ontario.md) : AppFolio est RARE en Ontario — la longue traîne ON est massivement sur Buildium (voir buildium_clients.json). REJETS vérifiés (ne pas réessayer) : AG Secure (agsecure.ca) et Strano (stranoproperties.ca) ont migré sur Wix, plus aucun lien AppFolio même en rendu JS Scrapfly ; rpmcan07 (RPM Barrie, cité par un rapport simcoe.ca 2021) éteint, rpmcan01-15 balayés = 0 client ON ; homonymes US écartés (wpd=Chicago, skylineprops=ND, stonebrown=VT, northbay=Californie, marda* sans portail public). Les adresses AppFolio étant en texte libre, le connecteur filtre par code postal canadien (K/L/M/N/P = ON, G/H/J = QC).",
3 + "updated": "2026-08-27",
4 + "clients": [
5 + {
6 + "id": "fusepm",
7 + "name": "Fuse Property Management",
8 + "site": "https://fusepm.ca",
9 + "subdomain": "fusepm",
10 + "regions": ["Barrie", "Orillia", "Oro-Medonte", "Simcoe"],
11 + "status": "valide",
12 + "listings_on": 25,
13 + "notes": "25 annonces ON le 2026-08-27 (Barrie/Orillia/Oro-Medonte), page unique sans pagination ; sous-domaine trouvé dans fusepm.ca"
14 + }
15 + ]
16 +}
added data/buildium_clients.json +116 −0
@@ -0,0 +1,116 @@
1 +{
2 + "_comment": "Registre des clients Buildium (<sous-domaine>.managebuilding.com/Resident/public/rentals) — longue traîne Ontario 2026-08. Consommé par louka/connectors/buildium.py qui génère un connecteur bld_<id> par entrée 'valide'. Sous-domaines découverts via Serper (site:managebuilding.com <nom>) ou le site vitrine du gestionnaire, puis VALIDÉS EN LIVE le 2026-08-27 (compte d'annonces ON résidentielles ; seuil ≥ 3). Deux mises en page servies par Buildium : « moderne » (/Resident/public/rentals, cartes a.featured-listing rendues serveur) et « héritée » (redirection 302 vers PublicPages/ApartmentSearch.aspx, annonces embarquées dans le viewmodel DotVVM __dot_viewmodel_root — AUCUN rendu JS requis dans les deux cas). Le connecteur détecte la mise en page automatiquement. REJETS du 2026-08-27 (ne pas re-tester avant un moment) : stsproperties (Shore to Slope, Collingwood) et lakeviewrealtyinc (Lakeview, Orillia) = 0 annonce en ligne (« check back ») ; keiltyresidential (Keilty, Cobourg), legacylogin (Legacy/LPRM, Thunder Bay), mgpropmgt (MG, Timmins), davispropertiesinc (Davis, Kirkland Lake) = pages publiques désactivées (302 vers /Resident/apps/portal/login) ; douglasfirpm (lié depuis prestigiousplacesudbury.com) = 302 vers /Resident/Public sans annonces.",
3 + "updated": "2026-08-27",
4 + "clients": [
5 + {
6 + "id": "richmond",
7 + "name": "Richmond Property Management",
8 + "site": "https://richmondpm.ca",
9 + "subdomain": "richmondmanagement",
10 + "regions": ["Windsor"],
11 + "status": "valide",
12 + "listings_on": 52,
13 + "notes": "52 annonces ON le 2026-08-27, toutes Windsor (MultiFamily 47, SingleFamily 3, CondoTownhome 2) ; mise en page moderne"
14 + },
15 + {
16 + "id": "dreampm",
17 + "name": "Dream Property Management",
18 + "site": "https://rentalsdreampm.com",
19 + "subdomain": "dreampm",
20 + "regions": ["Sarnia"],
21 + "status": "valide",
22 + "listings_on": 9,
23 + "notes": "9 annonces ON (Sarnia) le 2026-08-27 ; mise en page moderne"
24 + },
25 + {
26 + "id": "grem",
27 + "name": "Guardian Real Estate Management (GREM)",
28 + "site": "https://grem.ca",
29 + "subdomain": "guardianrealestatemanagement",
30 + "regions": ["Sarnia", "West Nipissing"],
31 + "status": "valide",
32 + "listings_on": 44,
33 + "notes": "44 annonces ON le 2026-08-27 (Sarnia surtout) ; MISE EN PAGE HÉRITÉE (302 vers ApartmentSearch.aspx, viewmodel DotVVM embarqué avec city/state/postalcode/description complète)"
34 + },
35 + {
36 + "id": "elite",
37 + "name": "Elite Property Group",
38 + "site": "https://elitepropertygroup.org",
39 + "subdomain": "elitepropertygroup",
40 + "regions": ["Sarnia"],
41 + "status": "valide",
42 + "listings_on": 34,
43 + "notes": "34 annonces ON (Sarnia) le 2026-08-27 ; mise en page moderne"
44 + },
45 + {
46 + "id": "paragon",
47 + "name": "Paragon Property Management & Maintenance",
48 + "site": "https://paragonproperty.ca",
49 + "subdomain": "paragonpropertymgt",
50 + "regions": ["Chatham-Kent"],
51 + "status": "valide",
52 + "listings_on": 34,
53 + "notes": "36 cartes ON le 2026-08-27 dont 2 Storage (filtrées par le connecteur) = 34 résidentielles ; attention : ne pas confondre avec paragonproperty.managebuilding.com (Brookings, Dakota du Sud)"
54 + },
55 + {
56 + "id": "panadew",
57 + "name": "Panadew Property Management",
58 + "site": "https://panadew.ca",
59 + "subdomain": "panadew",
60 + "regions": ["Kingston"],
61 + "status": "valide",
62 + "listings_on": 7,
63 + "notes": "8 cartes ON le 2026-08-27 dont 1 ParkingSpace (filtrée) = 7 résidentielles (Kingston) ; mise en page moderne"
64 + },
65 + {
66 + "id": "tag",
67 + "name": "TAG Property Management",
68 + "site": "https://tagpropertymanagement1.managebuilding.com/Resident/public/home",
69 + "subdomain": "tagpropertymanagement1",
70 + "regions": ["Cobourg", "Port Hope"],
71 + "status": "valide",
72 + "listings_on": 10,
73 + "notes": "10 annonces ON (Cobourg/Port Hope) le 2026-08-27 ; portail Buildium = seul site public ; mise en page moderne"
74 + },
75 + {
76 + "id": "genedco",
77 + "name": "Genedco Property Management (GRANITE)",
78 + "site": "https://genedco.ca",
79 + "subdomain": "genedco",
80 + "regions": ["Cobourg", "Port Hope", "Peterborough"],
81 + "status": "valide",
82 + "listings_on": 9,
83 + "notes": "9 annonces ON le 2026-08-27 ; mise en page moderne"
84 + },
85 + {
86 + "id": "rentcorp",
87 + "name": "RentCorp Property Management",
88 + "site": "https://www.rentcorp.ca",
89 + "subdomain": "rentcorppropertymanagement",
90 + "regions": ["Waterloo", "North Bay", "London"],
91 + "status": "valide",
92 + "listings_on": 26,
93 + "notes": "26 annonces ON le 2026-08-27 (Waterloo surtout, incl. chambres étudiantes 575-700 $ = longue durée, on garde) ; mise en page moderne"
94 + },
95 + {
96 + "id": "sleepwell",
97 + "name": "Sleepwell Property Management",
98 + "site": "https://www.sleepwellmanagement.com",
99 + "subdomain": "sleepwellpropertymanagement",
100 + "regions": ["Ottawa"],
101 + "status": "valide",
102 + "listings_on": 130,
103 + "notes": "130 annonces ON le 2026-08-27 (Ottawa — MultiFamily 121, SingleFamily 6, CondoTownhome 3) : plus gros client Buildium du registre, trouvé via Serper pendant la validation ; mise en page moderne"
104 + },
105 + {
106 + "id": "athena",
107 + "name": "Athena Property Management",
108 + "site": "https://athenapropertymanagementinc1.managebuilding.com/Resident/public/home",
109 + "subdomain": "athenapropertymanagementinc1",
110 + "regions": ["Hamilton", "St. Catharines"],
111 + "status": "valide",
112 + "listings_on": 29,
113 + "notes": "29 annonces ON le 2026-08-27 (Hamilton surtout) ; découvert via Serper pendant la validation ; mise en page moderne"
114 + }
115 + ]
116 +}
modified data/liftsystem_clients.json +374 −25
@@ -1,6 +1,6 @@
1 1 {
2 2 "_comment": "Registre des clients Rentsync/LiftSystem (api.theliftsystem.com/v2/search) — expansion Ontario 2026-08. Consommé par louka/connectors/liftsystem.py qui génère un connecteur lift_<id> par entrée validée. client_id découverts en live (div.search-data data-client-id, EnhancedSearch.config ou /scripts/main.js de chaque site) puis validés contre l'API le 2026-08-26 (token universel sswpREkUtyeYjeoahA2i). status: 'valide' = API confirmée ; 'a_verifier' = site sur la NOUVELLE plateforme Rentsync (website-gateway.rentsync.com, Vue) — pas de flux LiftSystem v2, connecteur à part à écrire. EXCLUS (connecteurs dédiés existants) : Minto, Hazelview, Centurion/CP Living, Summit, MetCap, InterRent. NB : properties_on = propriétés ON brutes du flux ; le connecteur filtre ensuite les types non résidentiels (Office/Retail/Industrial/Construction…) — résidentiel ON/QC réel au 2026-08-26 : greenwin 116, park 64, williamsmcdaniel 167, briarlane 116, dms 84, signet 108, shelter 25, york 76, terracorp 33, rentsarnia 69, firstnorth 26, tvm 30, urbandale 8 = 922 propriétés.",
3 − "updated": "2026-08-26",
3 + "updated": "2026-08-27",
4 4 "clients": [
5 5 {
6 6 "id": "greenwin",
@@ -8,7 +8,13 @@
8 8 "site": "https://www.greenwin.ca",
9 9 "client_id": 2,
10 10 "listing_url": "https://www.greenwin.ca/apartments-for-rent/cities/toronto",
11 − "regions": ["Toronto/GTA", "Kitchener", "Hamilton", "Brampton", "Guelph"],
11 + "regions": [
12 + "Toronto/GTA",
13 + "Kitchener",
14 + "Hamilton",
15 + "Brampton",
16 + "Guelph"
17 + ],
12 18 "status": "valide",
13 19 "properties_on": 164,
14 20 "notes": "~20 000 unités gérées ; 164 propriétés ON au flux (Toronto 76, Kitchener 16, North York 14, Hamilton 13)"
@@ -19,7 +25,14 @@
19 25 "site": "https://www.parkproperty.ca",
20 26 "client_id": 65,
21 27 "listing_url": "https://www.parkproperty.ca/apartments-for-rent",
22 − "regions": ["Toronto/GTA", "Stratford", "Waterloo", "Cambridge", "Burlington", "London"],
28 + "regions": [
29 + "Toronto/GTA",
30 + "Stratford",
31 + "Waterloo",
32 + "Cambridge",
33 + "Burlington",
34 + "London"
35 + ],
23 36 "status": "valide",
24 37 "properties_on": 64,
25 38 "notes": "64 propriétés ON (Stratford 13, Toronto 13, Scarborough 9, Waterloo 7)"
@@ -30,7 +43,15 @@
30 43 "site": "https://www.williamsandmcdaniel.com",
31 44 "client_id": 12,
32 45 "listing_url": "https://www.williamsandmcdaniel.com/apartments",
33 − "regions": ["Guelph", "Sault Ste. Marie", "Fergus", "Midland", "Kitchener", "Hamilton", "Stratford"],
46 + "regions": [
47 + "Guelph",
48 + "Sault Ste. Marie",
49 + "Fergus",
50 + "Midland",
51 + "Kitchener",
52 + "Hamilton",
53 + "Stratford"
54 + ],
34 55 "status": "valide",
35 56 "properties_on": 172,
36 57 "notes": "177 propriétés au flux dont 172 ON + 5 AB (filtrées) ; Guelph 54, Sault Ste. Marie 30, Fergus 20"
@@ -41,7 +62,13 @@
41 62 "site": "https://www.briarlane.ca",
42 63 "client_id": 470,
43 64 "listing_url": "https://www.briarlane.ca/apartments-for-rent",
44 − "regions": ["London", "Toronto/GTA", "Markham/York", "Brampton", "Mississauga"],
65 + "regions": [
66 + "London",
67 + "Toronto/GTA",
68 + "Markham/York",
69 + "Brampton",
70 + "Mississauga"
71 + ],
45 72 "status": "valide",
46 73 "properties_on": 203,
47 74 "notes": "204 propriétés dont 203 ON + 1 SK (filtrée) ; London 52, Toronto 43, North York 34 — rare couverture Vaughan/York"
@@ -52,7 +79,14 @@
52 79 "site": "https://www.dmsproperty.com",
53 80 "client_id": 122,
54 81 "listing_url": "https://www.dmsproperty.com/apartments-for-rent",
55 − "regions": ["Toronto/GTA", "Hamilton", "Kingston", "Barrie", "Richmond Hill", "Welland"],
82 + "regions": [
83 + "Toronto/GTA",
84 + "Hamilton",
85 + "Kingston",
86 + "Barrie",
87 + "Richmond Hill",
88 + "Welland"
89 + ],
56 90 "status": "valide",
57 91 "properties_on": 97,
58 92 "notes": "97 propriétés ON (Toronto 30, Hamilton 15, North York 14, Kingston 7)"
@@ -63,7 +97,13 @@
63 97 "site": "https://www.signetgroup.ca",
64 98 "client_id": 84,
65 99 "listing_url": "https://www.signetgroup.ca/apartments",
66 − "regions": ["Toronto", "Mississauga", "Kitchener", "Cambridge", "Stratford"],
100 + "regions": [
101 + "Toronto",
102 + "Mississauga",
103 + "Kitchener",
104 + "Cambridge",
105 + "Stratford"
106 + ],
67 107 "status": "valide",
68 108 "properties_on": 131,
69 109 "notes": "131 propriétés ON (Toronto 71, Mississauga 12, Kitchener 7, Cambridge 7)"
@@ -74,10 +114,19 @@
74 114 "site": "https://www.scpl.com",
75 115 "client_id": 407,
76 116 "listing_url": "https://www.scpl.com/residential-rental",
77 − "regions": ["Toronto", "Ottawa", "Belleville", "Thunder Bay", "Orillia"],
117 + "regions": [
118 + "Toronto",
119 + "Ottawa",
120 + "Belleville",
121 + "Thunder Bay",
122 + "Orillia"
123 + ],
78 124 "status": "valide",
79 125 "properties_on": 26,
80 − "permalink_sub": ["/images/", "/residential-rental/"],
126 + "permalink_sub": [
127 + "/images/",
128 + "/residential-rental/"
129 + ],
81 130 "notes": "87 propriétés au flux, pancanadien : 26 ON seulement (le connecteur filtre MB 37 / AB 22 / SK / ND) ; client_id trouvé sur les fiches propriété (pas la page villes) ; permaliens API cassés (/images/<slug> → 404), corrigés via permalink_sub"
82 131 },
83 132 {
@@ -86,7 +135,14 @@
86 135 "site": "https://www.yorkproperty.ca",
87 136 "client_id": 71,
88 137 "listing_url": "https://www.yorkproperty.ca/apartments-for-rent/london",
89 − "regions": ["Windsor", "London", "Sarnia", "Brantford", "Woodstock", "sud-ouest ON"],
138 + "regions": [
139 + "Windsor",
140 + "London",
141 + "Sarnia",
142 + "Brantford",
143 + "Woodstock",
144 + "sud-ouest ON"
145 + ],
90 146 "status": "valide",
91 147 "properties_on": 77,
92 148 "notes": "77 propriétés ON (Windsor 32, London 23, Sarnia 7) ; client_id via EnhancedSearch.config.client.id = 71 sur les pages villes"
@@ -97,7 +153,12 @@
97 153 "site": "https://terracorp.ca",
98 154 "client_id": 684,
99 155 "listing_url": "https://terracorp.ca/apartments-for-rent",
100 − "regions": ["London", "Windsor", "Kitchener", "Owen Sound"],
156 + "regions": [
157 + "London",
158 + "Windsor",
159 + "Kitchener",
160 + "Owen Sound"
161 + ],
101 162 "status": "valide",
102 163 "properties_on": 33,
103 164 "notes": "33 propriétés ON (London 19, Windsor 3, Kitchener 3)"
@@ -108,7 +169,10 @@
108 169 "site": "https://rentsarnia.com",
109 170 "client_id": 115,
110 171 "listing_url": "https://rentsarnia.com/",
111 − "regions": ["Sarnia", "Corunna"],
172 + "regions": [
173 + "Sarnia",
174 + "Corunna"
175 + ],
112 176 "status": "valide",
113 177 "properties_on": 70,
114 178 "notes": "70 propriétés ON (Sarnia 67, Corunna 3) ; portail dédié de Mutual PM"
@@ -119,7 +183,12 @@
119 183 "site": "https://firstnorthpm.com",
120 184 "client_id": 558,
121 185 "listing_url": "https://firstnorthpm.com/apartments-for-rent",
122 − "regions": ["Sudbury", "North Bay", "Timmins", "Nord de l'Ontario"],
186 + "regions": [
187 + "Sudbury",
188 + "North Bay",
189 + "Timmins",
190 + "Nord de l'Ontario"
191 + ],
123 192 "status": "valide",
124 193 "properties_on": 27,
125 194 "notes": "28 propriétés dont 27 ON (Sudbury 16, North Bay 5, Timmins 4) + 1 sans province (filtrée)"
@@ -130,7 +199,12 @@
130 199 "site": "https://www.tvm.ca",
131 200 "client_id": 359,
132 201 "listing_url": "https://www.tvm.ca/apartments-for-rent",
133 − "regions": ["Peterborough", "Cobourg", "Toronto", "Port Hope"],
202 + "regions": [
203 + "Peterborough",
204 + "Cobourg",
205 + "Toronto",
206 + "Port Hope"
207 + ],
134 208 "status": "valide",
135 209 "properties_on": 47,
136 210 "notes": "47 propriétés ON (Peterborough 33, Cobourg 6, Toronto 5)"
@@ -141,7 +215,11 @@
141 215 "site": "https://urbandalecorporation.com",
142 216 "client_id": 307,
143 217 "listing_url": "https://urbandalecorporation.com/residential-rentals",
144 − "regions": ["Ottawa", "Gloucester", "Riverside South"],
218 + "regions": [
219 + "Ottawa",
220 + "Gloucester",
221 + "Riverside South"
222 + ],
145 223 "status": "valide",
146 224 "properties_on": 24,
147 225 "notes": "24 propriétés ON (Ottawa 23, Gloucester 1) ; urbandale.com = volet construction, le locatif est sur urbandalecorporation.com"
@@ -152,9 +230,15 @@
152 230 "site": "https://www.sterlingkaramar.com",
153 231 "client_id": null,
154 232 "listing_url": "https://www.sterlingkaramar.com/apartments-for-rent",
155 − "regions": ["Toronto/GTA", "Mississauga", "Richmond Hill", "Oakville", "Oshawa"],
156 − "status": "a_verifier",
157 − "notes": "NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/sterlingkaramar, bundles Vue + website-gateway.rentsync.com) — aucun flux LiftSystem v2 ; ~15 000 unités, nécessite un connecteur website-gateway dédié"
233 + "regions": [
234 + "Toronto/GTA",
235 + "Mississauga",
236 + "Richmond Hill",
237 + "Oakville",
238 + "Oshawa"
239 + ],
240 + "status": "migre_rentsyncgw",
241 + "notes": "NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/sterlingkaramar, bundles Vue + website-gateway.rentsync.com) — aucun flux LiftSystem v2 ; ~15 000 unités, nécessite un connecteur website-gateway dédié ; FAIT 2026-08-27 → couvert par rsgw_sterlingkaramar (rentsyncgw_clients.json)"
158 242 },
159 243 {
160 244 "id": "panoramic",
@@ -162,9 +246,13 @@
162 246 "site": "https://www.panoramicproperties.ca",
163 247 "client_id": null,
164 248 "listing_url": "https://www.panoramicproperties.ca/apartments-for-rent",
165 − "regions": ["Niagara", "Oshawa", "Sudbury"],
166 − "status": "a_verifier",
167 − "notes": "NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/panoramic_properties, Vue) — aucun flux LiftSystem v2 exposé"
249 + "regions": [
250 + "Niagara",
251 + "Oshawa",
252 + "Sudbury"
253 + ],
254 + "status": "migre_rentsyncgw",
255 + "notes": "NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/panoramic_properties, Vue) — aucun flux LiftSystem v2 exposé ; FAIT 2026-08-27 → couvert par rsgw_panoramic (rentsyncgw_clients.json)"
168 256 },
169 257 {
170 258 "id": "drewlo",
@@ -172,9 +260,270 @@
172 260 "site": "https://www.drewloholdings.com",
173 261 "client_id": null,
174 262 "listing_url": "https://www.drewloholdings.com/apartments-for-rent",
175 − "regions": ["London", "Kitchener", "Burlington", "Sarnia", "Woodstock"],
176 − "status": "a_verifier",
177 − "notes": "NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/drewlo_rebuild, Vue) — aucun flux LiftSystem v2 exposé ; ~10 000 unités, gros morceau à récupérer via website-gateway"
263 + "regions": [
264 + "London",
265 + "Kitchener",
266 + "Burlington",
267 + "Sarnia",
268 + "Woodstock"
269 + ],
270 + "status": "migre_rentsyncgw",
271 + "notes": "NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/drewlo_rebuild, Vue) — aucun flux LiftSystem v2 exposé ; ~10 000 unités, gros morceau à récupérer via website-gateway ; FAIT 2026-08-27 → couvert par rsgw_drewlo (rentsyncgw_clients.json)"
272 + },
273 + {
274 + "id": "browngroup",
275 + "name": "The Brown Group of Companies",
276 + "site": "https://thebrowngroup.net",
277 + "client_id": 560,
278 + "listing_url": "https://thebrowngroup.net/properties",
279 + "regions": [
280 + "Toronto/GTA",
281 + "North York",
282 + "Etobicoke",
283 + "Brampton",
284 + "St. Catharines",
285 + "Cambridge"
286 + ],
287 + "status": "valide",
288 + "properties_on": 28,
289 + "notes": "Vague 2 (validé 2026-08-27) : 29 propriétés au flux dont 28 ON + 1 FL (filtrée) ; 21 résidentielles ON après filtre types (Toronto 7, North York 6, Etobicoke 2, Brampton 2)"
290 + },
291 + {
292 + "id": "ppml",
293 + "name": "Prudential Property Management (PPML)",
294 + "site": "https://ppml.ca",
295 + "client_id": 173,
296 + "listing_url": "https://ppml.ca/properties",
297 + "regions": [
298 + "Toronto",
299 + "Mississauga",
300 + "Waterloo",
301 + "Richmond Hill",
302 + "Brampton",
303 + "Scarborough"
304 + ],
305 + "status": "valide",
306 + "properties_on": 128,
307 + "notes": "Vague 2 (validé 2026-08-27) : 128 propriétés ON, toutes résidentielles (Toronto 91, Mississauga 14, Waterloo 7, Richmond Hill 3)"
308 + },
309 + {
310 + "id": "equiton",
311 + "name": "Equiton Living",
312 + "site": "https://equitonliving.com",
313 + "client_id": 885,
314 + "listing_url": "https://equitonliving.com/properties",
315 + "regions": [
316 + "Toronto",
317 + "Kingston",
318 + "London",
319 + "Guelph",
320 + "Chatham",
321 + "Brantford",
322 + "Kitchener"
323 + ],
324 + "status": "valide",
325 + "properties_on": 35,
326 + "notes": "Vague 2 (validé 2026-08-27) : 40 propriétés au flux dont 35 ON + 4 AB + 1 BC (filtrées) ; 32 résidentielles ON (Toronto 7, Kingston 3, London 3, Guelph 3)"
327 + },
328 + {
329 + "id": "regional",
330 + "name": "Regional Group (availabilities.regionalgroup.com)",
331 + "site": "https://regionalgroup.com",
332 + "client_id": 1432,
333 + "listing_url": "https://regionalgroup.com/availabilities/residential/",
334 + "regions": [
335 + "Ottawa",
336 + "Gloucester",
337 + "Nepean",
338 + "Gatineau (QC)"
339 + ],
340 + "status": "valide",
341 + "properties_on": 12,
342 + "notes": "Vague 2 (validé 2026-08-27) : 13 propriétés dont 12 ON + 1 QC (Gatineau, gardée par le connecteur) ; Ottawa 10 ; permaliens sur availabilities.regionalgroup.com (certains redirigent vers des sites de marque, ex. bravoliving.ca)"
343 + },
344 + {
345 + "id": "ranee",
346 + "name": "Ranee Management",
347 + "site": "https://www.ranee.ca",
348 + "client_id": 223,
349 + "listing_url": "https://www.ranee.ca/apartments-for-rent",
350 + "regions": [
351 + "Toronto",
352 + "Mississauga",
353 + "Brampton",
354 + "East York",
355 + "North York",
356 + "Scarborough"
357 + ],
358 + "status": "valide",
359 + "properties_on": 38,
360 + "notes": "Vague 2 (validé 2026-08-27) : 38 propriétés ON, toutes résidentielles (Toronto 21, Mississauga 5, Brampton 4, East York 3)"
361 + },
362 + {
363 + "id": "stellen",
364 + "name": "Stellen Property Management",
365 + "site": "https://www.stellen.ca",
366 + "client_id": 238,
367 + "listing_url": "https://www.stellen.ca/apartments-for-rent",
368 + "regions": [
369 + "Burlington",
370 + "North York",
371 + "Toronto"
372 + ],
373 + "status": "valide",
374 + "properties_on": 5,
375 + "notes": "Vague 2 (validé 2026-08-27) : 5 propriétés ON (Burlington 2, North York 2, Toronto 1) ; client_id via data-client-id des pages villes"
376 + },
377 + {
378 + "id": "fpm",
379 + "name": "Fleming Property Management",
380 + "site": "https://www.fpm.ca",
381 + "client_id": 963,
382 + "listing_url": "https://www.fpm.ca/residential",
383 + "regions": [
384 + "Ottawa"
385 + ],
386 + "status": "valide",
387 + "properties_on": 72,
388 + "permalink_sub": [
389 + "/listings/",
390 + "/residential/"
391 + ],
392 + "notes": "Vague 2 (validé 2026-08-27) : 72 propriétés ON, toutes Ottawa ; permaliens API cassés (fpm.ca/listings/<slug> → 404), corrigés via permalink_sub → /residential/ (testé sur 4 slugs) ; quelques permaliens de quartier (ex. /byward-market/…) intacts car sans /listings/"
393 + },
394 + {
395 + "id": "ottawapm",
396 + "name": "Ottawa Property Managers",
397 + "site": "https://www.ottawapropertymanagers.com",
398 + "client_id": 930,
399 + "listing_url": "https://www.ottawapropertymanagers.com/apartments",
400 + "regions": [
401 + "Ottawa",
402 + "Richmond",
403 + "Cumberland"
404 + ],
405 + "status": "valide",
406 + "properties_on": 175,
407 + "permalink_sub": [
408 + "/apartments/",
409 + "/for-rent/"
410 + ],
411 + "notes": "Vague 2 (validé 2026-08-27) : 175 propriétés ON (Ottawa 172) ; ~la moitié des permaliens API /apartments/<slug> → 404, mais /for-rent/<slug> rend la fiche (titre + galerie rentsync) pour TOUS les slugs testés → permalink_sub"
412 + },
413 + {
414 + "id": "tribond",
415 + "name": "Tribond Property Management",
416 + "site": "https://www.tribondpm.com",
417 + "client_id": 701,
418 + "listing_url": "https://www.tribondpm.com/residential",
419 + "regions": [
420 + "Kingston",
421 + "Napanee"
422 + ],
423 + "status": "valide",
424 + "properties_on": 13,
425 + "notes": "Vague 2 (validé 2026-08-27) : 13 propriétés ON (Kingston 12, Napanee 1)"
426 + },
427 + {
428 + "id": "ekort",
429 + "name": "Ekort Property Management",
430 + "site": "https://www.ekortpm.com",
431 + "client_id": 242,
432 + "listing_url": "https://www.ekortpm.com/properties",
433 + "regions": [
434 + "Belleville",
435 + "Quinte West",
436 + "Trenton",
437 + "Picton",
438 + "Bay of Quinte"
439 + ],
440 + "status": "valide",
441 + "properties_on": 59,
442 + "permalink_sub": [
443 + "/luxury-apartments/",
444 + "/residential/"
445 + ],
446 + "notes": "Vague 2 (validé 2026-08-27) : 59 propriétés ON dont 47 résidentielles (Belleville 32, Quinte West 6, Trenton 5) ; permaliens API cassés (/luxury-apartments/<slug> → 404), corrigés via permalink_sub → /residential/ (testé sur 6 slugs)"
447 + },
448 + {
449 + "id": "marandola",
450 + "name": "Team Marandola Property Management",
451 + "site": "https://www.teammarandola.com",
452 + "client_id": 554,
453 + "listing_url": "https://www.teammarandola.com/residential",
454 + "regions": [
455 + "Thorold",
456 + "St. Catharines",
457 + "Niagara Falls",
458 + "Niagara-on-the-Lake",
459 + "Welland"
460 + ],
461 + "status": "valide",
462 + "properties_on": 83,
463 + "notes": "Vague 2 (validé 2026-08-27) : 83 propriétés ON, rare couverture Niagara (Thorold 60, St. Catharines 16) ; beaucoup d'unités individuelles (maisons/upper-lower)"
464 + },
465 + {
466 + "id": "ceming",
467 + "name": "Ceming Apartments",
468 + "site": "https://www.cemingapartments.com",
469 + "client_id": 196,
470 + "listing_url": "https://www.cemingapartments.com/apartments",
471 + "regions": [
472 + "Sudbury"
473 + ],
474 + "status": "valide",
475 + "properties_on": 8,
476 + "notes": "Vague 2 (validé 2026-08-27) : 8 propriétés ON, toutes Sudbury"
477 + },
478 + {
479 + "id": "fitzrovia",
480 + "name": "Fitzrovia Real Estate",
481 + "site": "https://fitzrovia.ca",
482 + "client_id": null,
483 + "listing_url": "https://fitzrovia.ca/residences",
484 + "regions": [
485 + "Toronto/GTA"
486 + ],
487 + "status": "migre_rentsyncgw",
488 + "notes": "Vague 2 (2026-08-27) : NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/fitzrovia, Vue) — aucun flux LiftSystem v2 ; gros portefeuille locatif Toronto, à récupérer via website-gateway ; FAIT 2026-08-27 → couvert par rsgw_fitzrovia (rentsyncgw_clients.json)"
489 + },
490 + {
491 + "id": "drakepm",
492 + "name": "Drake Property Management",
493 + "site": "https://www.drakepm.com",
494 + "client_id": null,
495 + "listing_url": "https://www.drakepm.com/residential",
496 + "regions": [
497 + "Hamilton",
498 + "sud de l'Ontario"
499 + ],
500 + "status": "migre_rentsyncgw",
501 + "notes": "Vague 2 (2026-08-27) : NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/drakepm, Vue) — aucun flux LiftSystem v2 ; connecteur website-gateway à écrire ; FAIT 2026-08-27 → couvert par rsgw_drakepm (rentsyncgw_clients.json)"
502 + },
503 + {
504 + "id": "arcanos",
505 + "name": "Arcanos Property Management",
506 + "site": "https://arcanos.ca",
507 + "client_id": null,
508 + "listing_url": "https://arcanos.ca/apartments-for-rent",
509 + "regions": [
510 + "Ontario"
511 + ],
512 + "status": "migre_rentsyncgw",
513 + "notes": "Vague 2 (2026-08-27) : NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/arcanos_rebuild, Vue) — aucun flux LiftSystem v2 ; FAIT 2026-08-27 → couvert par rsgw_arcanos (rentsyncgw_clients.json)"
514 + },
515 + {
516 + "id": "bgo",
517 + "name": "BGO Living (BentallGreenOak)",
518 + "site": "https://bgoliving.com",
519 + "client_id": null,
520 + "listing_url": "https://bgoliving.com/apartments-for-rent",
521 + "regions": [
522 + "Toronto/GTA",
523 + "pancanadien"
524 + ],
525 + "status": "migre_rentsyncgw",
526 + "notes": "Vague 2 (2026-08-27) : NOUVELLE plateforme Rentsync (cdn.rentsync.com/site/bgo_rebuild, Vue) — aucun flux LiftSystem v2 ; gros gestionnaire institutionnel, morceau important pour website-gateway ; FAIT 2026-08-27 → couvert par rsgw_bgo (rentsyncgw_clients.json)"
178 527 }
179 528 ]
180 −}
529 +}
\ No newline at end of file
modified data/rentcafe_clients.json +151 −1
@@ -1,5 +1,5 @@
1 1 {
2 − "_comment": "Lou-Ka — Registre des clients Yardi RentCafe/SecureCafe (expansion Ontario 2026-08). Auteur : Simon-Pierre Boucher — contact@spboucher.ai. Consommé par louka/connectors/rentcafe.py : une sous-classe de connecteur est générée par client au statut « ok » (source_id = rc_<id>). Les entrées « echec » documentent les impasses vérifiées (ne pas réessayer sans nouvelle info). Enquête : 2026-08-26, tout passe par Scrapfly (Cloudflare 403 en direct).",
2 + "_comment": "Lou-Ka — Registre des clients Yardi RentCafe/SecureCafe (expansion Ontario 2026-08). Auteur : Simon-Pierre Boucher — contact@spboucher.ai. Consommé par louka/connectors/rentcafe.py : une sous-classe de connecteur est générée par client au statut « ok » (source_id = rc_<id>). Les entrées « echec » documentent les impasses vérifiées (ne pas réessayer sans nouvelle info). Enquêtes : vague 1 le 2026-08-26, vague 2 le 2026-08-27 ; tout passe par Scrapfly (Cloudflare 403 en direct).",
3 3 "clients": [
4 4 {
5 5 "id": "effort",
@@ -70,6 +70,156 @@
70 70 "regions": "London, Sarnia, Guelph, Woodstock",
71 71 "status": "echec",
72 72 "note": "Vérifié 2026-08-26 : tricar.com est un WordPress (pas de searchlisting.aspx, 404) ; propertyselect.aspx du portail SecureCafe = enregistrement des résidents, aucune liste publique. Piste restante : connecteur WP dédié (/rent-an-apartment/<ville>-apartments/)."
73 + },
74 + {
75 + "id": "claridge",
76 + "name": "Claridge Homes",
77 + "site": "https://rent.claridgehomes.com",
78 + "search_url": "https://rent.claridgehomes.com/searchlisting.aspx",
79 + "pattern": "searchlisting",
80 + "regions": "Ottawa (centre, Gloucester, Riverside Sud)",
81 + "status": "ok",
82 + "note": "14 cartes ON au 2026-08-27 (portail locatif rent.claridgehomes.com — le site corporatif claridgehomes.com n'a pas de searchlisting, 404). Fiches = microsites RentCafe par immeuble (ex. stirling.claridgehomes.com, magnolia.claridgehomes.com) ; plans fp-container sur <microsite>/floorplans (validé : Stirling Park 1 ch. 710 pi², à partir de 2 225 $)."
83 + },
84 + {
85 + "id": "richcraft",
86 + "name": "Richcraft Rentals",
87 + "site": "https://www.richcraftrentals.com",
88 + "search_url": "https://www.richcraftrentals.com/searchlisting.aspx",
89 + "pattern": "searchlisting",
90 + "regions": "Ottawa (Stittsville, Kanata, Barrhaven, Orléans, Riverside Sud)",
91 + "status": "ok",
92 + "note": "13 cartes ON au 2026-08-27. ⚠ Utiliser www.richcraftrentals.com (l'apex sans www ne répond pas ; richcraft.com corporatif = 404). Fiches = microsites par immeuble (grantcourtrentals.com, mapletonrentals.com…) : description/galerie OK mais certains microsites ne publient pas de plans fp-container (Grant Court) — les prix des cartes liste suffisent."
93 + },
94 + {
95 + "id": "arnon",
96 + "name": "Arnon Development",
97 + "site": "https://www.arnon.ca",
98 + "search_url": "https://www.arnon.ca/searchlisting.aspx",
99 + "pattern": "searchlisting",
100 + "regions": "Ottawa",
101 + "status": "ok",
102 + "note": "3 cartes ON au 2026-08-27 (petit parc). Aucun prix sur les cartes NI les épingles : le prix vient uniquement des plans fp-container des microsites par immeuble (ex. tendriveway.arnon.ca : à partir de 1 500 $) — garder max_details ≥ 2 pour ne pas publier sans prix."
103 + },
104 + {
105 + "id": "concert",
106 + "name": "Concert Properties",
107 + "site": "https://rent.concertproperties.com",
108 + "search_url": "https://rent.concertproperties.com/searchlisting.aspx",
109 + "pattern": "searchlisting",
110 + "regions": "Toronto, Etobicoke",
111 + "status": "ok",
112 + "note": "7 cartes ON au 2026-08-27. Fiches sur le même domaine (/etobicoke/20-samuel-wood, /toronto/jazz…) ; plans fp-container via <fiche>/floorplans (validé : 20 Samuel Wood, studio 360 pi² à partir de 1 810 $, 20 photos)."
113 + },
114 + {
115 + "id": "caraco",
116 + "name": "CaraCo Property Management",
117 + "site": "https://www.caracoproperties.ca",
118 + "search_url": "https://www.caracoproperties.ca/searchlisting.aspx",
119 + "pattern": "searchlisting",
120 + "regions": "Kingston",
121 + "status": "ok",
122 + "note": "9 cartes ON au 2026-08-27. ⚠ Domaine = caracoproperties.ca AVEC www (l'apex renvoie une erreur SSL 525 ; caracoliving.com = 404). Fiches même domaine ; plans fp-container (validé : L'Hermitage East 1 ch. 770 pi², à partir de 1 900 $)."
123 + },
124 + {
125 + "id": "colonnade",
126 + "name": "Colonnade BridgePort",
127 + "site": "https://colonnadebridgeport.ca",
128 + "search_url": "https://colonnadebridgeport.ca/searchlisting.aspx",
129 + "pattern": "microsites",
130 + "regions": "Ottawa (Westboro, Hintonburg)",
131 + "status": "echec",
132 + "note": "Vérifié 2026-08-27 : pas de searchlisting central — searchlisting.aspx renvoie un 403 origine même via Scrapfly ASP. Portefeuille éclaté en microsites RentCafe par immeuble (westboroconnection.com, hintonburgconnection.com…). Piste restante : registre de microsites dédié."
133 + },
134 + {
135 + "id": "destaron",
136 + "name": "Destaron Property Management",
137 + "site": "https://destaron.ca",
138 + "search_url": "https://destaron.ca/searchlisting.aspx",
139 + "pattern": "wordpress",
140 + "regions": "Oakville, Burlington, Mississauga, Milton, Brantford, Barrie, Orillia",
141 + "status": "echec",
142 + "note": "Vérifié 2026-08-27 : destaron.ca est un WordPress — searchlisting.aspx renvoie un 403 origine même via Scrapfly ASP ; le Yardi est intégré par immeuble (SecureCafe). Piste restante : connecteur WP dédié (pages villes /oakville/, /brantford/, /orillia/…)."
143 + },
144 + {
145 + "id": "hillside",
146 + "name": "Hillside Apartments",
147 + "site": "https://hillsideapartments.com",
148 + "search_url": "https://hillsideapartments.com/searchlisting.aspx",
149 + "pattern": "entrata",
150 + "regions": "Ottawa (étudiant)",
151 + "status": "echec",
152 + "note": "Vérifié 2026-08-27 : PAS un Yardi — plateforme Entrata/ProspectPortal (hillsideottawa.prospectportal.com, assets rcommoncf.entrata.com). searchlisting.aspx renvoie 200 mais c'est la page d'accueil (0 carte property-box). Hors périmètre rentcafe.py ; piste : connecteur Entrata."
153 + },
154 + {
155 + "id": "wellington",
156 + "name": "Wellington Towers",
157 + "site": "https://147wellington.com",
158 + "search_url": "https://147wellington.com/searchlisting.aspx",
159 + "pattern": "searchlisting",
160 + "regions": "Aurora",
161 + "status": "echec",
162 + "note": "Vérifié 2026-08-27 : 147wellingtonstreet.com ne répond pas en HTTPS (timeout, DNS pourtant actif) et 147wellington.com renvoie 403 même via Scrapfly ASP — aucun searchlisting public accessible. Immeuble unique ; à revisiter si le site revient en ligne."
163 + },
164 + {
165 + "id": "tricon",
166 + "name": "Tricon Residential",
167 + "site": "https://triconhomes.com",
168 + "search_url": "https://triconhomes.com/searchlisting.aspx",
169 + "pattern": "wordpress",
170 + "regions": "Toronto",
171 + "status": "echec",
172 + "note": "Vérifié 2026-08-27 : triconhomes.com est un WordPress (searchlisting.aspx = 404, triconresidential.com y redirige) et les immeubles (theselby.com…) sont des WordPress par propriété (searchlisting = page 404 WP). Aucun portail RentCafe central public. Piste : connecteurs par immeuble."
173 + },
174 + {
175 + "id": "taggart",
176 + "name": "Taggart Realty Management",
177 + "site": "https://taggart.ca",
178 + "search_url": "https://taggart.ca/searchlisting.aspx",
179 + "pattern": "wordpress",
180 + "regions": "Ottawa (Kenson Towers, 93 Norman)",
181 + "status": "echec",
182 + "note": "Vérifié 2026-08-27 : taggart.ca est un WordPress (searchlisting.aspx = 404, avec ou sans www) ; les annonces passent par SecureCafe intégré par immeuble. Piste restante : connecteur WP dédié (/apartments)."
183 + },
184 + {
185 + "id": "rezone",
186 + "name": "Rez-One",
187 + "site": "https://rez-one.ca",
188 + "search_url": "https://rez-one.ca/searchlisting.aspx",
189 + "pattern": "wordpress",
190 + "regions": "Waterloo (étudiant), Elora",
191 + "status": "echec",
192 + "note": "Vérifié 2026-08-27 : rez-one.ca est un WordPress (searchlisting.aspx = 404 ; rez-one.com ne répond pas en HTTPS) ; portail SecureCafe/Yardi par résidence. Gros parc étudiant — piste restante : connecteur WP dédié."
193 + },
194 + {
195 + "id": "wjproperties",
196 + "name": "WJ Properties",
197 + "site": "https://www.wjproperties.ca",
198 + "search_url": "https://www.wjproperties.ca/searchlisting.aspx",
199 + "pattern": "wordpress",
200 + "regions": "Old Toronto",
201 + "status": "echec",
202 + "note": "Vérifié 2026-08-27 : wjproperties.ca est un WordPress (searchlisting.aspx = 404, pas de sous-domaine rent.) ; le RentCafe est intégré par immeuble. Piste restante : connecteur WP dédié."
203 + },
204 + {
205 + "id": "northview",
206 + "name": "Northview (rentnorthview.com)",
207 + "site": "https://www.rentnorthview.com",
208 + "search_url": "https://www.rentnorthview.com/searchlisting.aspx",
209 + "pattern": "custom",
210 + "regions": "Ontario + pancanadien",
211 + "status": "echec",
212 + "note": "Vérifié 2026-08-27 : northviewreit.com redirige vers rentnorthview.com, un site de location maison (custom, searchlisting.aspx = 404) — pas un client RentCafe. Gros parc : piste restante, connecteur dédié rentnorthview.com (API interne à cartographier)."
213 + },
214 + {
215 + "id": "bakerfield",
216 + "name": "Rose Corporation (The Bakerfield)",
217 + "site": "https://www.thebakerfield.com",
218 + "search_url": "https://www.thebakerfield.com/searchlisting.aspx",
219 + "pattern": "rentsync",
220 + "regions": "Newmarket",
221 + "status": "echec",
222 + "note": "Vérifié 2026-08-27 : thebakerfield.com/searchlisting.aspx renvoie 204 No Content — site Rentsync, pas un searchlisting RentCafe public. Immeuble unique (225 unités) ; piste restante : connecteur Rentsync/LTL."
73 223 }
74 224 ]
75 225 }
added data/rentsyncgw_clients.json +124 −0
@@ -0,0 +1,124 @@
1 +{
2 + "_comment": "Registre des clients Rentsync « nouvelle génération » (passerelle JSON publique website-gateway.rentsync.com/v1/<siteKey>/) — expansion Ontario 2026-08. Consommé par louka/connectors/rentsyncgw.py qui génère un connecteur rsgw_<id> par entrée validée (même architecture data-driven que liftsystem.py). siteKeys découverts dans les bundles cdn.rentsync.com/site/<key> et VALIDÉS en live le 2026-08-27 (curl properties/units/cities/photos + HEAD S3). img_key = clé S3 lws_lift des galeries — peut différer du siteKey (drewlo_rebuild → drewlo, panoramic_properties → panoramic ; vérifié 200 vs 403). Connecteurs de la MÊME passerelle déjà dédiés (NE PAS dupliquer ici) : homestead.py (homestead_rebuild), skyline.py (skyline).",
3 + "updated": "2026-08-27",
4 + "clients": [
5 + {
6 + "id": "sterlingkaramar",
7 + "name": "Sterling Karamar Property Management",
8 + "site": "https://www.sterlingkaramar.com",
9 + "site_key": "sterlingkaramar",
10 + "img_key": "sterlingkaramar",
11 + "listing_url": "https://www.sterlingkaramar.com/apartments-for-rent",
12 + "regions": [
13 + "Toronto/GTA",
14 + "London",
15 + "Ottawa",
16 + "Kitchener-Waterloo",
17 + "Hamilton",
18 + "Barrie"
19 + ],
20 + "status": "valide",
21 + "properties": 217,
22 + "exclude_import_ids": [
23 + "yardi:ball0045",
24 + "yardi:davi0225",
25 + "yardi:davi0207"
26 + ],
27 + "notes": "~15 000 unités gérées ; 217 propriétés au flux dont 171 résidentielles (46 commerciales filtrées : office/retail/industrial) ; 1 187 rangées d'unités disponibles (2 pages), 39 villes ON ; exclude_import_ids = 3 immeubles Greenrock (couverts par greenrock.py)"
28 + },
29 + {
30 + "id": "drewlo",
31 + "name": "Drewlo Holdings",
32 + "site": "https://www.drewloholdings.com",
33 + "site_key": "drewlo_rebuild",
34 + "img_key": "drewlo",
35 + "listing_url": "https://www.drewloholdings.com/apartments-for-rent",
36 + "regions": [
37 + "London",
38 + "Kitchener-Waterloo",
39 + "Woodstock",
40 + "Sarnia",
41 + "St. Thomas",
42 + "Komoka"
43 + ],
44 + "status": "valide",
45 + "properties": 108,
46 + "notes": "~10 000 unités (constructeur-gestionnaire du Sud-Ouest ontarien) ; 108 propriétés dont ~94 résidentielles (filtrés : construction/coming-soon 4, office 4, retail 1, subdivision 5) ; 479 rangées d'unités disponibles ; typeName = noms de plans (« Elgin ») → unit_type dérivé des chambres"
47 + },
48 + {
49 + "id": "panoramic",
50 + "name": "Panoramic Properties",
51 + "site": "https://www.panoramicproperties.ca",
52 + "site_key": "panoramic_properties",
53 + "img_key": "panoramic",
54 + "listing_url": "https://www.panoramicproperties.ca/apartments-for-rent",
55 + "regions": [
56 + "Sudbury",
57 + "Nord de l'Ontario"
58 + ],
59 + "status": "valide",
60 + "properties": 49,
61 + "notes": "Gestionnaire dominant du Grand Sudbury ; 49 propriétés (toutes résidentielles : apartments + townhouses), 132 rangées d'unités disponibles, 11 villes"
62 + },
63 + {
64 + "id": "fitzrovia",
65 + "name": "Fitzrovia Real Estate",
66 + "site": "https://fitzrovia.ca",
67 + "site_key": "fitzrovia",
68 + "img_key": "fitzrovia",
69 + "listing_url": "https://fitzrovia.ca/residences",
70 + "regions": [
71 + "Toronto/GTA",
72 + "Guelph"
73 + ],
74 + "status": "valide",
75 + "properties": 25,
76 + "notes": "Vague 2b (2026-08-27) : 25 propriétés (Toronto/GTA + Guelph), 828 rangées d'unités disponibles (2 pages), 6 villes ; gros portefeuille luxe Toronto"
77 + },
78 + {
79 + "id": "drakepm",
80 + "name": "Drake Property Management",
81 + "site": "https://www.drakepm.com",
82 + "site_key": "drakepm",
83 + "img_key": "drake_pm",
84 + "listing_url": "https://www.drakepm.com/residential",
85 + "regions": [
86 + "Hamilton",
87 + "Sud de l'Ontario"
88 + ],
89 + "status": "valide",
90 + "properties": 55,
91 + "notes": "Vague 2b (2026-08-27) : 55 propriétés, 572 rangées d'unités disponibles, 14 villes ; img_key drake_pm (siteKey drakepm → 403)"
92 + },
93 + {
94 + "id": "arcanos",
95 + "name": "Arcanos Property Management",
96 + "site": "https://arcanos.ca",
97 + "site_key": "arcanos_rebuild",
98 + "img_key": "arcanos",
99 + "listing_url": "https://arcanos.ca/apartments-for-rent",
100 + "regions": [
101 + "Toronto",
102 + "Oakville"
103 + ],
104 + "status": "valide",
105 + "properties": 14,
106 + "notes": "Vague 2b (2026-08-27) : 14 propriétés (Toronto + Oakville), 41 rangées d'unités disponibles ; img_key arcanos (siteKey arcanos_rebuild → 403)"
107 + },
108 + {
109 + "id": "bgo",
110 + "name": "BGO Living (BentallGreenOak)",
111 + "site": "https://bgoliving.com",
112 + "site_key": "bgo_rebuild",
113 + "img_key": "bentall_green_oak",
114 + "listing_url": "https://bgoliving.com/apartments-for-rent",
115 + "regions": [
116 + "Toronto/GTA",
117 + "pancanadien"
118 + ],
119 + "status": "valide",
120 + "properties": 46,
121 + "notes": "Vague 2b (2026-08-27) : 46 propriétés pancanadiennes (20 villes, ON/BC/AB/QC — filtre ON strict du connecteur), 1 176 rangées d'unités disponibles (2 pages) ; img_key bentall_green_oak (trouvé dans le HTML de bgoliving.com)"
122 + }
123 + ]
124 +}
\ No newline at end of file
modified data/sources.json +468 −18
@@ -4395,7 +4395,7 @@
4395 4395 "url": "https://www.greenwin.ca",
4396 4396 "listing_url": "https://www.greenwin.ca/apartments-for-rent/cities/toronto",
4397 4397 "sectors": "Toronto/GTA, Kitchener, Hamilton, Brampton, Guelph",
4398 − "connector": "liftsystem",
4398 + "connector": "lift_greenwin",
4399 4399 "status": "actif",
4400 4400 "region": "Ontario — Toronto/GTA"
4401 4401 },
@@ -4405,7 +4405,7 @@
4405 4405 "url": "https://www.parkproperty.ca",
4406 4406 "listing_url": "https://www.parkproperty.ca/apartments-for-rent",
4407 4407 "sectors": "Toronto/GTA, Stratford, Waterloo, Cambridge, Burlington, London",
4408 − "connector": "liftsystem",
4408 + "connector": "lift_park",
4409 4409 "status": "actif",
4410 4410 "region": "Ontario — Toronto/GTA"
4411 4411 },
@@ -4415,7 +4415,7 @@
4415 4415 "url": "https://www.williamsandmcdaniel.com",
4416 4416 "listing_url": "https://www.williamsandmcdaniel.com/apartments",
4417 4417 "sectors": "Guelph, Sault Ste. Marie, Fergus, Midland, Kitchener, Hamilton, Stratford",
4418 − "connector": "liftsystem",
4418 + "connector": "lift_williamsmcdaniel",
4419 4419 "status": "actif",
4420 4420 "region": "Ontario — Sud-Ouest"
4421 4421 },
@@ -4425,7 +4425,7 @@
4425 4425 "url": "https://www.briarlane.ca",
4426 4426 "listing_url": "https://www.briarlane.ca/apartments-for-rent",
4427 4427 "sectors": "London, Toronto/GTA, Markham/York, Brampton, Mississauga",
4428 − "connector": "liftsystem",
4428 + "connector": "lift_briarlane",
4429 4429 "status": "actif",
4430 4430 "region": "Ontario — Toronto/GTA"
4431 4431 },
@@ -4435,7 +4435,7 @@
4435 4435 "url": "https://www.dmsproperty.com",
4436 4436 "listing_url": "https://www.dmsproperty.com/apartments-for-rent",
4437 4437 "sectors": "Toronto/GTA, Hamilton, Kingston, Barrie, Richmond Hill, Welland",
4438 − "connector": "liftsystem",
4438 + "connector": "lift_dms",
4439 4439 "status": "actif",
4440 4440 "region": "Ontario — Toronto/GTA"
4441 4441 },
@@ -4445,7 +4445,7 @@
4445 4445 "url": "https://www.signetgroup.ca",
4446 4446 "listing_url": "https://www.signetgroup.ca/apartments",
4447 4447 "sectors": "Toronto, Mississauga, Kitchener, Cambridge, Stratford",
4448 − "connector": "liftsystem",
4448 + "connector": "lift_signet",
4449 4449 "status": "actif",
4450 4450 "region": "Ontario — Toronto/GTA"
4451 4451 },
@@ -4455,7 +4455,7 @@
4455 4455 "url": "https://www.scpl.com",
4456 4456 "listing_url": "https://www.scpl.com/residential-rental",
4457 4457 "sectors": "Toronto, Ottawa, Belleville, Thunder Bay, Orillia",
4458 − "connector": "liftsystem",
4458 + "connector": "lift_shelter",
4459 4459 "status": "actif",
4460 4460 "region": "Ontario — multi-régions"
4461 4461 },
@@ -4465,7 +4465,7 @@
4465 4465 "url": "https://www.yorkproperty.ca",
4466 4466 "listing_url": "https://www.yorkproperty.ca/apartments-for-rent/london",
4467 4467 "sectors": "Windsor, London, Sarnia, Brantford, Woodstock",
4468 − "connector": "liftsystem",
4468 + "connector": "lift_york",
4469 4469 "status": "actif",
4470 4470 "region": "Ontario — Sud-Ouest"
4471 4471 },
@@ -4475,7 +4475,7 @@
4475 4475 "url": "https://terracorp.ca",
4476 4476 "listing_url": "https://terracorp.ca/apartments-for-rent",
4477 4477 "sectors": "London, Windsor, Kitchener, Owen Sound",
4478 − "connector": "liftsystem",
4478 + "connector": "lift_terracorp",
4479 4479 "status": "actif",
4480 4480 "region": "Ontario — Sud-Ouest"
4481 4481 },
@@ -4485,7 +4485,7 @@
4485 4485 "url": "https://rentsarnia.com",
4486 4486 "listing_url": "https://rentsarnia.com/",
4487 4487 "sectors": "Sarnia, Corunna",
4488 − "connector": "liftsystem",
4488 + "connector": "lift_rentsarnia",
4489 4489 "status": "actif",
4490 4490 "region": "Ontario — Sud-Ouest"
4491 4491 },
@@ -4495,7 +4495,7 @@
4495 4495 "url": "https://firstnorthpm.com",
4496 4496 "listing_url": "https://firstnorthpm.com/apartments-for-rent",
4497 4497 "sectors": "Sudbury, North Bay, Timmins",
4498 − "connector": "liftsystem",
4498 + "connector": "lift_firstnorth",
4499 4499 "status": "actif",
4500 4500 "region": "Ontario — Nord"
4501 4501 },
@@ -4505,7 +4505,7 @@
4505 4505 "url": "https://www.tvm.ca",
4506 4506 "listing_url": "https://www.tvm.ca/apartments-for-rent",
4507 4507 "sectors": "Peterborough, Cobourg, Toronto, Port Hope",
4508 − "connector": "liftsystem",
4508 + "connector": "lift_tvm",
4509 4509 "status": "actif",
4510 4510 "region": "Ontario — Centre-Est"
4511 4511 },
@@ -4515,7 +4515,7 @@
4515 4515 "url": "https://urbandalecorporation.com",
4516 4516 "listing_url": "https://urbandalecorporation.com/residential-rentals",
4517 4517 "sectors": "Ottawa, Gloucester, Riverside South",
4518 − "connector": "liftsystem",
4518 + "connector": "lift_urbandale",
4519 4519 "status": "actif",
4520 4520 "region": "Ontario — Ottawa"
4521 4521 },
@@ -4545,7 +4545,7 @@
4545 4545 "url": "https://www.effortrentals.com",
4546 4546 "listing_url": "https://www.effortrentals.com/searchlisting.aspx",
4547 4547 "sectors": "Hamilton, Kitchener-Waterloo, Cambridge, Guelph, Brantford, Woodstock, St. Catharines, Burlington",
4548 − "connector": "rentcafe",
4548 + "connector": "rc_effort",
4549 4549 "status": "actif",
4550 4550 "region": "Ontario — Hamilton/Niagara"
4551 4551 },
@@ -4555,7 +4555,7 @@
4555 4555 "url": "https://www.osgoodeproperties.com",
4556 4556 "listing_url": "https://www.osgoodeproperties.com/searchlisting.aspx",
4557 4557 "sectors": "Ottawa, Kingston",
4558 − "connector": "rentcafe",
4558 + "connector": "rc_osgoode",
4559 4559 "status": "actif",
4560 4560 "region": "Ontario — Ottawa"
4561 4561 },
@@ -4565,7 +4565,7 @@
4565 4565 "url": "https://www.gwlraresidential.com",
4566 4566 "listing_url": "https://www.gwlraresidential.com/searchlisting.aspx",
4567 4567 "sectors": "Toronto, Mississauga, Burlington, Ottawa",
4568 − "connector": "rentcafe",
4568 + "connector": "rc_gwlra",
4569 4569 "status": "actif",
4570 4570 "region": "Ontario — Toronto/GTA"
4571 4571 },
@@ -4575,9 +4575,459 @@
4575 4575 "url": "https://www.oshanter.com",
4576 4576 "listing_url": "https://www.oshanter.com/searchlisting.aspx",
4577 4577 "sectors": "Toronto, East York, Mississauga",
4578 − "connector": "rentcafe",
4578 + "connector": "rc_oshanter",
4579 4579 "status": "actif",
4580 4580 "region": "Ontario — Toronto/GTA"
4581 + },
4582 + {
4583 + "id": "rsgw_sterlingkaramar",
4584 + "name": "Sterling Karamar Property Management",
4585 + "url": "https://www.sterlingkaramar.com",
4586 + "listing_url": "https://www.sterlingkaramar.com/apartments-for-rent",
4587 + "sectors": "Toronto/GTA, London, Ottawa, Kitchener-Waterloo, Hamilton, Barrie",
4588 + "connector": "rsgw_sterlingkaramar",
4589 + "status": "actif",
4590 + "region": "Ontario — multi-régions"
4591 + },
4592 + {
4593 + "id": "rsgw_drewlo",
4594 + "name": "Drewlo Holdings",
4595 + "url": "https://www.drewloholdings.com",
4596 + "listing_url": "https://www.drewloholdings.com/apartments-for-rent",
4597 + "sectors": "London, Kitchener-Waterloo, Woodstock, Sarnia, St. Thomas, Komoka",
4598 + "connector": "rsgw_drewlo",
4599 + "status": "actif",
4600 + "region": "Ontario — multi-régions"
4601 + },
4602 + {
4603 + "id": "rsgw_panoramic",
4604 + "name": "Panoramic Properties",
4605 + "url": "https://www.panoramicproperties.ca",
4606 + "listing_url": "https://www.panoramicproperties.ca/apartments-for-rent",
4607 + "sectors": "Sudbury, Nord de l'Ontario",
4608 + "connector": "rsgw_panoramic",
4609 + "status": "actif",
4610 + "region": "Ontario — multi-régions"
4611 + },
4612 + {
4613 + "id": "rsgw_fitzrovia",
4614 + "name": "Fitzrovia Real Estate",
4615 + "url": "https://fitzrovia.ca",
4616 + "listing_url": "https://fitzrovia.ca/residences",
4617 + "sectors": "Toronto/GTA, Guelph",
4618 + "connector": "rsgw_fitzrovia",
4619 + "status": "actif",
4620 + "region": "Ontario — multi-régions"
4621 + },
4622 + {
4623 + "id": "rsgw_drakepm",
4624 + "name": "Drake Property Management",
4625 + "url": "https://www.drakepm.com",
4626 + "listing_url": "https://www.drakepm.com/residential",
4627 + "sectors": "Hamilton, Sud de l'Ontario",
4628 + "connector": "rsgw_drakepm",
4629 + "status": "actif",
4630 + "region": "Ontario — multi-régions"
4631 + },
4632 + {
4633 + "id": "rsgw_arcanos",
4634 + "name": "Arcanos Property Management",
4635 + "url": "https://arcanos.ca",
4636 + "listing_url": "https://arcanos.ca/apartments-for-rent",
4637 + "sectors": "Toronto, Oakville",
4638 + "connector": "rsgw_arcanos",
4639 + "status": "actif",
4640 + "region": "Ontario — multi-régions"
4641 + },
4642 + {
4643 + "id": "rsgw_bgo",
4644 + "name": "BGO Living (BentallGreenOak)",
4645 + "url": "https://bgoliving.com",
4646 + "listing_url": "https://bgoliving.com/apartments-for-rent",
4647 + "sectors": "Toronto/GTA, pancanadien",
4648 + "connector": "rsgw_bgo",
4649 + "status": "actif",
4650 + "region": "Ontario — multi-régions"
4651 + },
4652 + {
4653 + "id": "lift_browngroup",
4654 + "name": "The Brown Group of Companies",
4655 + "url": "https://thebrowngroup.net",
4656 + "listing_url": "https://thebrowngroup.net/properties",
4657 + "sectors": "Toronto/GTA, North York, Etobicoke, Brampton, St. Catharines, Cambridge",
4658 + "connector": "lift_browngroup",
4659 + "status": "actif",
4660 + "region": "Ontario — multi-régions"
4661 + },
4662 + {
4663 + "id": "lift_ppml",
4664 + "name": "Prudential Property Management (PPML)",
4665 + "url": "https://ppml.ca",
4666 + "listing_url": "https://ppml.ca/properties",
4667 + "sectors": "Toronto, Mississauga, Waterloo, Richmond Hill, Brampton, Scarborough",
4668 + "connector": "lift_ppml",
4669 + "status": "actif",
4670 + "region": "Ontario — multi-régions"
4671 + },
4672 + {
4673 + "id": "lift_equiton",
4674 + "name": "Equiton Living",
4675 + "url": "https://equitonliving.com",
4676 + "listing_url": "https://equitonliving.com/properties",
4677 + "sectors": "Toronto, Kingston, London, Guelph, Chatham, Brantford, Kitchener",
4678 + "connector": "lift_equiton",
4679 + "status": "actif",
4680 + "region": "Ontario — multi-régions"
4681 + },
4682 + {
4683 + "id": "lift_regional",
4684 + "name": "Regional Group (availabilities.regionalgroup.com)",
4685 + "url": "https://regionalgroup.com",
4686 + "listing_url": "https://regionalgroup.com/availabilities/residential/",
4687 + "sectors": "Ottawa, Gloucester, Nepean, Gatineau (QC)",
4688 + "connector": "lift_regional",
4689 + "status": "actif",
4690 + "region": "Ontario — multi-régions"
4691 + },
4692 + {
4693 + "id": "lift_ranee",
4694 + "name": "Ranee Management",
4695 + "url": "https://www.ranee.ca",
4696 + "listing_url": "https://www.ranee.ca/apartments-for-rent",
4697 + "sectors": "Toronto, Mississauga, Brampton, East York, North York, Scarborough",
4698 + "connector": "lift_ranee",
4699 + "status": "actif",
4700 + "region": "Ontario — multi-régions"
4701 + },
4702 + {
4703 + "id": "lift_stellen",
4704 + "name": "Stellen Property Management",
4705 + "url": "https://www.stellen.ca",
4706 + "listing_url": "https://www.stellen.ca/apartments-for-rent",
4707 + "sectors": "Burlington, North York, Toronto",
4708 + "connector": "lift_stellen",
4709 + "status": "actif",
4710 + "region": "Ontario — multi-régions"
4711 + },
4712 + {
4713 + "id": "lift_fpm",
4714 + "name": "Fleming Property Management",
4715 + "url": "https://www.fpm.ca",
4716 + "listing_url": "https://www.fpm.ca/residential",
4717 + "sectors": "Ottawa",
4718 + "connector": "lift_fpm",
4719 + "status": "actif",
4720 + "region": "Ontario — Ottawa"
4721 + },
4722 + {
4723 + "id": "lift_ottawapm",
4724 + "name": "Ottawa Property Managers",
4725 + "url": "https://www.ottawapropertymanagers.com",
4726 + "listing_url": "https://www.ottawapropertymanagers.com/apartments",
4727 + "sectors": "Ottawa, Richmond, Cumberland",
4728 + "connector": "lift_ottawapm",
4729 + "status": "actif",
4730 + "region": "Ontario — multi-régions"
4731 + },
4732 + {
4733 + "id": "lift_tribond",
4734 + "name": "Tribond Property Management",
4735 + "url": "https://www.tribondpm.com",
4736 + "listing_url": "https://www.tribondpm.com/residential",
4737 + "sectors": "Kingston, Napanee",
4738 + "connector": "lift_tribond",
4739 + "status": "actif",
4740 + "region": "Ontario — multi-régions"
4741 + },
4742 + {
4743 + "id": "lift_ekort",
4744 + "name": "Ekort Property Management",
4745 + "url": "https://www.ekortpm.com",
4746 + "listing_url": "https://www.ekortpm.com/properties",
4747 + "sectors": "Belleville, Quinte West, Trenton, Picton, Bay of Quinte",
4748 + "connector": "lift_ekort",
4749 + "status": "actif",
4750 + "region": "Ontario — multi-régions"
4751 + },
4752 + {
4753 + "id": "lift_marandola",
4754 + "name": "Team Marandola Property Management",
4755 + "url": "https://www.teammarandola.com",
4756 + "listing_url": "https://www.teammarandola.com/residential",
4757 + "sectors": "Thorold, St. Catharines, Niagara Falls, Niagara-on-the-Lake, Welland",
4758 + "connector": "lift_marandola",
4759 + "status": "actif",
4760 + "region": "Ontario — multi-régions"
4761 + },
4762 + {
4763 + "id": "lift_ceming",
4764 + "name": "Ceming Apartments",
4765 + "url": "https://www.cemingapartments.com",
4766 + "listing_url": "https://www.cemingapartments.com/apartments",
4767 + "sectors": "Sudbury",
4768 + "connector": "lift_ceming",
4769 + "status": "actif",
4770 + "region": "Ontario — Sudbury"
4771 + },
4772 + {
4773 + "id": "rc_claridge",
4774 + "name": "Claridge Homes",
4775 + "url": "https://rent.claridgehomes.com",
4776 + "listing_url": "https://rent.claridgehomes.com/searchlisting.aspx",
4777 + "sectors": "Ottawa (centre, Gloucester, Riverside Sud)",
4778 + "connector": "rc_claridge",
4779 + "status": "actif",
4780 + "region": "Ontario — multi-régions"
4781 + },
4782 + {
4783 + "id": "rc_richcraft",
4784 + "name": "Richcraft Rentals",
4785 + "url": "https://www.richcraftrentals.com",
4786 + "listing_url": "https://www.richcraftrentals.com/searchlisting.aspx",
4787 + "sectors": "Ottawa (Stittsville, Kanata, Barrhaven, Orléans, Riverside Sud)",
4788 + "connector": "rc_richcraft",
4789 + "status": "actif",
4790 + "region": "Ontario — multi-régions"
4791 + },
4792 + {
4793 + "id": "rc_arnon",
4794 + "name": "Arnon Development",
4795 + "url": "https://www.arnon.ca",
4796 + "listing_url": "https://www.arnon.ca/searchlisting.aspx",
4797 + "sectors": "Ottawa",
4798 + "connector": "rc_arnon",
4799 + "status": "actif",
4800 + "region": "Ontario — Ottawa"
4801 + },
4802 + {
4803 + "id": "rc_concert",
4804 + "name": "Concert Properties",
4805 + "url": "https://rent.concertproperties.com",
4806 + "listing_url": "https://rent.concertproperties.com/searchlisting.aspx",
4807 + "sectors": "Toronto, Etobicoke",
4808 + "connector": "rc_concert",
4809 + "status": "actif",
4810 + "region": "Ontario — multi-régions"
4811 + },
4812 + {
4813 + "id": "rc_caraco",
4814 + "name": "CaraCo Property Management",
4815 + "url": "https://www.caracoproperties.ca",
4816 + "listing_url": "https://www.caracoproperties.ca/searchlisting.aspx",
4817 + "sectors": "Kingston",
4818 + "connector": "rc_caraco",
4819 + "status": "actif",
4820 + "region": "Ontario — Kingston"
4821 + },
4822 + {
4823 + "id": "bld_richmond",
4824 + "name": "Richmond Property Management",
4825 + "url": "https://richmondpm.ca",
4826 + "listing_url": "https://richmondmanagement.managebuilding.com/Resident/public/rentals",
4827 + "sectors": "Windsor",
4828 + "connector": "bld_richmond",
4829 + "status": "actif",
4830 + "region": "Ontario — Windsor"
4831 + },
4832 + {
4833 + "id": "bld_dreampm",
4834 + "name": "Dream Property Management",
4835 + "url": "https://rentalsdreampm.com",
4836 + "listing_url": "https://dreampm.managebuilding.com/Resident/public/rentals",
4837 + "sectors": "Sarnia",
4838 + "connector": "bld_dreampm",
4839 + "status": "actif",
4840 + "region": "Ontario — Sarnia"
4841 + },
4842 + {
4843 + "id": "bld_grem",
4844 + "name": "Guardian Real Estate Management (GREM)",
4845 + "url": "https://grem.ca",
4846 + "listing_url": "https://guardianrealestatemanagement.managebuilding.com/Resident/public/rentals",
4847 + "sectors": "Sarnia, West Nipissing",
4848 + "connector": "bld_grem",
4849 + "status": "actif",
4850 + "region": "Ontario — multi-régions"
4851 + },
4852 + {
4853 + "id": "bld_elite",
4854 + "name": "Elite Property Group",
4855 + "url": "https://elitepropertygroup.org",
4856 + "listing_url": "https://elitepropertygroup.managebuilding.com/Resident/public/rentals",
4857 + "sectors": "Sarnia",
4858 + "connector": "bld_elite",
4859 + "status": "actif",
4860 + "region": "Ontario — Sarnia"
4861 + },
4862 + {
4863 + "id": "bld_paragon",
4864 + "name": "Paragon Property Management & Maintenance",
4865 + "url": "https://paragonproperty.ca",
4866 + "listing_url": "https://paragonpropertymgt.managebuilding.com/Resident/public/rentals",
4867 + "sectors": "Chatham-Kent",
4868 + "connector": "bld_paragon",
4869 + "status": "actif",
4870 + "region": "Ontario — Chatham-Kent"
4871 + },
4872 + {
4873 + "id": "bld_panadew",
4874 + "name": "Panadew Property Management",
4875 + "url": "https://panadew.ca",
4876 + "listing_url": "https://panadew.managebuilding.com/Resident/public/rentals",
4877 + "sectors": "Kingston",
4878 + "connector": "bld_panadew",
4879 + "status": "actif",
4880 + "region": "Ontario — Kingston"
4881 + },
4882 + {
4883 + "id": "bld_tag",
4884 + "name": "TAG Property Management",
4885 + "url": "https://tagpropertymanagement1.managebuilding.com/Resident/public/home",
4886 + "listing_url": "https://tagpropertymanagement1.managebuilding.com/Resident/public/rentals",
4887 + "sectors": "Cobourg, Port Hope",
4888 + "connector": "bld_tag",
4889 + "status": "actif",
4890 + "region": "Ontario — multi-régions"
4891 + },
4892 + {
4893 + "id": "bld_genedco",
4894 + "name": "Genedco Property Management (GRANITE)",
4895 + "url": "https://genedco.ca",
4896 + "listing_url": "https://genedco.managebuilding.com/Resident/public/rentals",
4897 + "sectors": "Cobourg, Port Hope, Peterborough",
4898 + "connector": "bld_genedco",
4899 + "status": "actif",
4900 + "region": "Ontario — multi-régions"
4901 + },
4902 + {
4903 + "id": "bld_rentcorp",
4904 + "name": "RentCorp Property Management",
4905 + "url": "https://www.rentcorp.ca",
4906 + "listing_url": "https://rentcorppropertymanagement.managebuilding.com/Resident/public/rentals",
4907 + "sectors": "Waterloo, North Bay, London",
4908 + "connector": "bld_rentcorp",
4909 + "status": "actif",
4910 + "region": "Ontario — multi-régions"
4911 + },
4912 + {
4913 + "id": "bld_sleepwell",
4914 + "name": "Sleepwell Property Management",
4915 + "url": "https://www.sleepwellmanagement.com",
4916 + "listing_url": "https://sleepwellpropertymanagement.managebuilding.com/Resident/public/rentals",
4917 + "sectors": "Ottawa",
4918 + "connector": "bld_sleepwell",
4919 + "status": "actif",
4920 + "region": "Ontario — Ottawa"
4921 + },
4922 + {
4923 + "id": "bld_athena",
4924 + "name": "Athena Property Management",
4925 + "url": "https://athenapropertymanagementinc1.managebuilding.com/Resident/public/home",
4926 + "listing_url": "https://athenapropertymanagementinc1.managebuilding.com/Resident/public/rentals",
4927 + "sectors": "Hamilton, St. Catharines",
4928 + "connector": "bld_athena",
4929 + "status": "actif",
4930 + "region": "Ontario — multi-régions"
4931 + },
4932 + {
4933 + "id": "apf_fusepm",
4934 + "name": "Fuse Property Management",
4935 + "url": "https://fusepm.ca",
4936 + "listing_url": "https://fusepm.appfolio.com/listings",
4937 + "sectors": "Barrie, Orillia, Oro-Medonte, Simcoe",
4938 + "connector": "apf_fusepm",
4939 + "status": "actif",
4940 + "region": "Ontario — multi-régions"
4941 + },
4942 + {
4943 + "id": "starlight",
4944 + "name": "Starlight Investments",
4945 + "url": "https://www.starlightinvest.com",
4946 + "listing_url": "https://www.starlightinvest.com/real-estate/canadian-residential#property-finder",
4947 + "sectors": "Toronto/GTA, Ottawa, Hamilton, London, Kitchener-Waterloo, multi-villes ON",
4948 + "connector": "starlight",
4949 + "status": "actif",
4950 + "region": "Ontario — multi-régions"
4951 + },
4952 + {
4953 + "id": "medallion",
4954 + "name": "Medallion Corporation",
4955 + "url": "https://medallioncorp.com",
4956 + "listing_url": "https://medallioncorp.com/apartments/",
4957 + "sectors": "Toronto/GTA, Brampton, Ajax, Whitby, Oshawa, Richmond Hill, Hamilton, London",
4958 + "connector": "medallion",
4959 + "status": "actif",
4960 + "region": "Ontario — multi-régions"
4961 + },
4962 + {
4963 + "id": "kggroup",
4964 + "name": "K-G Group (MyRental.ca)",
4965 + "url": "https://www.myrental.ca",
4966 + "listing_url": "https://www.myrental.ca/apartments-for-rent",
4967 + "sectors": "Toronto — Midtown, North York",
4968 + "connector": "kggroup",
4969 + "status": "actif",
4970 + "region": "Ontario — multi-régions"
4971 + },
4972 + {
4973 + "id": "qresidential",
4974 + "name": "Q Residential",
4975 + "url": "https://www.qresidential.ca",
4976 + "listing_url": "https://www.qresidential.ca/searchlisting.aspx",
4977 + "sectors": "Toronto, St. Catharines, Hamilton, Niagara, Kitchener",
4978 + "connector": "qresidential",
4979 + "status": "actif",
4980 + "region": "Ontario — multi-régions"
4981 + },
4982 + {
4983 + "id": "riocan",
4984 + "name": "RioCan Living",
4985 + "url": "https://riocanliving.com",
4986 + "listing_url": "https://riocanliving.com/residences/",
4987 + "sectors": "Toronto/GTA, Ottawa, Calgary (filtre ON)",
4988 + "connector": "riocan",
4989 + "status": "actif",
4990 + "region": "Ontario — multi-régions"
4991 + },
4992 + {
4993 + "id": "sifton",
4994 + "name": "Sifton Properties",
4995 + "url": "https://sifton.com",
4996 + "listing_url": "https://sifton.com/apartments",
4997 + "sectors": "London, Guelph, Brantford",
4998 + "connector": "sifton",
4999 + "status": "actif",
5000 + "region": "Ontario — multi-régions"
5001 + },
5002 + {
5003 + "id": "accommod8u",
5004 + "name": "Accommod8u",
5005 + "url": "https://www.accommod8u.com",
5006 + "listing_url": "https://www.accommod8u.com/residential/apartments",
5007 + "sectors": "Waterloo, Kitchener",
5008 + "connector": "accommod8u",
5009 + "status": "actif",
5010 + "region": "Ontario — multi-régions"
5011 + },
5012 + {
5013 + "id": "greenrock",
5014 + "name": "Greenrock (Davisville Village)",
5015 + "url": "https://greenrockreal.ca",
5016 + "listing_url": "https://www.sterlingkaramar.com/apartments-for-rent",
5017 + "sectors": "Toronto — Davisville Village",
5018 + "connector": "greenrock",
5019 + "status": "actif",
5020 + "region": "Ontario — Toronto — Davisville Village"
5021 + },
5022 + {
5023 + "id": "westdale",
5024 + "name": "Westdale Properties",
5025 + "url": "https://www.westdaleproperties.com",
5026 + "listing_url": "https://www.westdaleproperties.com/residential",
5027 + "sectors": "Toronto/North York",
5028 + "connector": "westdale",
5029 + "status": "actif",
5030 + "region": "Ontario — Toronto/North York"
4581 5031 }
4582 5032 ]
4583 −}
5033 +}
\ No newline at end of file
added louka/connectors/accommod8u.py +266 −0
@@ -0,0 +1,266 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/accommod8u.py : connecteur Accommod8u (accommod8u.com)
5 +# Gros gestionnaire de Waterloo ON (~10 immeubles/villages au moment de
6 +# l'écriture : tours Albert/Lester/Sunview, THE LINQ, villages étudiants
7 +# Linden/Spring/Walnut au bail mensuel — longue durée, donc pertinent).
8 +# Site « corporate » Yardi RentCafe derrière Cloudflare : curl nu = 403,
9 +# Scrapfly asp=true suffit (rendu serveur, PAS de render_js). Comme Killam,
10 +# la page /residential/apartments embarque TOUT le portefeuille dans
11 +# l'input caché `#available_prop` (JSON doublement encodé) : nom, adresse,
12 +# ville/ON/code postal, lat/lng, fourchette de loyer, politique animaux,
13 +# téléphone, vignette, occupation et l'URL du MICROSITE RentCafe de chaque
14 +# immeuble (apartments-waterloo-228albert.com…).
15 +# La page /floorplans du microsite (même anti-bot, via self.detail() +
16 +# Scrapfly) donne les cartes plans d'étage : « N Bed / N Bath / N Sq. Ft. /
17 +# Starting at $X /Month » quand des unités sont disponibles, « Call for
18 +# details » sinon (carte sans lien « Availability » → écartée, rien
19 +# d'inventé). Une annonce PAR PLAN D'ÉTAGE DISPONIBLE, avec repli « une
20 +# annonce par immeuble » (loyer plancher du flux) si aucune carte n'a de
21 +# prix mais que l'immeuble n'est pas complet. Les villages étudiants
22 +# affichent des loyers à la chambre (~600 $) : le nom du plan (« Room »,
23 +# « 4 Bedroom »…) donne le type d'unité, jamais de valeur inventée.
24 +# Immeubles IsFullyOccupied (ex. « Fir Village (Fully Leased) ») : ignorés.
25 +# Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.
26 +# -----------------------------------------------------------------------------
27 +from __future__ import annotations
28 +
29 +import hashlib
30 +import json
31 +import os
32 +import re
33 +
34 +from bs4 import BeautifulSoup
35 +
36 +from ..schema import Listing, normalize_unit_type, strip_accents
37 +from .base import BaseConnector
38 +
39 +BASE = "https://www.accommod8u.com"
40 +SEARCH_PAGE = f"{BASE}/residential/apartments"
41 +
42 +# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent
43 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
44 +
45 +# blob JSON du portefeuille (input caché RentCafe, doublement encodé)
46 +_PROP_BLOB_RE = re.compile(r"id=\"available_prop\"\s+value='(.*?)'", re.S)
47 +
48 +# cartes plans d'étage du microsite : « 1 Bed », « 2 Bath », « 660 Sq. Ft. »
49 +_BED_RE = re.compile(r"([\d.]+)\s*Bed\b")
50 +_BATH_RE = re.compile(r"([\d.]+)\s*Bath")
51 +_SQFT_RE = re.compile(r"([\d,]+)(?:-\s*to\s*[\d,]+)?\s*Sq\.?\s*Ft", re.I)
52 +_PRICE_RE = re.compile(r"\$\s*([\d,]+(?:\.\d{2})?)")
53 +
54 +
55 +class Accommod8uConnector(BaseConnector):
56 + source_id = "accommod8u"
57 + request_delay = 1.5 # tout passe par Scrapfly ASP : rester très poli
58 + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)
59 + max_properties = 20 # garde-fou (10 immeubles au 2026-08)
60 + max_images = 15
61 +
62 + # -- portefeuille : blob embarqué de la page de recherche --------------------
63 + def _properties(self) -> list[dict]:
64 + page = self.get_scrapfly(SEARCH_PAGE, render_js=False, asp=True)
65 + m = _PROP_BLOB_RE.search(page or "")
66 + if not m:
67 + raise RuntimeError("Accommod8u : blob #available_prop introuvable "
68 + "(gabarit RentCafe modifié ou échec ASP)")
69 + return json.loads(json.loads(m.group(1)))
70 +
71 + def fetch(self) -> list[Listing]:
72 + listings: list[Listing] = []
73 + count = 0
74 + for p in self._properties():
75 + if (p.get("propertyState") or "").strip().upper() != "ON":
76 + continue
77 + if p.get("IsFullyOccupied") is True:
78 + continue # complet (« Fully Leased ») : rien à publier
79 + if count >= self.max_properties:
80 + break
81 + count += 1
82 + try:
83 + listings.extend(self._listings(p))
84 + except Exception:
85 + continue
86 + return listings
87 +
88 + # -- annonces d'un immeuble (une par plan d'étage disponible) ----------------
89 + def _listings(self, p: dict) -> list[Listing]:
90 + pid = str(p.get("propertyid"))
91 + name = (p.get("propertyName") or "").strip()
92 + # microsite RentCafe de l'immeuble (sans le paramètre de tracking)
93 + site = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "")
94 + .split("?")[0].rstrip("/"))
95 + url = f"{site}/floorplans" if site.startswith("http") else SEARCH_PAGE
96 +
97 + city = (p.get("propertyCity") or "").strip()
98 + street = (p.get("propertyAddress") or "").strip()
99 + postal = (p.get("propertyZipCode") or "").strip()
100 + address = ", ".join(x for x in (street, city) if x) + ", ON"
101 + if postal:
102 + address += f" {postal}"
103 +
104 + try:
105 + lat = float(p.get("propertyLat")) or None
106 + lng = float(p.get("propertyLng")) or None
107 + except (TypeError, ValueError):
108 + lat = lng = None
109 +
110 + # politique animaux structurée du flux
111 + pets = None
112 + try:
113 + pol = p.get("bPetPolicy") or {}
114 + if isinstance(pol, str):
115 + pol = json.loads(pol)
116 + if pol.get("bNoPetsAllowed") is True:
117 + pets = "non"
118 + elif pol.get("bCats") and pol.get("bDogs"):
119 + pets = "oui"
120 + elif pol.get("bCats") or pol.get("bDogs"):
121 + pets = "conditions"
122 + except (ValueError, TypeError):
123 + pass
124 +
125 + details: dict = {}
126 + if (p.get("phone") or "").strip():
127 + details["contact"] = {"phone": p["phone"].strip()}
128 +
129 + # page /floorplans du microsite via le cache BD : revisitée seulement
130 + # quand la ligne du flux change (loyers, occupation, vignette)
131 + feed_key = hashlib.sha1("|".join(str(p.get(k)) for k in (
132 + "propertyMinRent", "propertyMaxRent", "propertyMinBed",
133 + "propertyMaxBed", "IsFullyOccupied", "dtMinUnitAvailable",
134 + "propertyThumb", "PropertySiteUrl",
135 + )).encode("utf-8")).hexdigest()
136 + d = self.detail(pid, feed_key, lambda: self._fetch_floorplans(url))
137 +
138 + thumb = (p.get("propertyThumb") or "").strip()
139 + base_images = [thumb] if thumb else []
140 +
141 + common = dict(
142 + source=self.source_id, url=url, address=address, city=city,
143 + province="ON", pets=pets, lat=lat, lng=lng,
144 + )
145 +
146 + out: list[Listing] = []
147 + for fp in d.get("floorplans") or []:
148 + if fp.get("price") is None:
149 + continue # « Call for details » sans lien Availability :
150 + # aucune unité annoncée disponible — rien d'inventé
151 + images = [u for u in base_images + (fp.get("images") or [])
152 + if u][: self.max_images]
153 + beds = fp.get("beds")
154 + slug = re.sub(r"[^a-z0-9]+", "-",
155 + strip_accents((fp.get("name") or "").lower())
156 + ).strip("-")
157 + out.append(Listing(
158 + external_id=f"{pid}-{slug or 'u'}",
159 + title=f"{name} — {fp['name']}" if fp.get("name") else name,
160 + unit_type=self._unit_type(fp.get("name") or "", beds),
161 + bedrooms=beds,
162 + bathrooms=fp.get("baths"),
163 + price=fp["price"],
164 + price_label=f"À partir de {fp['price']:.0f} $ /mois",
165 + availability="Unités disponibles",
166 + area_sqft=fp.get("sqft"),
167 + details=dict(details),
168 + images=images,
169 + **common,
170 + ))
171 + if out:
172 + return out
173 +
174 + # repli : une annonce par immeuble avec le loyer plancher du flux
175 + price = None
176 + try:
177 + price = float(p.get("propertyMinRent") or 0) or None
178 + except (TypeError, ValueError):
179 + pass
180 + if price is None:
181 + return [] # ni plan disponible ni loyer affiché : rien
182 + beds = None
183 + try:
184 + bmin = float(p.get("propertyMinBed") or -1)
185 + if bmin >= 0 and bmin == float(p.get("propertyMaxBed") or -1):
186 + beds = bmin
187 + except (TypeError, ValueError):
188 + pass
189 + return [Listing(
190 + external_id=pid,
191 + title=name,
192 + unit_type=("Studio" if beds == 0 else normalize_unit_type(
193 + f"{int(beds)} chambres") if beds is not None else ""),
194 + bedrooms=beds,
195 + price=price,
196 + price_label=f"À partir de {price:.0f} $ /mois",
197 + availability="Unités disponibles",
198 + details=details,
199 + images=base_images[: self.max_images],
200 + **common,
201 + )]
202 +
203 + @staticmethod
204 + def _unit_type(fp_name: str, beds: float | None) -> str:
205 + """Type d'unité : nom du plan (« Room » -> Chambre) sinon dérivé cc."""
206 + ut = normalize_unit_type(fp_name)
207 + if ut and ut != fp_name.strip():
208 + return ut
209 + if beds == 0:
210 + return "Studio"
211 + if beds is not None:
212 + return normalize_unit_type(f"{int(beds)} chambres")
213 + return ut
214 +
215 + # -- page /floorplans du microsite RentCafe ----------------------------------
216 + def _fetch_floorplans(self, url: str) -> dict:
217 + out: dict = {"floorplans": []}
218 + if not url.startswith("http"):
219 + return out
220 + try:
221 + page = self.get_scrapfly(url, render_js=False, asp=True)
222 + except Exception:
223 + return out
224 + if not page:
225 + return out
226 + soup = BeautifulSoup(page, "html.parser")
227 + seen: set[str] = set()
228 + for card in soup.select(".fp-container"):
229 + txt = card.get_text(" ", strip=True)
230 + h = card.find(["h2", "h3", "h4"])
231 + fp_name = h.get_text(" ", strip=True) if h else ""
232 + if not fp_name:
233 + # premier segment avant « N Bed » (gabarit compact)
234 + fp_name = re.split(r"\d+\s*Bed", txt)[0].strip()
235 + if not fp_name or fp_name.lower() in seen:
236 + continue
237 + seen.add(fp_name.lower())
238 + mb = _BED_RE.search(txt)
239 + mba = _BATH_RE.search(txt)
240 + msq = _SQFT_RE.search(txt)
241 + sqft = float(msq.group(1).replace(",", "")) if msq else None
242 + # prix seulement si des unités sont disponibles (lien Availability)
243 + price = None
244 + has_avail = card.find(
245 + "a", href=re.compile(r"/floorplans/")) is not None
246 + mp = _PRICE_RE.search(txt)
247 + if mp and has_avail:
248 + price = float(mp.group(1).replace(",", ""))
249 + images = []
250 + for img in card.find_all("img"):
251 + src = (img.get("src") or img.get("data-src") or "").strip()
252 + if src and "resource.rentcafe.com" in src \
253 + and src not in images:
254 + src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?",
255 + "c_limit,w_1200", src)
256 + images.append(src)
257 + out["floorplans"].append({
258 + "name": fp_name,
259 + "beds": (float(mb.group(1)) if mb
260 + else 0.0 if "Studio" in txt else None),
261 + "baths": float(mba.group(1)) if mba else None,
262 + "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,
263 + "price": price,
264 + "images": images[:5],
265 + })
266 + return out
added louka/connectors/appfolio.py +298 −0
@@ -0,0 +1,298 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/appfolio.py : connecteur GÉNÉRIQUE AppFolio (appfolio.com)
5 +# Certains gestionnaires ontariens publient leur parc sur le portail AppFolio :
6 +# `https://<sous-domaine>.appfolio.com/listings` — HTML rendu serveur (cartes
7 +# `.js-listing-item`), page unique « Showing all available listings », aucun
8 +# rendu JS ni pagination nécessaires. Plutôt que N modules copiés-collés, ce
9 +# module lit le registre data/appfolio_clients.json ({id, name, subdomain…},
10 +# validés en live le 2026-08-27) et GÉNÈRE une sous-classe de BaseConnector
11 +# par client (source_id = "apf_<id>"), déposée dans les globals du module
12 +# pour le registre auto-découvrant (connectors/__init__.py). Même gabarit
13 +# que liftsystem.py / buildium.py.
14 +# NB recherche 2026-08-27 : AppFolio est RARE en Ontario (longue traîne ON
15 +# massivement sur Buildium) — un seul client validé (Fuse, Barrie) après
16 +# balayage Serper + scan des ~240 gestionnaires cartographiés ; le connecteur
17 +# est prêt pour les suivants. Les portails servent tout le Canada/US et les
18 +# adresses sont en texte libre : la province est déduite du CODE POSTAL
19 +# canadien (première lettre K/L/M/N/P = ON, G/H/J = QC), tout le reste est
20 +# ignoré. Filtre résidentiel longue durée (exclusion parking, entreposage,
21 +# commercial, court terme). Galerie photo de la fiche via le cache BD
22 +# self.detail() (images.cdn.appfolio.com/<client>/images/<uuid>/large.jpg).
23 +#
24 +# Expansion Ontario — activer via LOUKA_ONTARIO=1
25 +# (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les
26 +# classes générées sont `disabled` et donc exclues du registre / de la prod.
27 +# -----------------------------------------------------------------------------
28 +from __future__ import annotations
29 +
30 +import hashlib
31 +import json
32 +import os
33 +import re
34 +from pathlib import Path
35 +from urllib.parse import urljoin
36 +
37 +from bs4 import BeautifulSoup
38 +
39 +from ..schema import Listing, normalize_unit_type, parse_price
40 +from .base import BaseConnector
41 +
42 +# Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=1
43 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
44 +
45 +REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \
46 + "appfolio_clients.json"
47 +
48 +# Code postal canadien dans l'adresse libre — la 1re lettre (district de tri)
49 +# donne la province sans ambiguïté : K/L/M/N/P = Ontario, G/H/J = Québec.
50 +# Les adresses US (« Chicago, IL 60649 ») ne matchent pas -> ignorées.
51 +_POSTAL_RE = re.compile(r"\b([A-Za-z])(\d[A-Za-z])\s?(\d[A-Za-z]\d)\b")
52 +_POSTAL_PROVINCE = {**{c: "ON" for c in "KLMNP"}, **{c: "QC" for c in "GHJ"}}
53 +
54 +# Résidentiel longue durée seulement : stationnements, entreposage, commerces
55 +# et court terme sont hors sujet pour Lou-Ka (logements au mois).
56 +_NON_RESIDENTIAL_RE = re.compile(
57 + r"\b(parking|stationnement|storage|entreposage|locker|garage only|"
58 + r"commercial|office space|retail|warehouse)\b", re.I)
59 +_SHORT_TERM_RE = re.compile(
60 + r"\b(short[\s-]?term|nightly|per night|par nuit|vacation rental|"
61 + r"weekly rate|airbnb|court terme|campsites?|campground|nature camp)\b",
62 + re.I)
63 +
64 +# « 3 bd / 1.5 ba » ou « Studio / 1 ba » des cartes AppFolio
65 +_BED_BATH_RE = re.compile(
66 + r"(studio|\d+(?:\.\d+)?)\s*bd?\s*/\s*(\d+(?:\.\d+)?)\s*ba", re.I)
67 +
68 +# galerie de la fiche détail (préférer les variantes large.jpg)
69 +_GALLERY_IMG_RE = re.compile(
70 + r"https://images\.cdn\.appfolio\.com/[^\"'\s\\)]+/large\.[a-z]{3,4}", re.I)
71 +
72 +# mots des segments d'adresse qui ne sont PAS une ville (voir _parse_address)
73 +_NOT_CITY = {"", "on", "ontario", "ca", "canada", "qc", "quebec", "québec"}
74 +
75 +
76 +def _load_registry() -> list[dict]:
77 + """Entrées validées du registre (subdomain présent, status == valide)."""
78 + try:
79 + data = json.loads(REGISTRY_PATH.read_text("utf-8"))
80 + except (OSError, ValueError):
81 + return []
82 + return [c for c in data.get("clients") or []
83 + if c.get("subdomain") and c.get("status") == "valide"]
84 +
85 +
86 +def _parse_address(raw: str) -> tuple[str, str, str] | None:
87 + """(ville, province, code postal) depuis l'adresse libre AppFolio, ou None
88 + si hors ON/QC. Formats vus en live : « 241 Nottawasaga Street - 2 (B),
89 + Orillia L3V 3K2 », « 300 Cundles St West - Upper, Barrie, ON, CA L4N 7C9 »,
90 + « 37 Peel St - Apt - 808, Barrie, CA L4M 3L1 »."""
91 + m = _POSTAL_RE.search(raw)
92 + if not m:
93 + return None # pas de code postal canadien -> US
94 + prov = _POSTAL_PROVINCE.get(m.group(1).upper())
95 + if not prov:
96 + return None # autre province canadienne
97 + postal = f"{m.group(1)}{m.group(2)} {m.group(3)}".upper()
98 + # ville = dernier segment (virgules) qui n'est ni ON/CA/Canada ni le postal
99 + before = raw[: m.start()].strip()
100 + for seg in reversed([s.strip() for s in before.split(",")]):
101 + seg = re.sub(r"\b(ON|QC|CA)\b\.?$", "", seg, flags=re.I).strip()
102 + if seg.lower() not in _NOT_CITY and not any(ch.isdigit() for ch in seg):
103 + return seg, prov, postal
104 + return "", prov, postal
105 +
106 +
107 +class AppfolioConnector(BaseConnector):
108 + """Base commune des connecteurs AppFolio générés — non enregistrée
109 + elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de
110 + registre dans l'attribut de classe `client`."""
111 +
112 + source_id = "" # les sous-classes générées le définissent
113 + client: dict = {} # entrée du registre (name, subdomain, site…)
114 + request_delay = 1.0 # politesse — petits gestionnaires, on y va mollo
115 + max_listings = 300 # garde-fou (client validé le plus gros : 25)
116 + max_images = 20
117 +
118 + @property
119 + def base_url(self) -> str:
120 + return f"https://{self.client['subdomain']}.appfolio.com"
121 +
122 + def fetch(self) -> list[Listing]:
123 + soup = BeautifulSoup(self.get(self.base_url + "/listings").text,
124 + "html.parser")
125 + listings: list[Listing] = []
126 + for item in soup.select(".js-listing-item"):
127 + if len(listings) >= self.max_listings: # garde-fou APRÈS filtres
128 + break
129 + lst = self._listing(item)
130 + if lst is not None:
131 + listings.append(lst)
132 + return listings
133 +
134 + # -- une annonce par carte .js-listing-item --------------------------------
135 + def _listing(self, item) -> Listing | None:
136 + try:
137 + link = item.select_one("a.js-link-to-detail") or \
138 + item.select_one('a[href*="/listings/detail/"]')
139 + if link is None:
140 + return None
141 + href = link.get("href") or ""
142 + m = re.search(r"/listings/detail/([0-9a-f-]{16,})", href)
143 + if not m:
144 + return None
145 + uid = m.group(1)
146 + url = urljoin(self.base_url, href)
147 +
148 + def txt(sel: str) -> str:
149 + el = item.select_one(sel)
150 + return el.get_text(" ", strip=True) if el else ""
151 +
152 + # adresse libre -> ville/province/postal via code postal canadien
153 + raw_addr = txt(".js-listing-address")
154 + parsed = _parse_address(raw_addr)
155 + if parsed is None:
156 + return None # US ou autre province : hors sujet
157 + city, prov, postal = parsed
158 +
159 + title = txt(".js-listing-title")
160 + description = txt(".js-listing-description")
161 + if _NON_RESIDENTIAL_RE.search(f"{title} {description[:200]}") or \
162 + _SHORT_TERM_RE.search(f"{title} {description[:200]}"):
163 + return None
164 +
165 + # loyer : bandeau mobile, sinon <dd> sous l'étiquette RENT
166 + price = parse_price(txt(".js-listing-blurb-rent"))
167 + area = None
168 + for box in item.select(".detail-box__item"):
169 + label = (box.select_one("dt") or box).get_text(" ", strip=True)
170 + value = box.select_one("dd")
171 + value = value.get_text(" ", strip=True) if value else ""
172 + low = label.lower()
173 + if "rent" in low and price is None:
174 + price = parse_price(value)
175 + elif "square" in low:
176 + try:
177 + v = float(value.replace(",", "").strip() or 0)
178 + if 80 <= v <= 20000:
179 + area = v
180 + except ValueError:
181 + pass
182 +
183 + # « 3 bd / 1.5 ba » / « Studio / 1 ba » — sinon None (inconnu)
184 + bedrooms = bathrooms = None
185 + unit_type = ""
186 + bb = _BED_BATH_RE.search(txt(".js-listing-blurb-bed-bath") or
187 + item.get_text(" ", strip=True))
188 + if bb:
189 + if bb.group(1).lower() == "studio":
190 + bedrooms, unit_type = 0.0, "Studio"
191 + else:
192 + bedrooms = float(bb.group(1))
193 + unit_type = normalize_unit_type(f"{bedrooms:g} chambres")
194 + bathrooms = float(bb.group(2))
195 +
196 + availability = txt(".js-listing-available")
197 +
198 + # paragraphe « Utilities Included: … / Appliances: … » de la carte
199 + details: dict = {}
200 + for p in item.select(".listing-item__body p"):
201 + for b in p.find_all("span", style=re.compile("bold")):
202 + key = b.get_text(strip=True).rstrip(":").strip()
203 + val = (b.next_sibling or "")
204 + val = str(val).strip() if isinstance(val, str) else ""
205 + if key and val:
206 + details[key] = val
207 +
208 + # aperçu photo de la carte (lazy-load : data-original)
209 + preview = ""
210 + img = item.select_one("img.js-listing-image")
211 + if img:
212 + preview = (img.get("data-original") or "").strip()
213 + if "place_holder" in preview:
214 + preview = ""
215 +
216 + # fiche détail (description complète + galerie large.jpg) via le
217 + # cache BD : revisitée seulement quand la carte de la liste change
218 + feed_key = hashlib.sha1("|".join(str(x) for x in (
219 + price, availability, preview, raw_addr, description[:80],
220 + )).encode("utf-8")).hexdigest()
221 + d = self.detail(uid, feed_key, lambda: self._fetch_detail(url))
222 + full_desc = (d.get("description") or description).strip()
223 + images = [u for u in [preview] + list(d.get("images") or []) if u]
224 + images = list(dict.fromkeys(images)) # dédup en gardant l'ordre
225 +
226 + address = raw_addr
227 + return Listing(
228 + source=self.source_id,
229 + external_id=uid,
230 + url=url,
231 + title=title or address,
232 + address=address,
233 + city=city,
234 + province=prov,
235 + unit_type=unit_type,
236 + bedrooms=bedrooms,
237 + bathrooms=bathrooms,
238 + price=price,
239 + price_label=f"{price:.0f} $ /mois" if price else "",
240 + availability=availability,
241 + area_sqft=area,
242 + description=full_desc[:1200],
243 + details=details,
244 + images=images[: self.max_images],
245 + )
246 + except Exception:
247 + return None
248 +
249 + def _fetch_detail(self, url: str) -> dict:
250 + """Description complète + galerie (large.jpg) de la fiche détail."""
251 + out: dict = {"images": [], "description": ""}
252 + if not url:
253 + return out
254 + try:
255 + page = self.get(url).text
256 + except Exception:
257 + return out
258 + soup = BeautifulSoup(page, "html.parser")
259 + desc = soup.select_one(".listing-detail__description")
260 + if desc:
261 + out["description"] = desc.get_text("\n", strip=True)
262 + images: list[str] = []
263 + for u in _GALLERY_IMG_RE.findall(page):
264 + if u not in images:
265 + images.append(u)
266 + out["images"] = images[: self.max_images]
267 + return out
268 +
269 +
270 +# -----------------------------------------------------------------------------
271 +# Génération : une sous-classe par client du registre, déposée dans les globals
272 +# du module — connectors/__init__.py (scan de vars(module)) les découvre alors
273 +# comme n'importe quel connecteur écrit à la main.
274 +# -----------------------------------------------------------------------------
275 +def _make_connector(entry: dict) -> type[AppfolioConnector]:
276 + cls = type(
277 + f"Apf{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",
278 + (AppfolioConnector,),
279 + {
280 + "source_id": f"apf_{entry['id']}",
281 + "client": entry,
282 + # Expansion Ontario — activer via LOUKA_ONTARIO=1
283 + # (voir gestion-immobiliere-ontario.md)
284 + "disabled": not _ONTARIO,
285 + "__doc__": f"Connecteur AppFolio généré — {entry.get('name')} "
286 + f"({entry.get('subdomain')}.appfolio.com).",
287 + },
288 + )
289 + return cls
290 +
291 +
292 +def _register_all() -> None:
293 + for entry in _load_registry():
294 + cls = _make_connector(entry)
295 + globals()[cls.__name__] = cls
296 +
297 +
298 +_register_all()
added louka/connectors/buildium.py +403 −0
@@ -0,0 +1,403 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/buildium.py : connecteur GÉNÉRIQUE Buildium (managebuilding.com)
5 +# La longue traîne des petits gestionnaires ontariens (Sarnia, Chatham-Kent,
6 +# Cobourg, Kingston, Ottawa, le Nord…) publie ses annonces sur le portail
7 +# Buildium : `https://<sous-domaine>.managebuilding.com/Resident/public/rentals`.
8 +# Contrairement à ce qu'on pouvait craindre, PAS besoin de rendu JS : Buildium
9 +# sert deux mises en page, toutes deux exploitables en HTML brut :
10 +# - « moderne » : cartes `a.featured-listing` rendues serveur, avec
11 +# data-bedrooms/-bathrooms/-rent/-square-feet/-location/-type ;
12 +# - « héritée » : redirection 302 vers PublicPages/ApartmentSearch.aspx dont
13 +# le viewmodel DotVVM (`__dot_viewmodel_root`) embarque les annonces en
14 +# JSON complet (city/state/postalcode, description, contact…).
15 +# Plutôt que N modules copiés-collés, ce module lit le registre
16 +# data/buildium_clients.json ({id, name, subdomain…}, sous-domaines découverts
17 +# via Serper/sites vitrines et validés en live le 2026-08-27) et GÉNÈRE une
18 +# sous-classe de BaseConnector par client (source_id = "bld_<id>"), déposée
19 +# dans les globals du module pour le registre auto-découvrant
20 +# (connectors/__init__.py). Même gabarit que liftsystem.py.
21 +# Les portails sont pancanadiens/US : filtre province strict (ON/QC) sur le
22 +# data-location (moderne) ou le champ state (héritée), et filtre résidentiel
23 +# longue durée (exclusion parking, entreposage, commercial, court terme).
24 +# Galerie photo de la fiche via le cache BD self.detail() (revisitée quand la
25 +# ligne de la liste change) ; miniatures `_LxH` remises en pleine résolution.
26 +#
27 +# Expansion Ontario — activer via LOUKA_ONTARIO=1
28 +# (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les
29 +# classes générées sont `disabled` et donc exclues du registre / de la prod.
30 +# -----------------------------------------------------------------------------
31 +from __future__ import annotations
32 +
33 +import hashlib
34 +import html as htmllib
35 +import json
36 +import os
37 +import re
38 +from datetime import datetime
39 +from pathlib import Path
40 +from urllib.parse import urljoin
41 +
42 +from bs4 import BeautifulSoup
43 +
44 +from ..schema import Listing, normalize_unit_type
45 +from .base import BaseConnector
46 +
47 +# Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=1
48 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
49 +
50 +REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \
51 + "buildium_clients.json"
52 +
53 +# Provinces couvertes par Lou-Ka — les portails Buildium servent tout le
54 +# Canada et les États-Unis (codes d'état à 2 lettres aussi) : tout le reste
55 +# est ignoré au fetch.
56 +_PROVINCES = {"ON", "QC"}
57 +
58 +# Types de propriété NON résidentiels de la mise en page moderne (data-type) —
59 +# stationnements, entreposage et commercial sont hors sujet pour Lou-Ka.
60 +_NON_RESIDENTIAL_TYPES = {
61 + "parkingspace", "parking", "storage", "commercial", "commercialother",
62 + "office", "retail", "industrial", "warehouse", "shoppingcenter", "land",
63 +}
64 +
65 +# Filet complémentaire sur le titre/type d'unité (la mise en page héritée n'a
66 +# pas de data-type fiable ; certains clients titrent « PARKING - … »).
67 +_NON_RESIDENTIAL_RE = re.compile(
68 + r"\b(parking|stationnement|storage|entreposage|locker|garage only|"
69 + r"commercial|office space|retail|warehouse)\b", re.I)
70 +
71 +# Locations court terme / vacances (hors sujet : Lou-Ka = logements au mois)
72 +_SHORT_TERM_RE = re.compile(
73 + r"\b(short[\s-]?term|nightly|per night|par nuit|vacation rental|"
74 + r"weekly rate|airbnb|court terme|campsites?|campground|nature camp)\b",
75 + re.I)
76 +
77 +# miniatures Buildium : « <hash>_406x539.jpeg » -> pleine résolution sans suffixe
78 +_THUMB_SUFFIX_RE = re.compile(r"_\d{2,4}x\d{2,4}(\.[a-z]{3,4})$", re.I)
79 +
80 +# date de disponibilité de la mise en page héritée (« Friday, August 15, 2025 »)
81 +_LEGACY_DATE_FMT = "%A, %B %d, %Y"
82 +
83 +
84 +def _load_registry() -> list[dict]:
85 + """Entrées validées du registre (subdomain présent, status == valide)."""
86 + try:
87 + data = json.loads(REGISTRY_PATH.read_text("utf-8"))
88 + except (OSError, ValueError):
89 + return []
90 + return [c for c in data.get("clients") or []
91 + if c.get("subdomain") and c.get("status") == "valide"]
92 +
93 +
94 +def _full_image(url: str) -> str:
95 + """Remet une miniature Buildium (…_406x539.jpeg) en pleine résolution."""
96 + return _THUMB_SUFFIX_RE.sub(r"\1", url.replace("&amp;", "&")
97 + .replace("&isPreview=true", ""))
98 +
99 +
100 +class BuildiumConnector(BaseConnector):
101 + """Base commune des connecteurs Buildium générés — non enregistrée
102 + elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de
103 + registre dans l'attribut de classe `client`."""
104 +
105 + source_id = "" # les sous-classes générées le définissent
106 + client: dict = {} # entrée du registre (name, subdomain, site…)
107 + request_delay = 1.0 # politesse — petits gestionnaires, on y va mollo
108 + max_listings = 300 # garde-fou (plus gros client : Sleepwell, 130)
109 + max_images = 20
110 +
111 + @property
112 + def base_url(self) -> str:
113 + return f"https://{self.client['subdomain']}.managebuilding.com"
114 +
115 + def fetch(self) -> list[Listing]:
116 + resp = self.get(self.base_url + "/Resident/public/rentals")
117 + page = resp.text
118 + soup = BeautifulSoup(page, "html.parser")
119 +
120 + cards = soup.select("a.featured-listing")
121 + if cards: # mise en page moderne
122 + rows = [self._listing_modern(c) for c in cards]
123 + elif "__dot_viewmodel_root" in page: # mise en page héritée
124 + rows = [self._listing_legacy(d) for d in self._legacy_rows(page)]
125 + else: # portail sans annonces publiques (login…) : rien à faire
126 + return []
127 +
128 + listings: list[Listing] = []
129 + for lst in rows:
130 + if lst is None:
131 + continue
132 + if len(listings) >= self.max_listings: # garde-fou APRÈS filtres
133 + break
134 + listings.append(lst)
135 + return listings
136 +
137 + # -- mise en page moderne : une annonce par carte a.featured-listing -------
138 + def _listing_modern(self, card) -> Listing | None:
139 + try:
140 + href = card.get("href") or ""
141 + m = re.search(r"/Resident/public/rentals/(\d+)", href)
142 + if not m:
143 + return None
144 + pid = m.group(1)
145 +
146 + # data-location = « Windsor,ON|N9B 2N6 » — filtre province strict
147 + loc = card.get("data-location") or ""
148 + city, _, rest = loc.partition(",")
149 + prov, _, postal = rest.partition("|")
150 + prov = prov.strip().upper()
151 + if prov not in _PROVINCES:
152 + return None
153 + city, postal = city.strip(), postal.strip()
154 +
155 + ptype = (card.get("data-type") or "").strip().lower()
156 + if ptype in _NON_RESIDENTIAL_TYPES:
157 + return None # stationnement/commerce : hors sujet
158 +
159 + def txt(sel: str) -> str:
160 + el = card.select_one(sel)
161 + return el.get_text(" ", strip=True) if el else ""
162 +
163 + title = txt(".featured-listing__title")
164 + desc_excerpt = txt(".featured-listing__description")
165 + if _NON_RESIDENTIAL_RE.search(f"{title} {desc_excerpt}") or \
166 + _SHORT_TERM_RE.search(f"{title} {desc_excerpt}"):
167 + return None
168 +
169 + # chambres / salles de bain / loyer / pi² : attributs structurés —
170 + # jamais de valeur inventée (0 pi² = non renseigné chez Buildium)
171 + def num(attr: str) -> float | None:
172 + try:
173 + return float(card.get(attr))
174 + except (TypeError, ValueError):
175 + return None
176 + bedrooms = num("data-bedrooms")
177 + bathrooms = num("data-bathrooms")
178 + price = num("data-rent")
179 + if price is not None and price <= 0:
180 + price = None
181 + area = num("data-square-feet")
182 + if area is not None and not 80 <= area <= 20000:
183 + area = None
184 +
185 + unit_type = ""
186 + if bedrooms is not None:
187 + unit_type = "Studio" if bedrooms == 0 else \
188 + normalize_unit_type(f"{bedrooms:g} chambres")
189 +
190 + availability = txt(".featured-listing__availability")
191 +
192 + # adresse civique : le titre Buildium EST la rue (+ n° d'unité)
193 + address = ", ".join(x for x in (title, city) if x)
194 + if address and prov:
195 + address += f", {prov} {postal}".rstrip()
196 +
197 + url = urljoin(self.base_url, href)
198 +
199 + # aperçu photo de la carte (miniature -> pleine résolution)
200 + preview = ""
201 + img = card.select_one("img.featured-listing__image")
202 + if img and img.get("src"):
203 + preview = _full_image(urljoin(self.base_url, img["src"]))
204 +
205 + # fiche détail (description complète + galerie) via le cache BD :
206 + # revisitée seulement quand la carte de la liste change
207 + feed_key = hashlib.sha1("|".join(str(x) for x in (
208 + card.get("data-rent"), availability, preview,
209 + card.get("data-bedrooms"), desc_excerpt[:80],
210 + )).encode("utf-8")).hexdigest()
211 + d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))
212 + description = (d.get("description") or desc_excerpt).strip()
213 + images = [u for u in [preview] + list(d.get("images") or []) if u]
214 + images = list(dict.fromkeys(images)) # dédup en gardant l'ordre
215 +
216 + return Listing(
217 + source=self.source_id,
218 + external_id=pid,
219 + url=url,
220 + title=title,
221 + address=address,
222 + city=city,
223 + province=prov,
224 + unit_type=unit_type,
225 + bedrooms=bedrooms,
226 + bathrooms=bathrooms,
227 + price=price,
228 + price_label=f"{price:.0f} $ /mois" if price else "",
229 + availability=availability,
230 + area_sqft=area,
231 + description=description[:1200],
232 + images=images[: self.max_images],
233 + )
234 + except Exception:
235 + return None
236 +
237 + def _fetch_detail(self, url: str) -> dict:
238 + """Description complète + galerie de la fiche (mise en page moderne)."""
239 + out: dict = {"images": [], "description": ""}
240 + if not url:
241 + return out
242 + try:
243 + soup = BeautifulSoup(self.get(url).text, "html.parser")
244 + except Exception:
245 + return out
246 + desc = soup.select_one(".unit-detail__description")
247 + if desc:
248 + out["description"] = desc.get_text("\n", strip=True)
249 + gallery = soup.select_one(".unit-detail__gallery")
250 + if gallery:
251 + images: list[str] = []
252 + for el in gallery.find_all(("img", "a")):
253 + u = el.get("src") or el.get("href") or ""
254 + if "files/download" not in u:
255 + continue
256 + u = _full_image(urljoin(self.base_url, u))
257 + if u not in images:
258 + images.append(u)
259 + out["images"] = images[: self.max_images]
260 + return out
261 +
262 + # -- mise en page héritée : annonces embarquées dans le viewmodel DotVVM ---
263 + def _legacy_rows(self, page: str) -> list[dict]:
264 + """Extrait ContentViewModel.Listings du JSON __dot_viewmodel_root."""
265 + marker = "__dot_viewmodel_root value='"
266 + i = page.find(marker)
267 + if i < 0:
268 + return []
269 + start = i + len(marker)
270 + end = page.find("'", start) # le JSON encode ses ' en &#39;
271 + try:
272 + vm = json.loads(htmllib.unescape(page[start:end]))
273 + except ValueError:
274 + return []
275 + rows = (((vm.get("viewModel") or {}).get("ContentViewModel") or {})
276 + .get("Listings") or [])
277 + # chaque ligne est une liste de paires {Key, Value}
278 + return [{kv.get("Key"): kv.get("Value") for kv in row
279 + if isinstance(kv, dict)}
280 + for row in rows if isinstance(row, list)]
281 +
282 + def _listing_legacy(self, d: dict) -> Listing | None:
283 + try:
284 + pid = str(d.get("listingid") or "").strip()
285 + prov = str(d.get("state") or "").strip().upper()
286 + if not pid or prov not in _PROVINCES:
287 + return None
288 +
289 + title = (d.get("buildingname") or d.get("propertyname") or "").strip()
290 + unittype_src = (d.get("unittype") or "").strip() # « 3 Bed - 1 Bath »
291 + description = (d.get("unitdescription")
292 + or d.get("propertydescription") or "").strip()
293 + if _NON_RESIDENTIAL_RE.search(f"{title} {unittype_src}") or \
294 + _SHORT_TERM_RE.search(f"{title} {unittype_src} "
295 + f"{description[:400]}"):
296 + return None
297 +
298 + city = (d.get("city") or "").strip()
299 + postal = (d.get("postalcode") or "").strip()
300 + street = (d.get("line1") or "").strip() or title
301 + address = ", ".join(x for x in (street, city) if x)
302 + if address:
303 + address += f", {prov} {postal}".rstrip()
304 +
305 + # chambres/bains : « 3 Bed » / « 1.5 Bath » — sinon None (inconnu)
306 + def count(text: str) -> float | None:
307 + m = re.match(r"\s*(\d+(?:\.\d+)?)", str(text or ""))
308 + return float(m.group(1)) if m else None
309 + bedrooms = count(d.get("bedtypedesc"))
310 + bathrooms = count(d.get("bathtypedesc"))
311 + unit_type = ""
312 + if bedrooms is not None:
313 + unit_type = "Studio" if bedrooms == 0 else \
314 + normalize_unit_type(f"{bedrooms:g} chambres")
315 +
316 + try:
317 + price = float(d.get("listingrent") or 0) or None
318 + except (TypeError, ValueError):
319 + price = None
320 + try:
321 + area = float(d.get("squarefootage") or 0)
322 + area = area if 80 <= area <= 20000 else None
323 + except (TypeError, ValueError):
324 + area = None
325 +
326 + # disponibilité : format anglais long et fixe du viewmodel
327 + availability = str(d.get("availabilitydate") or "").strip()
328 + avail_date = None
329 + try:
330 + avail_date = datetime.strptime(
331 + availability, _LEGACY_DATE_FMT).date().isoformat()
332 + except ValueError:
333 + pass
334 +
335 + details: dict = {}
336 + contact = {k: str(d.get(f"contact{k}") or "").strip()
337 + for k in ("phone", "email")}
338 + contact = {k: v for k, v in contact.items() if v}
339 + if contact:
340 + details["contact"] = contact
341 +
342 + url = (f"{self.base_url}/Resident/PublicPages/apartmentdetail.aspx"
343 + f"?listingId={pid}&unitid={d.get('unitid') or ''}"
344 + f"&buildingid={d.get('buildingid') or ''}")
345 +
346 + images = []
347 + img = str(d.get("imageUrl") or "").strip()
348 + if img and "files/download" in img:
349 + images.append(_full_image(urljoin(self.base_url, img)))
350 +
351 + return Listing(
352 + source=self.source_id,
353 + external_id=pid,
354 + url=url,
355 + title=title,
356 + address=address,
357 + city=city,
358 + province=prov,
359 + unit_type=unit_type,
360 + bedrooms=bedrooms,
361 + bathrooms=bathrooms,
362 + price=price,
363 + price_label=f"{price:.0f} $ /mois" if price else "",
364 + availability=availability,
365 + availability_date=avail_date,
366 + area_sqft=area,
367 + description=description[:1200],
368 + details=details,
369 + images=images[: self.max_images],
370 + )
371 + except Exception:
372 + return None
373 +
374 +
375 +# -----------------------------------------------------------------------------
376 +# Génération : une sous-classe par client du registre, déposée dans les globals
377 +# du module — connectors/__init__.py (scan de vars(module)) les découvre alors
378 +# comme n'importe quel connecteur écrit à la main.
379 +# -----------------------------------------------------------------------------
380 +def _make_connector(entry: dict) -> type[BuildiumConnector]:
381 + cls = type(
382 + f"Bld{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",
383 + (BuildiumConnector,),
384 + {
385 + "source_id": f"bld_{entry['id']}",
386 + "client": entry,
387 + # Expansion Ontario — activer via LOUKA_ONTARIO=1
388 + # (voir gestion-immobiliere-ontario.md)
389 + "disabled": not _ONTARIO,
390 + "__doc__": f"Connecteur Buildium généré — {entry.get('name')} "
391 + f"({entry.get('subdomain')}.managebuilding.com).",
392 + },
393 + )
394 + return cls
395 +
396 +
397 +def _register_all() -> None:
398 + for entry in _load_registry():
399 + cls = _make_connector(entry)
400 + globals()[cls.__name__] = cls
401 +
402 +
403 +_register_all()
added louka/connectors/greenrock.py +320 −0
@@ -0,0 +1,320 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/greenrock.py : connecteur Greenrock (Davisville Village, Toronto)
5 +# IMPASSE VÉRIFIÉE 2026-08-27 sur les sites Greenrock eux-mêmes :
6 +# - greenrock.ca : NXDOMAIN (n'existe plus) ;
7 +# - greenrockrsca.com (ancien site locatif Rentsync, vivant encore le
8 +# 2026-06-13 d'après la Wayback Machine) : 522 Cloudflare persistant,
9 +# origine décommissionnée ; sa clé de passerelle Rentsync `greenrock`
10 +# répond encore mais avec 0 propriété (portefeuille vidé) ;
11 +# - Village Green (40/50 Alexander, 55 Maitland…) : VENDU — désormais
12 +# commercialisé par Brookfield Properties (hors périmètre de ce fichier).
13 +# Ce qui RESTE à Greenrock (greenrockreal.ca, « Greenrock Portfolio ») :
14 +# Davisville Village à Toronto — 45 Balliol Street, 225 Davisville Avenue,
15 +# Balliol & Davisville Townhomes (226-228 Balliol n'a pas de page locative).
16 +# Ces immeubles sont maintenant GÉRÉS/AFFICHÉS PAR STERLING KARAMAR : on
17 +# les lit sur la passerelle JSON publique Rentsync nouvelle génération
18 +# `website-gateway-cdn.rentsync.com/v1/sterlingkaramar/…` (zéro auth, zéro
19 +# anti-bot — même mécanique que skyline.py), STRICTEMENT limités au
20 +# portefeuille Greenrock par une liste blanche d'importId Yardi stables
21 +# (yardi:ball0045, yardi:davi0225, yardi:davi0207). Une annonce par type
22 +# d'unité disponible (rangées /units regroupées par plan, loyer plancher).
23 +# ⚠️ Si un connecteur Sterling Karamar complet est écrit un jour (216+
24 +# immeubles sur la même passerelle, suite notée dans project_ontario.md),
25 +# retirer ce fichier ou y exclure ces trois importId pour éviter le doublon.
26 +# Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.
27 +# -----------------------------------------------------------------------------
28 +from __future__ import annotations
29 +
30 +import html as htmllib
31 +import os
32 +import re
33 +
34 +from bs4 import BeautifulSoup
35 +
36 +from ..schema import Listing, normalize_unit_type, strip_accents
37 +from .base import BaseConnector
38 +
39 +SITE = "https://www.sterlingkaramar.com"
40 +GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/sterlingkaramar"
41 +IMG_BASE = ("https://s3.amazonaws.com/lws_lift/sterlingkaramar/images/"
42 + "gallery/full")
43 +
44 +# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent
45 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
46 +
47 +# Portefeuille Greenrock (greenrockreal.ca) sur la passerelle Sterling
48 +# Karamar : identifiants d'import Yardi (stables, contrairement aux ids)
49 +_GREENROCK_IMPORT_IDS = {
50 + "yardi:ball0045", # 45 Balliol Street
51 + "yardi:davi0225", # 225 Davisville Avenue
52 + "yardi:davi0207", # Balliol & Davisville Townhomes
53 +}
54 +
55 +_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")
56 +_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)
57 +
58 +
59 +class GreenrockConnector(BaseConnector):
60 + source_id = "greenrock"
61 + request_delay = 1.0
62 + disabled = not _ONTARIO # gate Ontario (voir en-tête)
63 + max_properties = 10 # garde-fou (3 immeubles au 2026-08)
64 + max_listings = 100 # garde-fou global (20 rangées au 2026-08)
65 + max_images = 15
66 + page_limit = 200
67 +
68 + # -- accès passerelle ---------------------------------------------------------
69 + def _get_json(self, path: str, params: dict) -> dict:
70 + resp = self.get(GATEWAY + path, params=params, headers={
71 + "Accept": "application/json",
72 + "Origin": SITE,
73 + "Referer": SITE + "/",
74 + "rs-lang": "en",
75 + })
76 + return resp.json()
77 +
78 + def _paged(self, path: str, params: dict, max_pages: int = 5) -> list[dict]:
79 + out: list[dict] = []
80 + page = 1
81 + while page <= max_pages:
82 + data = self._get_json(path, {**params, "page": page})
83 + out.extend(data.get("data") or [])
84 + meta = data.get("meta") or {}
85 + if page >= int(meta.get("totalPages") or 1):
86 + break
87 + page += 1
88 + return out
89 +
90 + # -- collecte -----------------------------------------------------------------
91 + def fetch(self) -> list[Listing]:
92 + # immeubles du portefeuille Greenrock seulement (liste blanche)
93 + props = [p for p in self._paged("/properties",
94 + {"limit": self.page_limit})
95 + if (p.get("importId") or "").strip()
96 + in _GREENROCK_IMPORT_IDS][: self.max_properties]
97 + if not props:
98 + return []
99 + by_id = {int(p["id"]): p for p in props}
100 + ids = "|".join(str(i) for i in sorted(by_id))
101 +
102 + # villes -> nom + garde-fou provincial (Toronto ON attendu)
103 + cities: dict[int, tuple[str, str]] = {}
104 + for c in self._paged("/cities", {
105 + "where": "id~in:" + "|".join(
106 + sorted({str(p.get("cityId")) for p in props
107 + if p.get("cityId")})),
108 + "relations": "province:p",
109 + "limit": self.page_limit,
110 + }):
111 + prov = c.get("province") or {}
112 + cities[int(c["id"])] = (
113 + (c.get("cityName") or "").strip(),
114 + (prov.get("provinceCode") or "").strip().upper())
115 +
116 + # rangées d'unités disponibles des seuls immeubles Greenrock,
117 + # regroupées par (immeuble, plan, cc, sdb) = une annonce par type
118 + units = self._paged("/units", {
119 + "where": f"status:enabled,available~in:1,buildingId~in:{ids}",
120 + "limit": self.page_limit,
121 + })
122 + by_building: dict[int, dict[tuple, list[dict]]] = {}
123 + for u in units:
124 + bid = int(u.get("buildingId") or 0)
125 + if bid not in by_id:
126 + continue
127 + key = ((u.get("typeName") or "").strip().lower(),
128 + u.get("bedMin", u.get("bed")),
129 + u.get("bathMin", u.get("bath")))
130 + by_building.setdefault(bid, {}).setdefault(key, []).append(u)
131 +
132 + # galeries photo par immeuble
133 + photos: dict[int, list[str]] = {}
134 + try:
135 + for ph in self._paged("/photos", {
136 + "relations": "buildingsHasPhotos:bhp",
137 + "where": f"bhp.buildingId~in:{ids}",
138 + "orderBy": "bhp.orderBy~asc",
139 + "limit": self.page_limit,
140 + }):
141 + bid, img = ph.get("buildingId"), (ph.get("image") or "").strip()
142 + if not bid or not img:
143 + continue
144 + urls = photos.setdefault(int(bid), [])
145 + u = f"{IMG_BASE}/{img}"
146 + if len(urls) < self.max_images and u not in urls:
147 + urls.append(u)
148 + except Exception:
149 + pass # galerie manquante : annonces sans photo plutôt que rien
150 +
151 + listings: list[Listing] = []
152 + for bid, groups in by_building.items():
153 + p = by_id[bid]
154 + city, prov = cities.get(int(p.get("cityId") or 0), ("", ""))
155 + if prov != "ON" or not city:
156 + continue
157 + try:
158 + base = self._building_ctx(p, city, photos.get(bid) or [])
159 + except Exception:
160 + continue
161 + for rows in groups.values():
162 + if len(listings) >= self.max_listings:
163 + break
164 + try:
165 + listings.append(self._listing(bid, rows, base))
166 + except Exception:
167 + continue
168 + return listings
169 +
170 + # -- contexte immeuble (partagé entre ses annonces) -----------------------------
171 + def _building_ctx(self, p: dict, city: str, images: list[str]) -> dict:
172 + street = " ".join(x for x in (
173 + (p.get("streetNumber") or "").strip(),
174 + (p.get("streetName") or "").strip()) if x)
175 + postal = (p.get("postal") or "").strip()
176 + address = ", ".join(x for x in (street, city) if x)
177 + if address:
178 + address += f", ON {postal}".rstrip()
179 +
180 + sector = (p.get("neighbourhood") or "").strip()
181 + if not sector or _COUNTY_RE.search(sector):
182 + sector = "Davisville Village" # quartier réel du portefeuille
183 +
184 + try:
185 + lat = float(p["latitude"]) if p.get("latitude") else None
186 + lng = float(p["longitude"]) if p.get("longitude") else None
187 + except (TypeError, ValueError):
188 + lat = lng = None
189 +
190 + amenities: list[str] = []
191 + feats = htmllib.unescape(p.get("buildingFeatures") or "")
192 + for li in BeautifulSoup(feats, "html.parser").find_all("li"):
193 + t = li.get_text(" ", strip=True)
194 + if t and t not in amenities:
195 + amenities.append(t)
196 +
197 + desc_html = (p.get("buildingOverview") or "") + " " + \
198 + (p.get("suiteDetails") or "")
199 + desc = BeautifulSoup(htmllib.unescape(desc_html),
200 + "html.parser").get_text(" ", strip=True)
201 +
202 + pets = None
203 + if p.get("petsNotAllowed") == 1:
204 + pets = "non"
205 + elif p.get("petFriendly") == 1:
206 + pets = "oui"
207 +
208 + details: dict = {"Gestion": "Sterling Karamar (portefeuille Greenrock)"}
209 + contact: dict = {}
210 + if (p.get("phone") or "").strip():
211 + contact["phone"] = p["phone"].strip()
212 + email = (p.get("email") or "").split(",")[0].strip()
213 + if email:
214 + contact["email"] = email
215 + if contact:
216 + details["contact"] = contact
217 + try:
218 + if int(p.get("yearBuilt") or 0) > 1800:
219 + details["Année de construction"] = int(p["yearBuilt"])
220 + if int(p.get("floorCount") or 0) > 0:
221 + details["Étages"] = int(p["floorCount"])
222 + except (TypeError, ValueError):
223 + pass
224 +
225 + perma = (p.get("fullPermaLink") or "").strip().strip("/")
226 + url = f"{SITE}/{perma}" if perma else SITE
227 +
228 + return {"city": city, "address": address, "sector": sector,
229 + "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,
230 + "pets": pets, "details": details, "url": url,
231 + "images": images[: self.max_images],
232 + "name": htmllib.unescape((p.get("buildingName") or "")
233 + .strip())}
234 +
235 + # -- une annonce par type d'unité disponible (rangées regroupées) ---------------
236 + def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:
237 + u = rows[0]
238 + type_name = (u.get("typeName") or "").strip()
239 + unit_type = normalize_unit_type(type_name)
240 + bed = u.get("bedMin", u.get("bed"))
241 + bath = u.get("bathMin", u.get("bath"))
242 + if bed is not None and (not unit_type or unit_type == type_name):
243 + unit_type = ("Studio" if int(bed) == 0
244 + else normalize_unit_type(f"{int(bed)} chambres"))
245 +
246 + # loyer : plancher du groupe (masqué si hideRateWebsites=1)
247 + rates: list[float] = []
248 + for r in rows:
249 + if r.get("hideRateWebsites"):
250 + continue
251 + try:
252 + lo = float(r.get("rateMin") or r.get("rate") or 0)
253 + hi = float(r.get("rateMax") or lo)
254 + except (TypeError, ValueError):
255 + continue
256 + if lo > 0:
257 + rates += [lo, max(hi, lo)]
258 + price = min(rates) if rates else None
259 + price_label = ""
260 + if price is not None:
261 + price_label = (f"À partir de {price:.0f} $" if max(rates) > price
262 + else f"{price:.0f} $ /mois")
263 +
264 + # superficie plancher plausible (le flux publie parfois 0)
265 + area = None
266 + for r in rows:
267 + try:
268 + v = float(r.get("sqFtMin") or r.get("sqFt") or 0)
269 + except (TypeError, ValueError):
270 + continue
271 + if 80 <= v <= 20000 and (area is None or v < area):
272 + area = v
273 +
274 + dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]
275 + for r in rows
276 + if _ISO_DATE_RE.match(str(r.get("availabilityDate")
277 + or "")))
278 + avail_date = dates[0] if dates else None
279 + availability = f"Disponible le {avail_date}" if avail_date \
280 + else "Disponible"
281 + if len(rows) > 1:
282 + availability += f" — {len(rows)} unités"
283 +
284 + details = dict(b["details"])
285 + if (u.get("leaseTerm") or "").strip():
286 + details["Bail"] = u["leaseTerm"].strip()
287 +
288 + slug = re.sub(r"[^a-z0-9]+", "-",
289 + strip_accents(type_name.lower())).strip("-")
290 + ext = f"{bid}-{slug or 'u'}"
291 + if bed is not None or bath is not None:
292 + ext += f"-{bed}-{bath}"
293 +
294 + return Listing(
295 + source=self.source_id,
296 + external_id=ext,
297 + url=b["url"],
298 + title=f"{b['name']} — {type_name}" if type_name else b["name"],
299 + address=b["address"],
300 + sector=b["sector"],
301 + city=b["city"],
302 + province="ON",
303 + unit_type=unit_type,
304 + bedrooms=float(bed) if bed is not None else None,
305 + bathrooms=float(bath) if bath else None,
306 + price=price,
307 + price_label=price_label,
308 + availability=availability,
309 + availability_date=avail_date,
310 + area_sqft=area,
311 + pets=b["pets"],
312 + furnished=True if rows and all(r.get("furnished") == 1
313 + for r in rows) else None,
314 + description=b["desc"][:600],
315 + amenities=b["amenities"][:25],
316 + details=details,
317 + images=b["images"],
318 + lat=b["lat"],
319 + lng=b["lng"],
320 + )
added louka/connectors/kggroup.py +251 −0
@@ -0,0 +1,251 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/kggroup.py : connecteur KG Group (myrental.ca)
5 +# Gestionnaire familial torontois (~4 700 suites — 7 tours à Midtown,
6 +# North York, Yonge & Sheppard). Le site est un Rentsync « nouvelle
7 +# génération » (SPA Vue servie par cdn.rentsync.com/site/kg_rebuild) : PAS
8 +# le flux LiftSystem classique (api.theliftsystem.com, cf. liftsystem.py)
9 +# mais l'API interne « website-gateway » découverte dans les bundles JS :
10 +# https://website-gateway-cdn.rentsync.com/v1/kg_rebuild/
11 +# properties -> 7 immeubles (adresse, GPS, quartier,
12 +# overview HTML, politique animaux…)
13 +# properties/<id>/unit-summary -> unités DISPONIBLES en direct
14 +# (typeName, bed, bath, sqFt, rate)
15 +# properties/<id>/photos -> galerie (fichiers S3 lws_lift, servis
16 +# en https://s3.amazonaws.com/lws_lift/
17 +# kggroup/images/gallery/full/<image>)
18 +# properties/<id>/amenities -> commodités nommées
19 +# Aucun anti-bot, pas de clé : l'API répond à un simple GET JSON.
20 +# Une annonce PAR TYPE D'UNITÉ DISPONIBLE (groupé sur typeName, ex. « 1A »),
21 +# prix plancher réel du groupe ; repli « une annonce par immeuble » SANS
22 +# prix quand aucune unité n'est affichée (rien d'inventé). unit-summary est
23 +# interrogé en direct à chaque synchronisation (c'est la donnée vivante) ;
24 +# photos + commodités passent par le cache BD self.detail() (clé = champ
25 +# `modified` de l'immeuble). Toutes les adresses sont à Toronto (North York,
26 +# Midtown… = quartiers) : city = « Toronto », sector = neighbourhood du flux.
27 +#
28 +# Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le
29 +# connecteur est `disabled` et exclu du registre (zéro impact prod QC).
30 +# -----------------------------------------------------------------------------
31 +from __future__ import annotations
32 +
33 +import html
34 +import os
35 +import re
36 +
37 +from bs4 import BeautifulSoup
38 +
39 +from ..schema import Listing, normalize_unit_type
40 +from .base import BaseConnector
41 +
42 +SITE = "https://www.myrental.ca"
43 +GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/kg_rebuild"
44 +# galerie S3 du client Rentsync (préfixe du compte : « kggroup »)
45 +IMG_BASE = "https://s3.amazonaws.com/lws_lift/kggroup/images/gallery/full"
46 +
47 +# Gate expansion Ontario : le connecteur reste hors registre tant que la
48 +# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.
49 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
50 +
51 +
52 +class KgGroupConnector(BaseConnector):
53 + source_id = "kggroup"
54 + request_delay = 1.0
55 + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)
56 + max_properties = 15 # garde-fou (7 immeubles aujourd'hui)
57 + max_images = 20
58 +
59 + def _api(self, path: str):
60 + return self.get(f"{GATEWAY}/{path}",
61 + headers={"Accept": "application/json",
62 + "Referer": SITE + "/"}).json().get("data")
63 +
64 + def fetch(self) -> list[Listing]:
65 + props = self._api("properties") or []
66 +
67 + listings: list[Listing] = []
68 + count = 0
69 + for p in props:
70 + try:
71 + if (p.get("status") or "").lower() != "enabled":
72 + continue
73 + if count >= self.max_properties:
74 + break
75 + count += 1
76 + listings.extend(self._property_listings(p))
77 + except Exception:
78 + continue
79 + return listings
80 +
81 + # -- annonces d'un immeuble (une par type d'unité disponible) ---------------
82 + def _property_listings(self, p: dict) -> list[Listing]:
83 + pid = str(p.get("id"))
84 + perma = (p.get("permaLink") or "").strip()
85 + url = f"{SITE}/apartments-for-rent/{perma}" if perma else SITE
86 + name = (p.get("buildingName") or "").strip()
87 +
88 + # adresse complète : rue + Toronto + « ON <postal> » — le parc KG est
89 + # entièrement torontois (North York/Midtown = quartiers, pas villes)
90 + street = " ".join(x for x in ((p.get("streetNumber") or "").strip(),
91 + (p.get("streetName") or "").strip()) if x)
92 + postal = (p.get("postal") or "").strip()
93 + city = "Toronto"
94 + sector = (p.get("neighbourhood") or "").strip()
95 + full_addr = ", ".join(x for x in (street, city) if x) + ", ON"
96 + if postal:
97 + full_addr += f" {postal}"
98 +
99 + # coordonnées GPS du flux (finalize() valide la bbox Ontario)
100 + try:
101 + lat = float(p.get("latitude")) if p.get("latitude") else None
102 + lng = float(p.get("longitude")) if p.get("longitude") else None
103 + except (TypeError, ValueError):
104 + lat = lng = None
105 +
106 + # politique animaux : champs structurés du flux — rien d'inventé
107 + pets = None
108 + if p.get("petsNotAllowed"):
109 + pets = "non"
110 + elif p.get("petFriendly"):
111 + pets = "oui"
112 + elif p.get("petsCats") or p.get("petsSmallDogs") \
113 + or p.get("petsLargeDogs"):
114 + pets = "conditions"
115 +
116 + # description : overview HTML doublement échappé du flux
117 + desc = BeautifulSoup(html.unescape(p.get("buildingOverview") or ""),
118 + "html.parser").get_text(" ", strip=True)[:600]
119 +
120 + details: dict = {}
121 + phone = (p.get("phone") or "").strip()
122 + if phone:
123 + details["contact"] = {"phone": phone}
124 +
125 + # photos + commodités via le cache BD : revisitées seulement quand
126 + # l'immeuble est modifié côté Rentsync
127 + feed_key = str(p.get("modified") or "")
128 + d = self.detail(pid, feed_key, lambda: self._fetch_media(pid))
129 + images = (d.get("images") or [])[: self.max_images]
130 + amenities = (d.get("amenities") or [])[:25]
131 +
132 + common = dict(
133 + source=self.source_id, url=url, address=full_addr, sector=sector,
134 + city=city, province="ON", pets=pets, description=desc,
135 + amenities=amenities, images=images, lat=lat, lng=lng,
136 + details=details,
137 + )
138 +
139 + # unités disponibles EN DIRECT (unit-summary) — la donnée vivante
140 + units = []
141 + try:
142 + summary = self._api(f"properties/{pid}/unit-summary") or {}
143 + units = (((summary.get("availableSummary") or {})
144 + .get("available") or {}).get("units")) or []
145 + except Exception:
146 + units = []
147 +
148 + # une annonce par TYPE d'unité disponible (groupé sur typeName)
149 + groups: dict[str, dict] = {}
150 + for u in units:
151 + if u.get("available") != 1 or u.get("hideSuiteTypeWebsite"):
152 + continue
153 + key = str(u.get("typeName") or
154 + f"{u.get('bed')}-{u.get('bath')}-{u.get('sqFt')}")
155 + g = groups.setdefault(key, {"units": [], "bed": u.get("bed"),
156 + "bath": u.get("bath"),
157 + "sqft": u.get("sqFt")})
158 + g["units"].append(u)
159 +
160 + out: list[Listing] = []
161 + for key, g in groups.items():
162 + rates = []
163 + for u in g["units"]:
164 + if u.get("hideRateWebsites"):
165 + continue
166 + try:
167 + r = float(u.get("rate") or 0)
168 + except (TypeError, ValueError):
169 + r = 0.0
170 + if r > 0:
171 + rates.append(r)
172 + price = min(rates) if rates else None
173 + beds = g["bed"]
174 + try:
175 + beds = float(beds) if beds is not None else None
176 + except (TypeError, ValueError):
177 + beds = None
178 + baths = g["bath"]
179 + try:
180 + baths = float(baths) if baths else None
181 + except (TypeError, ValueError):
182 + baths = None
183 + area = None
184 + try:
185 + v = float(g.get("sqft") or 0)
186 + if 80 <= v <= 20000:
187 + area = v
188 + except (TypeError, ValueError):
189 + pass
190 + n = len(g["units"])
191 + slug = re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-")
192 + out.append(Listing(
193 + external_id=f"{pid}-{slug or 'u'}",
194 + title=f"{name} — Suite {key}" if name else f"Suite {key}",
195 + unit_type=("Studio" if beds == 0 else normalize_unit_type(
196 + f"{int(beds)} chambres") if beds is not None else ""),
197 + bedrooms=beds,
198 + bathrooms=baths,
199 + price=price,
200 + price_label=(f"À partir de {price:.0f} $ /mois"
201 + if price is not None and n > 1
202 + else f"{price:.0f} $ /mois"
203 + if price is not None else ""),
204 + availability=(f"{n} unités disponibles" if n > 1
205 + else "Unité disponible"),
206 + area_sqft=area,
207 + **common,
208 + ))
209 + if out:
210 + return out
211 +
212 + # repli : une annonce par immeuble — aucun prix inventé
213 + return [Listing(
214 + external_id=pid,
215 + title=name,
216 + unit_type="",
217 + availability="Aucune unité disponible",
218 + **common,
219 + )]
220 +
221 + # -- médias : galerie S3 + commodités (endpoints secondaires) ---------------
222 + def _fetch_media(self, pid: str) -> dict:
223 + out: dict = {"images": [], "amenities": []}
224 + try:
225 + photos = self._api(f"properties/{pid}/photos") or []
226 + except Exception:
227 + photos = []
228 + images: list[str] = []
229 + for ph in sorted(photos, key=lambda x: (x or {}).get("orderBy") or 0):
230 + if not ph.get("active"):
231 + continue
232 + f = (ph.get("image") or "").strip()
233 + if not f:
234 + continue
235 + u = f"{IMG_BASE}/{f}"
236 + if u not in images:
237 + images.append(u)
238 + out["images"] = images[: self.max_images]
239 +
240 + try:
241 + ams = self._api(f"properties/{pid}/amenities") or []
242 + except Exception:
243 + ams = []
244 + amenities: list[str] = []
245 + for a in ams:
246 + t = (a.get("name") or "").strip() if isinstance(a, dict) else ""
247 + if t and (a.get("status") or "enabled") == "enabled" \
248 + and t not in amenities:
249 + amenities.append(t)
250 + out["amenities"] = amenities[:25]
251 + return out
added louka/connectors/medallion.py +249 −0
@@ -0,0 +1,249 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/medallion.py : connecteur Medallion Corporation (medallioncorp.com)
5 +# Gestionnaire torontois (~25 000 unités est., Toronto/GTA + Brampton, Ajax,
6 +# Whitby, Oshawa, Richmond Hill, Hamilton, London). Site WordPress (thème
7 +# RealHomes/inspiry + CPT UI) SANS anti-bot : le CPT `property` est exposé
8 +# par l'API REST standard — `/wp-json/wp/v2/properties?per_page=100`
9 +# (84 propriétés au moment de l'écriture, x-wp-total 84, une seule page).
10 +# Chaque entrée porte dans `class_list` la ville (property-city-<slug>), le
11 +# type (property-type-residential/commercial…) et le statut (property-status-
12 +# for-rent vs commercial-rent) : on ne garde que le résidentiel à louer
13 +# (56 immeubles). `property_meta` fournit l'adresse civique complète
14 +# (REAL_HOMES_property_address), les coordonnées GPS et la galerie photo
15 +# (URLs wp-content/uploads, variantes redimensionnées).
16 +# Les prix/types d'unités ne sont PAS dans le flux : la fiche propriété
17 +# (rendu serveur) affiche un accordéon « Floor Plans » (nom, N Bedrooms,
18 +# N Bathrooms, prix plancher, « Call for Availability ») — visitée via le
19 +# cache BD self.detail() (clé = date `modified` du flux). Une annonce par
20 +# plan d'étage ; repli « une annonce par propriété » (sans prix inventé)
21 +# quand l'accordéon est absent.
22 +#
23 +# Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le
24 +# connecteur est `disabled` et exclu du registre (zéro impact prod QC).
25 +# -----------------------------------------------------------------------------
26 +from __future__ import annotations
27 +
28 +import os
29 +import re
30 +
31 +from bs4 import BeautifulSoup
32 +
33 +from ..schema import Listing, normalize_unit_type, parse_price
34 +from .base import BaseConnector
35 +
36 +BASE = "https://medallioncorp.com"
37 +FEED_URL = f"{BASE}/wp-json/wp/v2/properties?per_page=100"
38 +
39 +# Gate expansion Ontario : le connecteur reste hors registre tant que la
40 +# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.
41 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
42 +
43 +# villes du flux (slug de class_list `property-city-<slug>`) -> affichage ;
44 +# tout slug inconnu est titré tel quel (« richmond-hill » -> « Richmond Hill »)
45 +_CITY_LABEL = {
46 + "toronto": "Toronto",
47 + "brampton": "Brampton",
48 + "ajax": "Ajax",
49 + "whitby": "Whitby",
50 + "oshawa": "Oshawa",
51 + "richmond-hill": "Richmond Hill",
52 + "hamilton": "Hamilton",
53 + "london": "London",
54 + "milton": "Milton",
55 +}
56 +
57 +# lignes de l'accordéon plans d'étage : « 2 Bedrooms », « 1.5 Bathrooms »
58 +_BED_RE = re.compile(r"(\d+)\s*Bedroom", re.I)
59 +_BATH_RE = re.compile(r"([\d.]+)\s*Bathroom", re.I)
60 +_SQFT_RE = re.compile(r"([\d,]+)\s*(?:sq\.?\s*ft|pi2|ft2)", re.I)
61 +_CITY_CLS_RE = re.compile(r"^property-city-(.+)$")
62 +
63 +
64 +class MedallionConnector(BaseConnector):
65 + source_id = "medallion"
66 + request_delay = 1.0
67 + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)
68 + max_properties = 80 # garde-fou (56 immeubles résidentiels aujourd'hui)
69 + max_images = 20
70 +
71 + def fetch(self) -> list[Listing]:
72 + props = self.get(FEED_URL, headers={"Accept": "application/json"}).json()
73 +
74 + listings: list[Listing] = []
75 + count = 0
76 + for p in props:
77 + try:
78 + cls = set(p.get("class_list") or [])
79 + # résidentiel longue durée seulement : à louer, non commercial
80 + if "property-status-for-rent" not in cls:
81 + continue
82 + if "property-type-residential" not in cls:
83 + continue
84 + if count >= self.max_properties:
85 + break
86 + count += 1
87 + listings.extend(self._property_listings(p))
88 + except Exception:
89 + continue
90 + return listings
91 +
92 + # -- annonces d'une propriété (une par plan d'étage, repli propriété) -------
93 + def _property_listings(self, p: dict) -> list[Listing]:
94 + pid = str(p.get("id"))
95 + url = p.get("link") or ""
96 + title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",
97 + "html.parser").get_text(" ", strip=True)
98 + meta = p.get("property_meta") or {}
99 +
100 + # adresse civique complète du flux (« 25 Kitney Dr, Ajax, ON L1S 0G6,
101 + # Canada ») — suffixe « , Canada » retiré
102 + address = re.sub(r",\s*Canada$", "",
103 + (meta.get("REAL_HOMES_property_address") or "").strip())
104 +
105 + # ville : slug property-city-<slug> de class_list
106 + city = ""
107 + for c in p.get("class_list") or []:
108 + m = _CITY_CLS_RE.match(c)
109 + if m:
110 + slug = m.group(1)
111 + city = _CITY_LABEL.get(slug, slug.replace("-", " ").title())
112 + break
113 +
114 + # coordonnées GPS du flux (finalize() valide la bbox Ontario)
115 + loc = meta.get("REAL_HOMES_property_location") or {}
116 + try:
117 + lat = float(loc.get("latitude")) if loc.get("latitude") else None
118 + lng = float(loc.get("longitude")) if loc.get("longitude") else None
119 + except (TypeError, ValueError):
120 + lat = lng = None
121 +
122 + # galerie photo : variantes redimensionnées du flux (large ~1024px)
123 + images: list[str] = []
124 + for img in meta.get("REAL_HOMES_property_images") or []:
125 + sizes = (img or {}).get("sizes") or {}
126 + u = ""
127 + for k in ("1536x1536", "large", "medium_large"):
128 + u = (sizes.get(k) or {}).get("url") or ""
129 + if u:
130 + break
131 + if not u and img.get("file"):
132 + u = f"{BASE}/wp-content/uploads/{img['file']}"
133 + if u and u not in images:
134 + images.append(u)
135 + images = images[: self.max_images]
136 +
137 + # description : contenu WordPress de la fiche (rendu texte)
138 + desc = BeautifulSoup((p.get("content") or {}).get("rendered") or "",
139 + "html.parser").get_text(" ", strip=True)[:600]
140 +
141 + # fiche propriété (plans d'étage + commodités) via le cache BD :
142 + # revisitée seulement quand le post WordPress est modifié
143 + feed_key = str(p.get("modified") or p.get("modified_gmt") or "")
144 + d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))
145 + amenities = d.get("amenities") or []
146 +
147 + common = dict(
148 + source=self.source_id, url=url, address=address, city=city,
149 + province="ON", description=desc, amenities=amenities[:25],
150 + images=images, lat=lat, lng=lng,
151 + )
152 +
153 + # une annonce par plan d'étage (type d'unité + prix plancher affiché)
154 + out: list[Listing] = []
155 + for fp in d.get("floorplans") or []:
156 + name = fp.get("name") or ""
157 + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
158 + beds = fp.get("beds")
159 + price = fp.get("price")
160 + out.append(Listing(
161 + external_id=f"{pid}-{slug or 'u'}",
162 + title=f"{title} — {name}" if name else title,
163 + unit_type=("Studio" if beds == 0 else normalize_unit_type(
164 + f"{int(beds)} chambres") if beds is not None
165 + else normalize_unit_type(name)),
166 + bedrooms=beds,
167 + bathrooms=fp.get("baths"),
168 + price=price,
169 + price_label=(f"À partir de {price:.0f} $ /mois"
170 + if price is not None else ""),
171 + availability=fp.get("avail") or "",
172 + area_sqft=fp.get("sqft"),
173 + **common,
174 + ))
175 + if out:
176 + return out
177 +
178 + # repli : une annonce par propriété — aucun prix inventé (None = inconnu)
179 + return [Listing(
180 + external_id=pid,
181 + title=title,
182 + unit_type="",
183 + **common,
184 + )]
185 +
186 + # -- fiche propriété : plans d'étage + commodités ----------------------------
187 + def _fetch_detail(self, url: str) -> dict:
188 + """Scrape l'accordéon « Floor Plans » (nom, chambres, sdb, prix,
189 + disponibilité) et la liste « Buildings Features » de la fiche."""
190 + out: dict = {"floorplans": [], "amenities": []}
191 + if not url:
192 + return out
193 + try:
194 + page = self.get(url).text
195 + except Exception:
196 + return out
197 + soup = BeautifulSoup(page, "html.parser")
198 +
199 + # plans d'étage : <div class="floor-plan"> (nom h3, meta « N Bedrooms /
200 + # N Bathrooms / prix / Call for Availability »)
201 + seen: set[str] = set()
202 + for fp in soup.select(".floor-plans-accordions .floor-plan"):
203 + h = fp.select_one(".floor-plan-title .title h3")
204 + name = h.get_text(" ", strip=True) if h else ""
205 + if not name or name.lower() in seen:
206 + continue
207 + seen.add(name.lower())
208 + meta_el = fp.select_one(".floor-plan-meta")
209 + meta_txt = meta_el.get_text(" ", strip=True) if meta_el else ""
210 + mb = _BED_RE.search(meta_txt) or _BED_RE.search(name)
211 + beds = (float(mb.group(1)) if mb
212 + else 0.0 if re.search(r"bachelor|studio", name, re.I)
213 + else None)
214 + mba = _BATH_RE.search(meta_txt)
215 + msq = _SQFT_RE.search(meta_txt)
216 + sqft = float(msq.group(1).replace(",", "")) if msq else None
217 + price_el = fp.select_one(".floor-price-value")
218 + price = parse_price(price_el.get_text(" ", strip=True)) \
219 + if price_el else None
220 + # texte de dispo : ce qui suit le prix (« Call for Availability »)
221 + avail = ""
222 + fprice = fp.select_one(".floor-price")
223 + if fprice:
224 + avail = fprice.get_text(" ", strip=True)
225 + if price_el:
226 + avail = avail.replace(
227 + price_el.get_text(" ", strip=True), "").strip()
228 + out["floorplans"].append({
229 + "name": name,
230 + "beds": beds,
231 + "baths": float(mba.group(1)) if mba else None,
232 + "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,
233 + "price": price,
234 + "avail": avail,
235 + })
236 +
237 + # commodités : « Buildings Features » (liste à puces de la fiche)
238 + h = soup.find(["h3", "h4"], string=re.compile(
239 + r"Buildings? Features", re.I))
240 + if h:
241 + ul = h.find_next("ul")
242 + if ul:
243 + amenities = []
244 + for li in ul.find_all("li"):
245 + t = li.get_text(" ", strip=True)
246 + if t and len(t) < 60 and t not in amenities:
247 + amenities.append(t)
248 + out["amenities"] = amenities[:25]
249 + return out
added louka/connectors/qresidential.py +289 −0
@@ -0,0 +1,289 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/qresidential.py : connecteur Q Residential (qresidential.ca)
5 +# Gestionnaire ontarien (~7 000 unités — Toronto, St. Catharines, Hamilton,
6 +# Oshawa/GTA, Brampton, Barrie). « Corporate site » Yardi RentCafe derrière
7 +# un Cloudflare STRICT (curl direct = 403) : tout passe par Scrapfly ASP
8 +# (self.get_scrapfly, asp=true, SANS render_js — le contenu est rendu
9 +# serveur, vérifié sur la recherche et les fiches).
10 +# Contrairement à killam.py (même famille RentCafe), il n'y a PAS de blob
11 +# #available_prop : la page /searchlisting.aspx rend 25 cartes propriétés
12 +# côté serveur (nom, adresse, ville, province, vignette, URL de fiche).
13 +# La fiche propriété fournit un JSON-LD ApartmentComplex (description,
14 +# adresse postale, GPS, commodités, téléphone) et un tableau « Floor Plans »
15 +# (tbody.floorplan-details : nom, Bed/Bath, pi², loyer — souvent « Call for
16 +# pricing » —, disponibilité). Une annonce PAR PLAN D'ÉTAGE quand au moins
17 +# un plan affiche un prix ; sinon repli « une annonce par propriété » sans
18 +# prix (rien d'inventé). Fiches visitées via le cache BD self.detail(),
19 +# clé datée du jour : au plus UNE visite Scrapfly par propriété par jour,
20 +# peu importe le nombre de synchronisations.
21 +#
22 +# Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le
23 +# connecteur est `disabled` et exclu du registre (zéro impact prod QC).
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import datetime as _dt
28 +import json
29 +import os
30 +import re
31 +
32 +from bs4 import BeautifulSoup
33 +
34 +from ..schema import Listing, normalize_unit_type, parse_price
35 +from .base import BaseConnector
36 +
37 +BASE = "https://www.qresidential.ca"
38 +SEARCH_PAGE = f"{BASE}/searchlisting.aspx"
39 +
40 +# Gate expansion Ontario : le connecteur reste hors registre tant que la
41 +# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.
42 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
43 +
44 +# cellules du tableau plans d'étage : « Bed/Bath Studio / 1 », « 2 / 1.5 »
45 +_BEDBATH_RE = re.compile(
46 + r"Bed/Bath\s+(Studio|\d+)\s*/\s*([\d.]+)", re.I)
47 +_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)
48 +# identifiant stable du plan d'étage (carrousel photo du plan)
49 +_FPID_RE = re.compile(r"fp-myCarousel(\d+)")
50 +
51 +
52 +class QResidentialConnector(BaseConnector):
53 + source_id = "qresidential"
54 + request_delay = 1.5 # Cloudflare strict : Scrapfly ASP, rester poli
55 + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)
56 + max_properties = 35 # garde-fou (25 propriétés aujourd'hui)
57 + max_images = 15
58 +
59 + def fetch(self) -> list[Listing]:
60 + page = self.get_scrapfly(SEARCH_PAGE, asp=True, render_js=False)
61 + if not page:
62 + raise RuntimeError("Q Residential : Scrapfly ASP n'a pas "
63 + "retourné la page de recherche")
64 + soup = BeautifulSoup(page, "html.parser")
65 +
66 + cards = soup.select("div.searchResult")
67 + listings: list[Listing] = []
68 + count = 0
69 + seen: set[str] = set()
70 + for card in cards:
71 + try:
72 + a = card.select_one("a.propertyUrl")
73 + if a is None:
74 + continue
75 + url = (a.get("href") or "").strip()
76 + if not url or url in seen:
77 + continue
78 + seen.add(url)
79 + # résidentiel longue durée seulement (structure « Apartment »)
80 + st = card.select_one(".structure-type")
81 + if st and st.get_text(" ", strip=True) and \
82 + "apartment" not in st.get_text(" ", strip=True).lower():
83 + continue
84 + state = card.select_one(".propertyState")
85 + if state and state.get_text(strip=True).upper() != "ON":
86 + continue
87 + if count >= self.max_properties:
88 + break
89 + count += 1
90 + listings.extend(self._property_listings(card, url))
91 + except Exception:
92 + continue
93 + return listings
94 +
95 + # -- annonces d'une propriété (une par plan d'étage tarifé, repli) ----------
96 + def _property_listings(self, card, url: str) -> list[Listing]:
97 + # identifiant : slug de la fiche (« queenston-manor »)
98 + slug = url.rstrip("/").split("/")[-2] if url.endswith("default.aspx") \
99 + else re.sub(r"[^a-z0-9-]", "", url.rstrip("/").split("/")[-1])
100 + a = card.select_one("a.propertyUrl")
101 + name = a.get_text(" ", strip=True)
102 + city = (card.select_one(".propertyCity") or a) \
103 + .get_text(" ", strip=True) if card.select_one(".propertyCity") \
104 + else ""
105 + # adresse complète de la carte (« 382 Queenston Street St. Catharines
106 + # ON L2P 3V5 ») — on la reconstruit avec des virgules
107 + street = ""
108 + spans = card.select(".propertyAddress")
109 + if spans:
110 + street = spans[-1].get_text(" ", strip=True)
111 + postal = ""
112 + m = re.search(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d",
113 + card.get_text(" ", strip=True))
114 + if m:
115 + postal = m.group(0)
116 + address = ", ".join(x for x in (street, city) if x)
117 + if address:
118 + address += f", ON {postal}".rstrip()
119 +
120 + thumb = ""
121 + img = card.select_one("img.propertyThumb")
122 + if img is not None:
123 + thumb = (img.get("src") or "").strip()
124 +
125 + # fiche propriété via le cache BD — clé datée : au plus une visite
126 + # Scrapfly par propriété par jour (les prix « Call for pricing »
127 + # changent rarement, et jamais entre deux syncs du même jour)
128 + feed_key = f"{_dt.date.today().isoformat()}|{name}|{thumb}"
129 + d = self.detail(slug, feed_key, lambda: self._fetch_detail(url))
130 +
131 + desc = d.get("description") or ""
132 + amenities = d.get("amenities") or []
133 + images = [u for u in ([thumb] + (d.get("images") or [])) if u]
134 + lat, lng = d.get("lat"), d.get("lng")
135 + details: dict = {}
136 + if d.get("phone"):
137 + details["contact"] = {"phone": d["phone"]}
138 +
139 + common = dict(
140 + source=self.source_id, url=url, address=address, city=city,
141 + province="ON", description=desc, amenities=amenities[:25],
142 + images=images[: self.max_images], lat=lat, lng=lng,
143 + )
144 +
145 + # une annonce par plan d'étage TARIFÉ (prix affiché) ; les plans
146 + # « Call for pricing » ne deviennent pas des annonces individuelles
147 + out: list[Listing] = []
148 + priced = [fp for fp in d.get("floorplans") or []
149 + if fp.get("price") is not None]
150 + for fp in priced:
151 + beds = fp.get("beds")
152 + price = fp["price"]
153 + out.append(Listing(
154 + external_id=f"{slug}-{fp['fpid']}",
155 + title=f"{name} — {fp['name']}" if fp.get("name") else name,
156 + unit_type=("Studio" if beds == 0 else normalize_unit_type(
157 + f"{int(beds)} chambres") if beds is not None else ""),
158 + bedrooms=beds,
159 + bathrooms=fp.get("baths"),
160 + price=price,
161 + price_label=f"À partir de {price:.0f} $ /mois",
162 + availability=fp.get("avail") or "",
163 + area_sqft=fp.get("sqft"),
164 + details=dict(details),
165 + **common,
166 + ))
167 + if out:
168 + return out
169 +
170 + # repli : une annonce par propriété — gamme de types connue seulement
171 + # via les plans d'étage non tarifés ; aucun prix inventé
172 + fps = d.get("floorplans") or []
173 + beds_set = {fp.get("beds") for fp in fps if fp.get("beds") is not None}
174 + unit_type = ""
175 + if len(beds_set) == 1:
176 + b = beds_set.pop()
177 + unit_type = "Studio" if b == 0 else normalize_unit_type(
178 + f"{int(b)} chambres")
179 + return [Listing(
180 + external_id=slug,
181 + title=name,
182 + unit_type=unit_type,
183 + availability="Sur demande (contacter la gestion)" if fps else "",
184 + details=details,
185 + **common,
186 + )]
187 +
188 + # -- fiche propriété : JSON-LD + tableau plans d'étage ----------------------
189 + def _fetch_detail(self, url: str) -> dict:
190 + out: dict = {"description": "", "amenities": [], "images": [],
191 + "floorplans": [], "lat": None, "lng": None, "phone": ""}
192 + page = self.get_scrapfly(url, asp=True, render_js=False)
193 + if not page:
194 + return out
195 + soup = BeautifulSoup(page, "html.parser")
196 +
197 + # JSON-LD ApartmentComplex : description, GPS, commodités, téléphone
198 + for tag in soup.find_all("script", type="application/ld+json"):
199 + try:
200 + ld = json.loads(tag.string or "")
201 + except (TypeError, ValueError):
202 + continue
203 + if not isinstance(ld, dict) or \
204 + ld.get("@type") != "ApartmentComplex":
205 + continue
206 + out["description"] = BeautifulSoup(
207 + ld.get("description") or "", "html.parser"
208 + ).get_text(" ", strip=True)[:600]
209 + geo = ld.get("geo") or {}
210 + try:
211 + out["lat"] = float(geo.get("latitude"))
212 + out["lng"] = float(geo.get("longitude"))
213 + except (TypeError, ValueError):
214 + pass
215 + out["phone"] = ((ld.get("address") or {})
216 + .get("telephone") or "").strip()
217 + ams = []
218 + for af in ld.get("amenityFeature") or []:
219 + t = (af.get("name") or "").strip() if isinstance(af, dict) \
220 + else ""
221 + if t and t not in ams:
222 + ams.append(t)
223 + out["amenities"] = ams[:25]
224 + break
225 +
226 + # visuels de la fiche (bannière dmslivecafe) — HORS plans d'étage/logos
227 + images: list[str] = []
228 + for img in soup.find_all("img"):
229 + src = (img.get("src") or "").strip()
230 + if "cdngeneralcf.rentcafe.com/dmslivecafe" not in src:
231 + continue
232 + if re.search(r"logo|icon|floor\s?plan|FloorPlan", src, re.I):
233 + continue
234 + if img.find_parent(class_=re.compile("floorplan")):
235 + continue
236 + if src not in images:
237 + images.append(src)
238 + out["images"] = images[: self.max_images]
239 +
240 + # tableau plans d'étage : une ligne par variation (nom, Bed/Bath,
241 + # pi² éventuel, loyer, disponibilité)
242 + seen: set[str] = set()
243 + for tr in soup.select("tbody.floorplan-details tr"):
244 + txt = tr.get_text(" ", strip=True)
245 + if "Bed/Bath" not in txt:
246 + continue
247 + mid = _FPID_RE.search(str(tr))
248 + name_td = tr.find(string=re.compile(r"Floor Plan\s"))
249 + name = ""
250 + if name_td:
251 + name = re.sub(r"^Floor Plan\s+", "",
252 + str(name_td).strip()).strip()
253 + fpid = mid.group(1) if mid else \
254 + re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
255 + if not fpid or fpid in seen:
256 + continue
257 + seen.add(fpid)
258 + mbb = _BEDBATH_RE.search(txt)
259 + beds = baths = None
260 + if mbb:
261 + beds = 0.0 if mbb.group(1).lower() == "studio" \
262 + else float(mbb.group(1))
263 + try:
264 + baths = float(mbb.group(2))
265 + except ValueError:
266 + baths = None
267 + msq = _SQFT_RE.search(txt)
268 + sqft = float(msq.group(1).replace(",", "")) if msq else None
269 + # loyer : montant affiché seulement (« Call for pricing » -> None)
270 + price = None
271 + mrent = re.search(r"Rent\s+([^D]*?)(?:Deposit|$)", txt)
272 + if mrent:
273 + price = parse_price(mrent.group(1))
274 + avail = ""
275 + mav = re.search(r"Deposit\s*(.*?)(?:Read More|$)", txt)
276 + if mav:
277 + avail = mav.group(1).strip()
278 + if avail.lower() in ("contact us", ""):
279 + avail = ""
280 + out["floorplans"].append({
281 + "fpid": fpid,
282 + "name": name,
283 + "beds": beds,
284 + "baths": baths,
285 + "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,
286 + "price": price,
287 + "avail": avail,
288 + })
289 + return out
modified louka/connectors/rentcafe.py +9 −4
@@ -7,10 +7,12 @@
7 7 #
8 8 # Une sous-classe est générée dynamiquement par client du registre
9 9 # data/rentcafe_clients.json (statut « ok ») : source_id = rc_<id>
10 −# (ex. rc_effort, rc_osgoode, rc_gwlra, rc_oshanter). Le registre
11 −# auto-découvrant connectors/__init__.py les ramasse dans vars(module).
10 +# (vague 1 2026-08-26 : rc_effort, rc_osgoode, rc_gwlra, rc_oshanter ;
11 +# vague 2 2026-08-27 : rc_claridge, rc_richcraft, rc_arnon, rc_concert,
12 +# rc_caraco). Le registre auto-découvrant connectors/__init__.py les
13 +# ramasse dans vars(module).
12 14 #
13 −# Pattern « searchlisting » (validé sur les 4 clients actifs) :
15 +# Pattern « searchlisting » (validé sur les 9 clients actifs) :
14 16 # 1) <site>/searchlisting.aspx via Scrapfly (Cloudflare 403 en direct,
15 17 # contenu rendu côté serveur -> render_js inutile) :
16 18 # - cartes li.property-box-hidden : nom, lien fiche, adresse complète
@@ -31,7 +33,10 @@
31 33 # Pattern « securecafe » (<client>.securecafe.com/residentservices/
32 34 # apartmentsforrent/…) : vérifié fermé derrière login chez Old Oak,
33 35 # Paramount et Tricar — entrées « echec » du registre, aucune classe
34 −# générée (voir les notes du registre avant de réessayer).
36 +# générée (voir les notes du registre avant de réessayer). Les autres
37 +# impasses vérifiées (WordPress sans searchlisting, microsites par
38 +# immeuble, Entrata, Rentsync, sites custom) sont aussi documentées
39 +# en « echec » dans le registre.
35 40 #
36 41 # ⚠ Gate Ontario : les classes ne sont actives que si LOUKA_ONTARIO=1 —
37 42 # sans cette variable, disabled=True et le registre des connecteurs les
added louka/connectors/rentsyncgw.py +432 −0
@@ -0,0 +1,432 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/rentsyncgw.py : connecteur GÉNÉRIQUE passerelle Rentsync
5 +# « nouvelle génération » (website-gateway.rentsync.com)
6 +# Plusieurs gros gestionnaires ontariens ont migré leur site vers la SPA
7 +# Rentsync nouvelle génération (bundles cdn.rentsync.com/site/<siteKey>) qui
8 +# parle à la passerelle JSON PUBLIQUE — aucune auth, aucun anti-bot :
9 +# https://website-gateway.rentsync.com/v1/<siteKey>/
10 +# properties?limit=500 → immeubles (adresse, GPS, cityId, permaLink,
11 +# buildingTypeId, animaux, description, modified) ; paginé meta.totalPages
12 +# cities?where=id~in:X|Y&relations=province:p → nom de ville + province
13 +# (filtre ON strict ; séparateur multi-valeurs « | »)
14 +# units?where=status:enabled,available~in:1&limit=750&page=N
15 +# → types d'unités AVEC vacance (bed/bath/pi²/loyer/date), paginés
16 +# photos?relations=buildingsHasPhotos:bhp&where=bhp.buildingId~in:…
17 +# → galeries par lots d'immeubles (images S3 lws_lift, taille « full »)
18 +# Images : https://s3.amazonaws.com/lws_lift/<img_key>/images/gallery/full/…
19 +# — la clé S3 peut différer du siteKey (drewlo_rebuild → drewlo,
20 +# panoramic_properties → panoramic ; validé en live HEAD 200 vs 403), d'où
21 +# la clé `img_key` du registre.
22 +# Plutôt que N modules copiés-collés (homestead.py et skyline.py sont les
23 +# connecteurs canoniques de cette passerelle), ce module lit le registre
24 +# data/rentsyncgw_clients.json ({id, name, site, site_key, img_key…},
25 +# validés en live le 2026-08-27) et GÉNÈRE une sous-classe par client
26 +# (source_id = "rsgw_<id>", ex. "rsgw_drewlo"), déposée dans les globals du
27 +# module pour le registre auto-découvrant (connectors/__init__.py).
28 +# Extraction modelée sur skyline.py : UNE annonce par type d'unité disponible,
29 +# regroupée par (immeuble, typeName, cc, sdb) — certains flux publient une
30 +# rangée PAR APPARTEMENT du même plan (champ `number`) : loyer plancher
31 +# « à partir de », date la plus proche, identifiant stable immeuble+plan.
32 +# Filtre province ON strict via /cities, filtre types non résidentiels
33 +# (office/retail/industrial/construction/subdivision — Sterling Karamar et
34 +# Drewlo mélangent commercial et chantiers dans le même flux). Tout vient de
35 +# l'API par lots : aucune page détail à visiter, pas de self.detail().
36 +# Clients au 2026-08-27 : Sterling Karamar (~15 000 unités, 217 propriétés),
37 +# Drewlo Holdings (~10 000, 108), Panoramic Properties (Sudbury, 49).
38 +# Expansion Ontario — activer via LOUKA_ONTARIO=1 : sans la variable, toutes
39 +# les classes générées sont `disabled` (exclues du registre), prod QC intacte.
40 +# -----------------------------------------------------------------------------
41 +from __future__ import annotations
42 +
43 +import html as htmllib
44 +import json
45 +import os
46 +import re
47 +from pathlib import Path
48 +
49 +from bs4 import BeautifulSoup
50 +
51 +from ..schema import Listing, normalize_unit_type, strip_accents
52 +from .base import BaseConnector
53 +
54 +# Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=1
55 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
56 +
57 +REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \
58 + "rentsyncgw_clients.json"
59 +GATEWAY = "https://website-gateway.rentsync.com/v1"
60 +IMG_BASE = "https://s3.amazonaws.com/lws_lift"
61 +
62 +# Types d'immeubles NON résidentiels du flux (buildingTypeId) — Sterling
63 +# Karamar publie 46 locaux commerciaux, Drewlo des chantiers (« coming-soon »)
64 +# et des subdivisions de maisons neuves À VENDRE : hors sujet pour Lou-Ka.
65 +_NON_RESIDENTIAL = {
66 + "office", "retail", "warehouse", "industrial", "commercial", "land",
67 + "construction", "subdivision", "motel", "hotel", "parking", "storage",
68 +}
69 +
70 +# secteur : le champ `neighbourhood` contient parfois le comté (« Grey
71 +# County », « Niagara Region ») — pas un quartier, on l'écarte (cf. skyline)
72 +_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)
73 +_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")
74 +
75 +
76 +def _load_registry() -> list[dict]:
77 + """Entrées validées du registre (site_key présent, status = valide)."""
78 + try:
79 + data = json.loads(REGISTRY_PATH.read_text("utf-8"))
80 + except (OSError, ValueError):
81 + return []
82 + return [c for c in data.get("clients") or []
83 + if c.get("site_key") and c.get("status") == "valide"]
84 +
85 +
86 +class RentsyncGatewayConnector(BaseConnector):
87 + """Base commune des connecteurs passerelle Rentsync générés — non
88 + enregistrée elle-même (source_id vide) ; chaque sous-classe reçoit son
89 + entrée de registre dans l'attribut de classe `client`."""
90 +
91 + source_id = "" # les sous-classes générées le définissent
92 + client: dict = {} # entrée du registre (name, site, site_key…)
93 + request_delay = 0.8 # politesse — même rythme que skyline/homestead
94 + max_properties = 300 # garde-fou immeubles (plus gros : 217 au 2026-08)
95 + max_listings = 1500 # garde-fou global d'annonces
96 + max_images = 15
97 + page_limit = 750 # taille de page maximale de la passerelle
98 + photo_chunk = 40 # immeubles par requête /photos
99 +
100 + # -- accès passerelle -------------------------------------------------------
101 + def _get_json(self, path: str, params: dict) -> dict:
102 + site = (self.client.get("site") or "").rstrip("/")
103 + resp = self.get(f"{GATEWAY}/{self.client['site_key']}{path}",
104 + params=params, headers={
105 + "Accept": "application/json",
106 + "Origin": site,
107 + "Referer": site + "/",
108 + "rs-lang": "en",
109 + })
110 + return resp.json()
111 +
112 + def _paged(self, path: str, params: dict, max_pages: int = 20) -> list[dict]:
113 + """Concatène toutes les pages d'un endpoint (meta.totalPages)."""
114 + out: list[dict] = []
115 + page = 1
116 + while page <= max_pages:
117 + data = self._get_json(path, {**params, "page": page})
118 + out.extend(data.get("data") or [])
119 + meta = data.get("meta") or {}
120 + if page >= int(meta.get("totalPages") or 1):
121 + break
122 + page += 1
123 + return out
124 +
125 + # -- villes : cityId -> (nom, code province) --------------------------------
126 + def _cities(self, city_ids: set[int]) -> dict[int, tuple[str, str]]:
127 + if not city_ids:
128 + return {}
129 + ids = "|".join(str(i) for i in sorted(city_ids))
130 + rows = self._paged("/cities", {
131 + "where": f"id~in:{ids}",
132 + "relations": "province:p",
133 + "limit": self.page_limit,
134 + })
135 + out: dict[int, tuple[str, str]] = {}
136 + for c in rows:
137 + prov = c.get("province") or {}
138 + out[int(c["id"])] = ((c.get("cityName") or "").strip(),
139 + (prov.get("provinceCode") or "").strip().upper())
140 + return out
141 +
142 + # -- galeries photo par lots d'immeubles ------------------------------------
143 + def _photos(self, building_ids: list[int]) -> dict[int, list[str]]:
144 + img_base = f"{IMG_BASE}/{self.client.get('img_key') or self.client['site_key']}" \
145 + "/images/gallery/full"
146 + out: dict[int, list[str]] = {}
147 + for i in range(0, len(building_ids), self.photo_chunk):
148 + chunk = building_ids[i:i + self.photo_chunk]
149 + ids = "|".join(str(b) for b in chunk)
150 + try:
151 + rows = self._paged("/photos", {
152 + "relations": "buildingsHasPhotos:bhp",
153 + "where": f"bhp.buildingId~in:{ids}",
154 + "orderBy": "bhp.orderBy~asc",
155 + "limit": self.page_limit,
156 + })
157 + except Exception:
158 + continue # galerie manquante : annonces sans photo plutôt que rien
159 + for ph in rows:
160 + bid = ph.get("buildingId")
161 + img = (ph.get("image") or "").strip()
162 + if not bid or not img:
163 + continue
164 + urls = out.setdefault(int(bid), [])
165 + if len(urls) < self.max_images:
166 + u = f"{img_base}/{img}"
167 + if u not in urls:
168 + urls.append(u)
169 + return out
170 +
171 + # -- collecte ---------------------------------------------------------------
172 + def fetch(self) -> list[Listing]:
173 + props = self._paged("/properties",
174 + {"limit": 500})[: self.max_properties]
175 +
176 + # écarter commerces/chantiers/subdivisions du flux (buildingTypeId)
177 + props = [p for p in props
178 + if str(p.get("buildingTypeId") or "").strip().lower()
179 + not in _NON_RESIDENTIAL]
180 +
181 + # immeubles gérés pour un tiers déjà couvert par un connecteur dédié
182 + # (ex. Greenrock via sterlingkaramar) — exclus par importId
183 + excl = {s.strip() for s in (self.client.get("exclude_import_ids") or [])}
184 + if excl:
185 + props = [p for p in props
186 + if (p.get("importId") or "").strip() not in excl]
187 +
188 + # villes -> filtre Ontario strict (certains flux débordent de l'ON)
189 + cities = self._cities({int(p["cityId"]) for p in props
190 + if p.get("cityId")})
191 + on_props: dict[int, dict] = {}
192 + for p in props:
193 + name, prov = cities.get(int(p.get("cityId") or 0), ("", ""))
194 + if prov != "ON" or not name:
195 + continue
196 + p["_city"] = name
197 + on_props[int(p["id"])] = p
198 +
199 + # types d'unités avec vacance : certains flux publient une rangée PAR
200 + # APPARTEMENT (champ `number`) du même plan — regrouper par
201 + # (immeuble, plan, cc, sdb) pour une annonce par type d'unité
202 + units = self._paged("/units", {
203 + "where": "status:enabled,available~in:1",
204 + "limit": self.page_limit,
205 + })
206 + by_building: dict[int, dict[tuple, list[dict]]] = {}
207 + for u in units:
208 + if u.get("hideSuiteTypeWebsite"):
209 + continue
210 + bid = int(u.get("buildingId") or 0)
211 + if bid not in on_props:
212 + continue
213 + key = ((u.get("typeName") or "").strip().lower(),
214 + u.get("bedMin", u.get("bed")),
215 + u.get("bathMin", u.get("bath")))
216 + by_building.setdefault(bid, {}).setdefault(key, []).append(u)
217 +
218 + # galeries des seuls immeubles qui produisent des annonces
219 + photos = self._photos(sorted(by_building))
220 +
221 + listings: list[Listing] = []
222 + for bid, groups in by_building.items():
223 + if len(listings) >= self.max_listings:
224 + break
225 + p = on_props[bid]
226 + try:
227 + base = self._building_ctx(p, photos.get(bid) or [])
228 + except Exception:
229 + continue
230 + for rows in groups.values():
231 + if len(listings) >= self.max_listings:
232 + break
233 + try:
234 + listings.append(self._listing(bid, rows, base))
235 + except Exception:
236 + continue
237 + return listings
238 +
239 + # -- contexte immeuble (partagé entre ses annonces) --------------------------
240 + def _building_ctx(self, p: dict, images: list[str]) -> dict:
241 + city = p["_city"]
242 + street = " ".join(x for x in (
243 + (p.get("streetNumber") or "").strip(),
244 + (p.get("streetName") or "").strip()) if x)
245 + postal = (p.get("postal") or "").strip()
246 + address = ", ".join(x for x in (street, city) if x)
247 + if address:
248 + address += f", ON {postal}".rstrip()
249 +
250 + # secteur : quartier seulement (écarter comtés/« regions » du flux)
251 + sector = (p.get("neighbourhood") or "").strip()
252 + if _COUNTY_RE.search(sector):
253 + sector = ""
254 +
255 + try:
256 + lat = float(p["latitude"]) if p.get("latitude") else None
257 + lng = float(p["longitude"]) if p.get("longitude") else None
258 + except (TypeError, ValueError):
259 + lat = lng = None
260 +
261 + # caractéristiques de l'immeuble : HTML doublement échappé dans le flux
262 + amenities: list[str] = []
263 + feats = htmllib.unescape(p.get("buildingFeatures") or "")
264 + for li in BeautifulSoup(feats, "html.parser").find_all("li"):
265 + t = li.get_text(" ", strip=True)
266 + if t and t not in amenities:
267 + amenities.append(t)
268 +
269 + # description : aperçu de l'immeuble (+ détails des suites si présents)
270 + desc_html = (p.get("buildingOverview") or "") + " " + \
271 + (p.get("suiteDetails") or "")
272 + desc = BeautifulSoup(htmllib.unescape(desc_html),
273 + "html.parser").get_text(" ", strip=True)
274 +
275 + # animaux : drapeaux structurés du flux
276 + pets = None
277 + if p.get("petsNotAllowed") == 1:
278 + pets = "non"
279 + elif p.get("petFriendly") == 1:
280 + pets = "oui"
281 +
282 + details: dict = {}
283 + contact: dict = {}
284 + if (p.get("phone") or "").strip():
285 + contact["phone"] = p["phone"].strip()
286 + email = (p.get("email") or "").split(",")[0].strip()
287 + # adresses de tracking (leadmanaging…) : pas un vrai contact public
288 + if email and "leadmanaging" not in email.lower():
289 + contact["email"] = email
290 + if contact:
291 + details["contact"] = contact
292 + try:
293 + if int(p.get("yearBuilt") or 0) > 1800:
294 + details["Année de construction"] = int(p["yearBuilt"])
295 + if int(p.get("floorCount") or 0) > 0:
296 + details["Étages"] = int(p["floorCount"])
297 + except (TypeError, ValueError):
298 + pass
299 +
300 + # fiche : site du client + fullPermaLink (validé 200 sur les 3 clients)
301 + site = (self.client.get("site") or "").rstrip("/")
302 + perma = (p.get("fullPermaLink") or "").strip().strip("/")
303 + url = f"{site}/{perma}" if perma else \
304 + (self.client.get("listing_url") or site)
305 +
306 + return {"city": city, "address": address, "sector": sector,
307 + "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,
308 + "pets": pets, "details": details, "url": url,
309 + "images": images[: self.max_images],
310 + # nom : entités HTML dans certains flux (« &amp; » chez Sterling)
311 + "name": htmllib.unescape(p.get("buildingName") or "").strip()}
312 +
313 + # -- une annonce par type d'unité disponible (rangées regroupées) ------------
314 + def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:
315 + u = rows[0]
316 + type_name = (u.get("typeName") or "").strip()
317 + unit_type = normalize_unit_type(type_name)
318 + bed = u.get("bedMin", u.get("bed"))
319 + bath = u.get("bathMin", u.get("bath"))
320 + if bed is not None and (not unit_type or unit_type == type_name):
321 + # plans nommés (« Elgin », « Tempo »…) : dériver des chambres
322 + unit_type = ("Studio" if int(bed) == 0
323 + else normalize_unit_type(f"{int(bed)} chambres"))
324 +
325 + # loyer : plancher du groupe (masqué sur le site si hideRateWebsites=1)
326 + rates: list[float] = []
327 + for r in rows:
328 + if r.get("hideRateWebsites"):
329 + continue
330 + try:
331 + lo = float(r.get("rateMin") or r.get("rate") or 0)
332 + hi = float(r.get("rateMax") or lo)
333 + except (TypeError, ValueError):
334 + continue
335 + if lo > 0:
336 + rates += [lo, max(hi, lo)]
337 + price = min(rates) if rates else None
338 + price_label = ""
339 + if price is not None:
340 + price_label = (f"À partir de {price:.0f} $" if max(rates) > price
341 + else f"{price:.0f} $ /mois")
342 +
343 + # superficie plausible seulement (le flux publie parfois 0)
344 + area = None
345 + for r in rows:
346 + try:
347 + v = float(r.get("sqFtMin") or r.get("sqFt") or 0)
348 + except (TypeError, ValueError):
349 + continue
350 + if 80 <= v <= 20000 and (area is None or v < area):
351 + area = v
352 +
353 + # disponibilité : date structurée la plus proche du groupe
354 + dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]
355 + for r in rows
356 + if _ISO_DATE_RE.match(str(r.get("availabilityDate")
357 + or "")))
358 + avail_date = dates[0] if dates else None
359 + availability = f"Disponible le {avail_date}" if avail_date \
360 + else "Disponible"
361 + if len(rows) > 1:
362 + availability += f" — {len(rows)} unités"
363 +
364 + details = dict(b["details"])
365 + if (u.get("leaseTerm") or "").strip():
366 + details["Bail"] = u["leaseTerm"].strip()
367 +
368 + # identifiant stable : immeuble + plan (les ids d'appartements roulent)
369 + slug = re.sub(r"[^a-z0-9]+", "-",
370 + strip_accents(type_name.lower())).strip("-")
371 + ext = f"{bid}-{slug or 'u'}"
372 + if bed is not None or bath is not None:
373 + ext += f"-{bed}-{bath}"
374 +
375 + return Listing(
376 + source=self.source_id,
377 + external_id=ext,
378 + url=b["url"],
379 + title=f"{b['name']} — {type_name}" if type_name else b["name"],
380 + address=b["address"],
381 + sector=b["sector"],
382 + city=b["city"],
383 + province="ON",
384 + unit_type=unit_type,
385 + bedrooms=float(bed) if bed is not None else None,
386 + bathrooms=float(bath) if bath else None,
387 + price=price,
388 + price_label=price_label,
389 + availability=availability,
390 + availability_date=avail_date,
391 + area_sqft=area,
392 + pets=b["pets"],
393 + furnished=True if rows and all(r.get("furnished") == 1
394 + for r in rows) else None,
395 + description=b["desc"][:600],
396 + amenities=b["amenities"][:25],
397 + details=details,
398 + images=b["images"],
399 + lat=b["lat"],
400 + lng=b["lng"],
401 + )
402 +
403 +
404 +# -----------------------------------------------------------------------------
405 +# Génération : une sous-classe par client du registre, déposée dans les globals
406 +# du module — connectors/__init__.py (scan de vars(module)) les découvre alors
407 +# comme n'importe quel connecteur écrit à la main.
408 +# -----------------------------------------------------------------------------
409 +def _make_connector(entry: dict) -> type[RentsyncGatewayConnector]:
410 + cls = type(
411 + f"Rsgw{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",
412 + (RentsyncGatewayConnector,),
413 + {
414 + "source_id": f"rsgw_{entry['id']}",
415 + "client": entry,
416 + # Expansion Ontario — activer via LOUKA_ONTARIO=1
417 + # (voir gestion-immobiliere-ontario.md)
418 + "disabled": not _ONTARIO,
419 + "__doc__": f"Connecteur passerelle Rentsync généré — "
420 + f"{entry.get('name')} (siteKey {entry.get('site_key')}).",
421 + },
422 + )
423 + return cls
424 +
425 +
426 +def _register_all() -> None:
427 + for entry in _load_registry():
428 + cls = _make_connector(entry)
429 + globals()[cls.__name__] = cls
430 +
431 +
432 +_register_all()
added louka/connectors/riocan.py +367 −0
@@ -0,0 +1,367 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/riocan.py : connecteur RioCan Living (riocanliving.com)
5 +# Bras résidentiel du REIT RioCan — tours locatives neuves au-dessus de ses
6 +# centres commerciaux (eCentral/ePlace et Pivot à Toronto, Rhythm à Ottawa,
7 +# Bridge à Leaside…). Le site corporatif est un WordPress SANS anti-bot :
8 +# CPT `property` exposé par l'API REST (`/wp-json/wp/v2/property?per_page=
9 +# 100`) — on garde les billets EN (« /property/ » dans le lien, les billets
10 +# FR doublonnent sous « /fr/propriete/ »), de type « rental » et localisés
11 +# en Ontario (property-location-toronto-gta / -ottawa ; fourth-street-lofts
12 +# est à CALGARY -> exclu). La fiche riocanliving fournit adresse civique,
13 +# description, visuels app/uploads et le CTA « Website » vers le MICROSITE
14 +# de la tour — chaque tour a le sien, d'architecture différente :
15 +# - eCentral : blob JS `floorPlanData` (RentCafe embarqué : nom,
16 +# BedroomNum, BathNum, Size, MinimumRent, FloorplanId, avaliableDate)
17 +# - Pivot (livingatpivot.com, Cloudflare 403 -> la chaîne résiliente de
18 +# self.get escalade d'elle-même vers Scrapfly ASP) : cartes RentCafe
19 +# `.fp-container` (« N Bed / N Bath / N Sq. Ft. », « Starting at $X »,
20 +# « Available from JJ/MM/AAAA »)
21 +# - Bridge : cartes par CATÉGORIE (« Studio … Starting from $2,095* ») —
22 +# une annonce par catégorie de chambres, prix plancher affiché
23 +# - Rhythm : aucun prix publié -> repli « une annonce par propriété »
24 +# Fiche riocanliving + découverte du microsite via le cache BD
25 +# self.detail() (clé = date `modified` du flux WP) ; la page « suites » du
26 +# microsite est relue EN DIRECT à chaque synchronisation (donnée vivante).
27 +# Une annonce par plan d'étage TARIFÉ ; sinon par catégorie tarifée ; sinon
28 +# repli par propriété SANS prix (rien d'inventé).
29 +#
30 +# Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le
31 +# connecteur est `disabled` et exclu du registre (zéro impact prod QC).
32 +# -----------------------------------------------------------------------------
33 +from __future__ import annotations
34 +
35 +import os
36 +import re
37 +from urllib.parse import urljoin
38 +
39 +from bs4 import BeautifulSoup
40 +
41 +from ..schema import Listing, normalize_unit_type, parse_price
42 +from .base import BaseConnector
43 +
44 +BASE = "https://riocanliving.com"
45 +FEED_URL = f"{BASE}/wp-json/wp/v2/property?per_page=100"
46 +
47 +# Gate expansion Ontario : le connecteur reste hors registre tant que la
48 +# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.
49 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
50 +
51 +# localisations ontariennes du CPT (class_list property-location-<slug>)
52 +_ON_LOCATIONS = {"toronto-gta": "Toronto", "ottawa": "Ottawa"}
53 +
54 +# adresse civique sur la fiche (« 15 Roehampton Ave, Toronto, ON, M4P 1P9 »)
55 +_ADDR_RE = re.compile(
56 + r"\d+[^,<>{}\n]{2,60},\s*[A-Za-z .'’-]+,\s*ON,?\s*"
57 + r"[A-Z]\d[A-Z]\s?\d[A-Z]\d")
58 +# blob RentCafe embarqué (eCentral) : affectations JS, PAS du JSON —
59 +# floorPlanData[0] = {name:'The Centric II', BedroomNum: '1', …};
60 +_FPDATA_RE = re.compile(r"floorPlanData\[\d+\]\s*=\s*\{([\s\S]*?)\};")
61 +# cartes par catégorie (Bridge) : « Studio Starting from $2,095* » —
62 +# la catégorie doit être ADJACENTE au prix (pas de texte d'intro entre deux)
63 +_CAT_RE = re.compile(
64 + r"(Studio|Bachelor|(\d+)[\s-]*Bed(?:room)?s?)\s*(?:\|\s*)?"
65 + r"Starting\s+from\s+\$\s*([\d,]+)", re.I)
66 +_BED_RE = re.compile(r"(\d+)\s*Bed", re.I)
67 +_BATH_RE = re.compile(r"([\d.]+)\s*Bath", re.I)
68 +_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)
69 +# lien « suites/plans » du microsite (découverte sur la page d'accueil)
70 +_SUITES_HREF_RE = re.compile(
71 + r"(floor-?plans?|rental-suites|/suites?/?$|availability)", re.I)
72 +
73 +
74 +class RioCanConnector(BaseConnector):
75 + source_id = "riocan"
76 + request_delay = 1.2 # microsites variés, dont un Cloudflare (Pivot)
77 + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)
78 + max_properties = 10 # garde-fou (4 tours ON aujourd'hui)
79 + max_images = 15
80 +
81 + def fetch(self) -> list[Listing]:
82 + props = self.get(FEED_URL,
83 + headers={"Accept": "application/json"}).json()
84 +
85 + listings: list[Listing] = []
86 + count = 0
87 + for p in props:
88 + try:
89 + link = p.get("link") or ""
90 + # billets EN seulement (les FR doublonnent /fr/propriete/)
91 + if "/property/" not in link:
92 + continue
93 + cls = set(p.get("class_list") or [])
94 + if "property-type-rental" not in cls:
95 + continue
96 + # Ontario seulement (fourth-street-lofts = Calgary -> exclu)
97 + city = ""
98 + for slug, label in _ON_LOCATIONS.items():
99 + if f"property-location-{slug}" in cls:
100 + city = label
101 + break
102 + if not city:
103 + continue
104 + if count >= self.max_properties:
105 + break
106 + count += 1
107 + listings.extend(self._property_listings(p, city))
108 + except Exception:
109 + continue
110 + return listings
111 +
112 + # -- annonces d'une tour (une par plan/catégorie tarifé, repli) -------------
113 + def _property_listings(self, p: dict, city: str) -> list[Listing]:
114 + pid = str(p.get("id"))
115 + link = p.get("link") or ""
116 + title = BeautifulSoup((p.get("title") or {}).get("rendered") or "",
117 + "html.parser").get_text(" ", strip=True)
118 +
119 + # fiche riocanliving + découverte du microsite via le cache BD :
120 + # revisitée seulement quand le billet WordPress est modifié
121 + feed_key = str(p.get("modified") or p.get("modified_gmt") or "")
122 + d = self.detail(pid, feed_key, lambda: self._fetch_detail(link))
123 +
124 + url = d.get("microsite") or link
125 + common = dict(
126 + source=self.source_id, url=url,
127 + address=d.get("address") or "", city=city, province="ON",
128 + description=d.get("description") or "",
129 + images=(d.get("images") or [])[: self.max_images],
130 + )
131 +
132 + # page « suites » du microsite EN DIRECT (prix/dispo = donnée vivante)
133 + page = ""
134 + suites_url = d.get("suites_url") or ""
135 + if suites_url:
136 + try:
137 + page = self.get(suites_url).text
138 + except Exception:
139 + page = ""
140 +
141 + out = self._parse_floorplandata(page, pid, title, common)
142 + if not out:
143 + out = self._parse_fp_containers(page, pid, title, common)
144 + if not out:
145 + out = self._parse_categories(page, pid, title, common)
146 + if out:
147 + return out
148 +
149 + # repli : une annonce par tour — aucun prix inventé (Rhythm…)
150 + return [Listing(
151 + external_id=pid,
152 + title=title,
153 + unit_type="",
154 + **common,
155 + )]
156 +
157 + # -- parseur 1 : blob JS floorPlanData (eCentral) ----------------------------
158 + @staticmethod
159 + def _js_field(body: str, *keys: str) -> str:
160 + """Valeur d'un champ d'objet littéral JS (`name:'The Centric II'`)."""
161 + for k in keys:
162 + m = re.search(rf"\b{k}\s*:\s*'((?:[^'\\]|\\.)*)'", body)
163 + if m:
164 + return m.group(1).replace("\\'", "'").strip()
165 + return ""
166 +
167 + def _parse_floorplandata(self, page: str, pid: str, title: str,
168 + common: dict) -> list[Listing]:
169 + out: list[Listing] = []
170 + seen: set[str] = set()
171 + for m in _FPDATA_RE.finditer(page or ""):
172 + body = m.group(1)
173 + name = self._js_field(body, "name", "Name")
174 + fpid = self._js_field(body, "FloorplanId", "floorplanId", "id")
175 + key = fpid or re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
176 + if not key or key in seen:
177 + continue
178 + seen.add(key)
179 + # loyer plancher affiché seulement (0/absent = pas de prix)
180 + price = None
181 + try:
182 + v = float(self._js_field(body, "MinimumRent")
183 + .replace(",", "") or 0)
184 + if v > 0:
185 + price = v
186 + except ValueError:
187 + pass
188 + if price is None:
189 + continue
190 + beds = baths = None
191 + try:
192 + beds = float(self._js_field(body, "BedroomNum"))
193 + except ValueError:
194 + pass
195 + try:
196 + baths = float(self._js_field(body, "BathNum", "Bath"))
197 + except ValueError:
198 + pass
199 + sqft = None
200 + msq = re.search(r"[\d,]+", self._js_field(body, "Size",
201 + "DisplaySize"))
202 + if msq:
203 + try:
204 + v = float(msq.group(0).replace(",", ""))
205 + if 80 <= v <= 20000:
206 + sqft = v
207 + except ValueError:
208 + pass
209 + avail = self._js_field(body, "avaliableDate", "AvailableDate")
210 + out.append(Listing(
211 + external_id=f"{pid}-{key}",
212 + title=f"{title} — {name}" if name else title,
213 + unit_type=("Studio" if beds == 0 else normalize_unit_type(
214 + f"{int(beds)} chambres") if beds is not None else ""),
215 + bedrooms=beds,
216 + bathrooms=baths,
217 + price=price,
218 + price_label=f"À partir de {price:.0f} $ /mois",
219 + availability=avail,
220 + area_sqft=sqft,
221 + **common,
222 + ))
223 + return out
224 +
225 + # -- parseur 2 : cartes RentCafe .fp-container (Pivot) -----------------------
226 + def _parse_fp_containers(self, page: str, pid: str, title: str,
227 + common: dict) -> list[Listing]:
228 + if not page or "fp-container" not in page:
229 + return []
230 + soup = BeautifulSoup(page, "html.parser")
231 + out: list[Listing] = []
232 + seen: set[str] = set()
233 + for card in soup.select("[id^=fp-container-]"):
234 + key = (card.get("id") or "").replace("fp-container-", "").strip()
235 + h = card.select_one(".card-title") or card.find(["h2", "h3"])
236 + name = h.get_text(" ", strip=True) if h else ""
237 + if not key:
238 + key = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
239 + if not key or key in seen:
240 + continue
241 + seen.add(key)
242 + txt = card.get_text(" ", strip=True)
243 + # prix plancher affiché seulement (« Starting at $3,204.00 »)
244 + mprice = re.search(r"Starting\s+at\s+(\$[\d,.]+)", txt, re.I)
245 + price = parse_price(mprice.group(1)) if mprice else None
246 + if price is None:
247 + continue
248 + beds = None
249 + if re.search(r"\bStudio|\bBachelor", txt, re.I):
250 + beds = 0.0
251 + else:
252 + mb = _BED_RE.search(txt)
253 + if mb:
254 + beds = float(mb.group(1))
255 + mba = _BATH_RE.search(txt)
256 + msq = _SQFT_RE.search(txt)
257 + sqft = float(msq.group(1).replace(",", "")) if msq else None
258 + mav = re.search(r"Available\s+from\s+([\d/]+)", txt, re.I)
259 + out.append(Listing(
260 + external_id=f"{pid}-{key}",
261 + title=f"{title} — {name}" if name else title,
262 + unit_type=("Studio" if beds == 0 else normalize_unit_type(
263 + f"{int(beds)} chambres") if beds is not None else ""),
264 + bedrooms=beds,
265 + bathrooms=float(mba.group(1)) if mba else None,
266 + price=price,
267 + price_label=f"À partir de {price:.0f} $ /mois",
268 + availability=f"Disponible le {mav.group(1)}" if mav else "",
269 + area_sqft=sqft if sqft and 80 <= sqft <= 20000 else None,
270 + **common,
271 + ))
272 + return out
273 +
274 + # -- parseur 3 : cartes par catégorie (Bridge) --------------------------------
275 + def _parse_categories(self, page: str, pid: str, title: str,
276 + common: dict) -> list[Listing]:
277 + if not page:
278 + return []
279 + txt = BeautifulSoup(page, "html.parser").get_text(" ", strip=True)
280 + out: list[Listing] = []
281 + seen: set[str] = set()
282 + for m in _CAT_RE.finditer(txt):
283 + label = m.group(1).strip()
284 + beds = 0.0 if m.group(2) is None else float(m.group(2))
285 + key = "studio" if beds == 0 else f"{int(beds)}-bed"
286 + if key in seen:
287 + continue
288 + seen.add(key)
289 + try:
290 + price = float(m.group(3).replace(",", ""))
291 + except ValueError:
292 + continue
293 + if price <= 0:
294 + continue
295 + out.append(Listing(
296 + external_id=f"{pid}-cat-{key}",
297 + title=f"{title} — {label}",
298 + unit_type=("Studio" if beds == 0
299 + else normalize_unit_type(f"{int(beds)} chambres")),
300 + bedrooms=beds,
301 + price=price,
302 + price_label=f"À partir de {price:.0f} $ /mois",
303 + **common,
304 + ))
305 + return out
306 +
307 + # -- fiche riocanliving : adresse, description, visuels, microsite -----------
308 + def _fetch_detail(self, link: str) -> dict:
309 + out: dict = {"address": "", "description": "", "images": [],
310 + "microsite": "", "suites_url": ""}
311 + if not link:
312 + return out
313 + try:
314 + page = self.get(link).text
315 + except Exception:
316 + return out
317 + soup = BeautifulSoup(page, "html.parser")
318 +
319 + m = _ADDR_RE.search(soup.get_text(" ", strip=True))
320 + if m:
321 + out["address"] = re.sub(r"\s+", " ", m.group(0)).strip()
322 +
323 + # description : premiers paragraphes substantiels de la fiche
324 + paras = [q.get_text(" ", strip=True) for q in soup.find_all("p")]
325 + out["description"] = " ".join(
326 + t for t in paras if len(t) > 60)[:600]
327 +
328 + # visuels du site corporatif (app/uploads) — hors logos/icônes
329 + images: list[str] = []
330 + for img in soup.find_all("img"):
331 + src = (img.get("src") or "").strip()
332 + if "/app/uploads/" not in src or src.lower().endswith(".svg"):
333 + continue
334 + if re.search(r"logo|icon", src, re.I):
335 + continue
336 + if src not in images:
337 + images.append(src)
338 + out["images"] = images[: self.max_images]
339 +
340 + # CTA « Website » -> microsite de la tour
341 + micro = ""
342 + for a in soup.find_all("a", href=True):
343 + if a.get_text(" ", strip=True).lower() in (
344 + "website", "visit website"):
345 + micro = a["href"].strip()
346 + break
347 + out["microsite"] = micro
348 + if not micro:
349 + return out
350 +
351 + # découverte de la page « suites/plans » sur l'accueil du microsite
352 + # (la chaîne résiliente escalade seule le Cloudflare de Pivot)
353 + try:
354 + home = self.get(micro).text
355 + except Exception:
356 + return out
357 + hsoup = BeautifulSoup(home, "html.parser")
358 + for a in hsoup.find_all("a", href=True):
359 + href = a["href"].strip()
360 + if _SUITES_HREF_RE.search(href.split("?")[0]):
361 + out["suites_url"] = urljoin(micro, href)
362 + break
363 + # certains microsites affichent les plans sur l'accueil même
364 + if not out["suites_url"] and (
365 + "floorPlanData" in home or "fp-container" in home):
366 + out["suites_url"] = micro
367 + return out
added louka/connectors/sifton.py +259 −0
@@ -0,0 +1,259 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sifton.py : connecteur Sifton Properties (sifton.com)
5 +# Gros gestionnaire/promoteur de London ON (aussi Guelph, Brantford) :
6 +# appartements ET maisons de ville en location longue durée (~15 communautés
7 +# locatives, 43 « produits » = types d'unités au moment de l'écriture).
8 +# Le site est un Statamic (Laravel) dont l'API de contenu REST est PUBLIQUE
9 +# et ouverte — aucun rendu JS ni anti-bot nécessaire (le front l'utilise
10 +# aussi via /api/custom/search, plus lourd car il renvoie des cartes HTML).
11 +# Deux requêtes suffisent :
12 +# - /api/collections/places/entries?limit=100 — les « places » (41, dont
13 +# ~15 place_type=rentals) : adresse civique complète (address_street),
14 +# géocodage Google embarqué (lat/lon, ville, code postal), description
15 +# et accordéons de commodités (landing_page.page_modules), galerie
16 +# photo, microsite externe éventuel (Candela, Spektra) ;
17 +# - /api/collections/products/entries?limit=300 — les « produits »
18 +# (_type_taxonomy=rental_types) : type d'unité (« 1 Bedroom »,
19 +# « 2 Bedroom + Den »…), sous-type apartments/townhomes, prix plancher
20 +# (_price, toujours « from »), pi², taxonomie beds (studio, 1-bed,
21 +# 2_5-beds = 2 cc + den), drapeau `available`, place_slug de rattachement.
22 +# Une annonce PAR PRODUIT (type d'unité) rattachée à sa place — granularité
23 +# identique au site. Le drapeau `available` du produit pilote le texte de
24 +# disponibilité (les produits non disponibles restent affichés par Sifton
25 +# avec leur prix « Get In Touch » : on les garde, comme les plans Killam).
26 +# Baths : la taxonomie est « none » partout — jamais inventé (None).
27 +# Tout vient de l'API en 2 requêtes : pas besoin du cache BD self.detail().
28 +# Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.
29 +# -----------------------------------------------------------------------------
30 +from __future__ import annotations
31 +
32 +import os
33 +import re
34 +
35 +from bs4 import BeautifulSoup
36 +
37 +from ..schema import Listing, normalize_unit_type
38 +from .base import BaseConnector
39 +
40 +BASE = "https://sifton.com"
41 +PLACES_API = f"{BASE}/api/collections/places/entries"
42 +PRODUCTS_API = f"{BASE}/api/collections/products/entries"
43 +
44 +# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent
45 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
46 +
47 +# taxonomie beds de Statamic : « studio », « 1-bed », « 2_5-beds » (2 cc + den)
48 +_BEDS_SLUG_RE = re.compile(r"^(\d+)(_5)?-beds?$")
49 +
50 +# visuels de style de vie génériques réutilisés sur plusieurs fiches — écartés
51 +_STOCK_IMG_RE = re.compile(
52 + r"people-walking|balcony-lady|logo|favicon|icon", re.I)
53 +
54 +
55 +def _strip_html(fragment: str) -> str:
56 + return BeautifulSoup(fragment or "", "html.parser").get_text(
57 + " ", strip=True)
58 +
59 +
60 +class SiftonConnector(BaseConnector):
61 + source_id = "sifton"
62 + request_delay = 1.0
63 + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)
64 + max_properties = 40 # garde-fou places locatives (15 au 2026-08)
65 + max_listings = 200 # garde-fou produits (43 au 2026-08)
66 + max_images = 15
67 +
68 + # -- API Statamic ------------------------------------------------------------
69 + def _entries(self, url: str, limit: int) -> list[dict]:
70 + rows: list[dict] = []
71 + page = 1
72 + while page <= 10:
73 + data = self.get(url, params={"limit": limit, "page": page},
74 + headers={"Accept": "application/json"}).json()
75 + rows.extend(data.get("data") or [])
76 + meta = data.get("meta") or {}
77 + if page >= int(meta.get("last_page") or 1):
78 + break
79 + page += 1
80 + return rows
81 +
82 + # -- places locatives : contexte partagé par leurs produits -------------------
83 + def _rental_places(self) -> dict[str, dict]:
84 + out: dict[str, dict] = {}
85 + for p in self._entries(PLACES_API, 100):
86 + ptype = (p.get("place_type") or {})
87 + if not isinstance(ptype, dict) or ptype.get("slug") != "rentals":
88 + continue
89 + if p.get("status") not in (None, "published"):
90 + continue
91 + if len(out) >= self.max_properties:
92 + break
93 + try:
94 + out[p["slug"]] = self._place_ctx(p)
95 + except Exception:
96 + continue
97 + return out
98 +
99 + def _place_ctx(self, p: dict) -> dict:
100 + gm = p.get("address_google_maps") or {}
101 + city = (gm.get("locality") or "").strip()
102 + street = " ".join(x for x in (
103 + (gm.get("streetNumber") or "").strip(),
104 + (gm.get("streetName") or "").strip()) if x)
105 + # address_street du CMS (« 550 Berkshire Drive, London, ON ») est déjà
106 + # complète et plus fiable que le géocodage pour les adresses multiples
107 + # (« 577/587/597/607 Cranbrook Road ») ; on la suffixe du code postal
108 + address = (p.get("address_street") or "").strip().rstrip(",")
109 + if not address:
110 + address = ", ".join(x for x in (street, city) if x) + ", ON"
111 + postal = (gm.get("postalCode") or "").strip()
112 + if postal and postal not in address:
113 + address += f" {postal}"
114 +
115 + try:
116 + lat = float(gm.get("lat")) or None
117 + lng = float(gm.get("lon")) or None
118 + except (TypeError, ValueError):
119 + lat = lng = None
120 +
121 + # description + accordéons de commodités + galerie : modules de la
122 + # landing page (structure Statamic, servie directement par l'API)
123 + desc_parts: list[str] = []
124 + amenities: list[str] = []
125 + images: list[str] = []
126 + card = (p.get("card_image") or {}).get("permalink")
127 + if card:
128 + images.append(card)
129 + modules = ((p.get("landing_page") or {}).get("page_modules")) or []
130 + for m in modules:
131 + mtype = m.get("type")
132 + if mtype == "two_col_text_acc":
133 + t = _strip_html(m.get("text_acc_content_text") or "")
134 + if t:
135 + desc_parts.append(t)
136 + acc = (m.get("text_acc_content_acc") or {})
137 + for item in acc.get("accordion_repeater") or []:
138 + for blk in item.get("acc_dropdown_content") or []:
139 + soup = BeautifulSoup(blk.get("text") or "",
140 + "html.parser")
141 + for li in soup.find_all("li"):
142 + t = li.get_text(" ", strip=True)
143 + if t and len(t) < 90 and t not in amenities:
144 + amenities.append(t)
145 + elif mtype == "image_gallery":
146 + for img in m.get("images") or []:
147 + u = (img.get("permalink") or "").strip()
148 + if u and not _STOCK_IMG_RE.search(u) and u not in images:
149 + images.append(u)
150 +
151 + # URL publique : microsite externe (Candela, Spektra) sinon la fiche
152 + url = (p.get("external_url") or "").strip() or \
153 + (p.get("permalink") or "").strip() or BASE
154 +
155 + return {
156 + "title": (p.get("title") or "").strip(),
157 + "address": address, "city": city, "lat": lat, "lng": lng,
158 + "url": url, "description": " ".join(desc_parts)[:900],
159 + "amenities": amenities[:25],
160 + "images": images[: self.max_images],
161 + }
162 +
163 + # -- collecte : une annonce par produit locatif -------------------------------
164 + def fetch(self) -> list[Listing]:
165 + places = self._rental_places()
166 + listings: list[Listing] = []
167 + for pr in self._entries(PRODUCTS_API, 300):
168 + if len(listings) >= self.max_listings:
169 + break
170 + try:
171 + if pr.get("_type_taxonomy") != "rental_types":
172 + continue
173 + if pr.get("status") not in (None, "published"):
174 + continue
175 + b = places.get(pr.get("place_slug") or "")
176 + if b is None:
177 + continue # place non locative ou hors garde-fou
178 + listings.append(self._listing(pr, b))
179 + except Exception:
180 + continue
181 + return listings
182 +
183 + def _listing(self, pr: dict, b: dict) -> Listing:
184 + title_raw = (pr.get("title") or "").strip() # « 2 Bedroom + Den »
185 +
186 + # chambres fermées : taxonomie beds (studio / n-bed / n_5-beds, où
187 + # « _5 » = den, pas une chambre) — jamais inventé
188 + beds = None
189 + slug_beds = ((pr.get("beds") or {}).get("slug") or "").strip()
190 + if slug_beds == "studio":
191 + beds = 0.0
192 + else:
193 + m = _BEDS_SLUG_RE.match(slug_beds)
194 + if m:
195 + beds = float(m.group(1))
196 +
197 + # type d'unité : maison de ville -> « Maison », sinon dérivé des cc
198 + subtypes = pr.get("_subtypes") or []
199 + if "townhomes" in subtypes:
200 + unit_type = normalize_unit_type("townhouse")
201 + elif beds == 0:
202 + unit_type = "Studio"
203 + elif beds is not None:
204 + unit_type = normalize_unit_type(f"{int(beds)} chambres")
205 + else:
206 + unit_type = normalize_unit_type(title_raw)
207 +
208 + # prix : toujours « from » chez Sifton (_price_type = from)
209 + price = None
210 + try:
211 + price = float(pr.get("_price") or 0) or None
212 + except (TypeError, ValueError):
213 + pass
214 + price_label = f"À partir de {price:.0f} $ /mois" if price else ""
215 +
216 + # superficie plancher plausible (sq_ft_end = haut de fourchette)
217 + area = None
218 + try:
219 + v = float(pr.get("_sqft") or 0)
220 + if 80 <= v <= 20000:
221 + area = v
222 + except (TypeError, ValueError):
223 + pass
224 +
225 + # baths : taxonomie « none » sur tout le catalogue -> inconnu (None)
226 + baths = None
227 + slug_baths = ((pr.get("baths") or {}).get("slug") or "").strip()
228 + m = re.match(r"^(\d+)(_5)?-baths?$", slug_baths)
229 + if m:
230 + baths = float(m.group(1)) + (0.5 if m.group(2) else 0.0)
231 +
232 + available = pr.get("available") is True
233 + details: dict = {}
234 + if "townhomes" in subtypes:
235 + details["Type de bâtiment"] = "Maison de ville"
236 +
237 + return Listing(
238 + source=self.source_id,
239 + external_id=(pr.get("slug") or pr.get("id") or "").strip(),
240 + url=b["url"],
241 + title=f"{b['title']} — {title_raw}" if title_raw else b["title"],
242 + address=b["address"],
243 + city=b["city"],
244 + province="ON",
245 + unit_type=unit_type,
246 + bedrooms=beds,
247 + bathrooms=baths,
248 + price=price,
249 + price_label=price_label,
250 + availability=("Unités disponibles" if available
251 + else "Aucune unité disponible actuellement"),
252 + area_sqft=area,
253 + description=b["description"],
254 + amenities=b["amenities"],
255 + details=details,
256 + images=b["images"],
257 + lat=b["lat"],
258 + lng=b["lng"],
259 + )
added louka/connectors/starlight.py +259 −0
@@ -0,0 +1,259 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/starlight.py : connecteur Starlight Investments (starlightinvest.com)
5 +# Plus grand propriétaire résidentiel privé du Canada (~60 000 unités). Le
6 +# site corpo (Craft CMS) n'a pas de page /properties : le portefeuille est
7 +# exposé par le « Property Finder » de /real-estate/canadian-residential,
8 +# alimenté par un index Algolia PUBLIC (appId PX9UWFRLZ3, clé search-only
9 +# embarquée dans la page, index `prod_properties`, ~549 immeubles dont ~391
10 +# « live » en Ontario). Chaque enregistrement donne : nom d'immeuble (le plus
11 +# souvent l'adresse civique), ville, province et l'URL d'un MICROSITE par
12 +# immeuble. Aucun prix/plan d'étage dans l'index.
13 +# Les microsites (317 domaines ON distincts) partagent majoritairement un
14 +# gabarit Duda + PropertyVista, MAIS les plans d'étage y sont chargés par un
15 +# GraphQL (DudaWebsiteService_getFloorplanList) dont l'endpoint vit dans une
16 +# collection Duda `vista_api_url` inaccessible hors runtime (400/500 même en
17 +# navigateur headless — vérifié via Scrapfly js_scenario) : pas de pattern
18 +# prix exploitable. On émet donc UNE annonce PAR PROPRIÉTÉ (granularité
19 +# immeuble, sans prix — jamais de valeur inventée) et on enrichit chaque
20 +# fiche avec le HTML serveur du microsite (meta description, téléphone,
21 +# galerie irp.cdn-website.com filtrée par l'alias du site) via le cache BD
22 +# self.detail() — budget max_details par sync, le reste vient du cache.
23 +# Les immeubles dont le « microsite » est en réalité une page metcap.com ou
24 +# cogir.net sont SAUTÉS : les connecteurs dédiés `metcap` et `cogir` (tous
25 +# deux gatés Ontario) couvrent déjà ces gestionnaires — zéro doublon.
26 +# Idem sterlingkaramar.com (65 propriétés) : couvert par rsgw_sterlingkaramar.
27 +# -----------------------------------------------------------------------------
28 +from __future__ import annotations
29 +
30 +import hashlib
31 +import json
32 +import os
33 +import re
34 +from urllib.parse import urlparse
35 +
36 +from ..schema import Listing
37 +from .base import BaseConnector
38 +
39 +# index Algolia public du Property Finder (clé search-only embarquée dans
40 +# https://www.starlightinvest.com/real-estate/canadian-residential)
41 +ALGOLIA_APP = "PX9UWFRLZ3"
42 +ALGOLIA_KEY = "5d9417503fde20d753b7a25a301f8991"
43 +ALGOLIA_URL = (f"https://{ALGOLIA_APP.lower()}-dsn.algolia.net"
44 + "/1/indexes/prod_properties/query")
45 +FINDER_PAGE = ("https://www.starlightinvest.com/real-estate/"
46 + "canadian-residential#property-finder")
47 +
48 +# Gate expansion Ontario : le connecteur reste hors registre tant que la
49 +# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée (zéro impact prod).
50 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
51 +
52 +# gestionnaires déjà couverts par un connecteur Lou-Ka dédié (gaté Ontario) —
53 +# leurs immeubles Starlight sont sautés ici pour éviter les doublons
54 +_SKIP_DOMAINS = ("metcap.com", "cogir.net", "sterlingkaramar.com")
55 +
56 +# fragment d'adresse civique dans le titre (« 1637 - 1645 Bathurst Street »,
57 +# « Yonge St Towns: 389 Yonge Street », « St Regents Place - 2375 The
58 +# Collegeway ») — on n'utilise le titre comme adresse QUE s'il y ressemble
59 +_ADDR_RE = re.compile(
60 + r"\d[\d\s&/-]*\s+[A-Za-z'’.\- ]+\b(?:Street|St\.?|Avenue|Ave\.?|Road|"
61 + r"Rd\.?|Drive|Dr\.?|Boulevard|Blvd\.?|Court|Crt\.?|Crescent|Cres\.?|"
62 + r"Lane|Ln\.?|Way|Place|Pl\.?|Terrace|Circle|Cir\.?|Parkway|Pkwy\.?|"
63 + r"Gate|Square|Sq\.?|Trail|Line|Highway|Hwy\.?|Gardens|Grove|Path|"
64 + r"Promenade|Quay|Row|Walk|Heights|Collegeway|Queensway|Kingsway|"
65 + r"Esplanade|Appleby|Mall)\b[.A-Za-z'’\- ]*", re.I)
66 +
67 +# visuels du microsite Duda à ÉCARTER (logos gestionnaire, drapeaux du
68 +# sélecteur de langue, pictos, bandeau-titre « just address » du gabarit)
69 +_IMG_SKIP_RE = re.compile(
70 + r"logo|flag|icon|favicon|\+100px|badge|certif|just\+address|\+font\+",
71 + re.I)
72 +_META_DESC_RE = re.compile(
73 + r'<meta\s+(?:name|property)="(?:og:)?description"\s+content="([^"]*)"',
74 + re.I)
75 +_SITE_ALIAS_RE = re.compile(r"siteAlias\s*=\s*'([0-9a-f]{6,10})'")
76 +_TEL_RE = re.compile(r'href="tel:([\d\s().+-]{7,20})"')
77 +_DUDA_IMG_RE = re.compile(
78 + r"https://irp\.cdn-website\.com/[^\"'\\\s)]+\.(?:jpe?g|png|webp)", re.I)
79 +
80 +
81 +class StarlightConnector(BaseConnector):
82 + source_id = "starlight"
83 + request_delay = 1.0 # microsites variés : rester poli partout
84 + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)
85 + max_properties = 450 # garde-fou (~391 propriétés ON « live »)
86 + max_details = 10 # vraies visites de microsites par sync (le
87 + # reste est servi par le cache BD self.detail)
88 + max_images = 12
89 +
90 + def __init__(self) -> None:
91 + super().__init__()
92 + self._detail_fetches = 0
93 +
94 + # -- portefeuille : index Algolia public du Property Finder -----------------
95 + def _properties(self) -> list[dict]:
96 + headers = {
97 + "X-Algolia-Application-Id": ALGOLIA_APP,
98 + "X-Algolia-API-Key": ALGOLIA_KEY,
99 + "Content-Type": "application/json",
100 + }
101 + hits: list[dict] = []
102 + page = 0
103 + while True:
104 + resp = self.post(ALGOLIA_URL, headers=headers, data=json.dumps({
105 + "query": "",
106 + "page": page,
107 + "hitsPerPage": 1000,
108 + "attributesToRetrieve": ["title", "website", "address",
109 + "status", "section"],
110 + "attributesToHighlight": [],
111 + }))
112 + data = resp.json()
113 + hits.extend(data.get("hits") or [])
114 + page += 1
115 + if page >= int(data.get("nbPages") or 1) or page > 5:
116 + break
117 + if not hits:
118 + raise RuntimeError("Starlight : index Algolia prod_properties "
119 + "vide (clé/app modifiée ?)")
120 + return hits
121 +
122 + def fetch(self) -> list[Listing]:
123 + listings: list[Listing] = []
124 + count = 0
125 + seen: set[tuple[str, str]] = set()
126 + for p in self._properties():
127 + if (p.get("status") or "") != "live":
128 + continue
129 + addr = p.get("address") or {}
130 + if ((addr.get("state") or "").strip().upper()) != "ON":
131 + continue # portefeuille national : Ontario seulement ici
132 + website = (p.get("website") or "").strip()
133 + if not website:
134 + continue
135 + host = (urlparse(website).hostname or "").lower()
136 + if any(host == d or host.endswith("." + d)
137 + for d in _SKIP_DOMAINS):
138 + continue # gestionnaire déjà couvert (metcap/cogir)
139 + # l'index contient des variantes du même immeuble (« 1637 - 1645 »
140 + # vs « 1637-1645 Bathurst ») : dédoublonnage titre+microsite
141 + key = (host, re.sub(r"\s*-\s*", "-",
142 + re.sub(r"\s+", " ",
143 + (p.get("title") or "").strip().lower())))
144 + if key in seen:
145 + continue
146 + seen.add(key)
147 + if count >= self.max_properties:
148 + break
149 + count += 1
150 + try:
151 + lst = self._listing(p, addr, website)
152 + if lst is not None:
153 + listings.append(lst)
154 + except Exception:
155 + continue
156 + return listings
157 +
158 + # -- une annonce par propriété (granularité immeuble, sans prix) ------------
159 + def _listing(self, p: dict, addr: dict, website: str) -> Listing | None:
160 + title = re.sub(r"\s+", " ", (p.get("title") or "")).strip()
161 + city = (addr.get("city") or "").strip()
162 + if not title or not city:
163 + return None
164 +
165 + # adresse civique : champ street1 quand il existe (rare), sinon le
166 + # titre s'il ressemble à une adresse — sinon rien (pas d'invention)
167 + street = (addr.get("street1") or "").strip()
168 + if not street:
169 + m = _ADDR_RE.search(title)
170 + if m:
171 + street = m.group(0).strip()
172 + full_addr = ""
173 + if street:
174 + full_addr = f"{street}, {city}, ON"
175 + postal = (addr.get("zip") or "").strip()
176 + if postal:
177 + full_addr += f" {postal}"
178 +
179 + # coordonnées GPS de l'index (présentes sur ~5 % des fiches ;
180 + # finalize() valide la bbox Ontario)
181 + try:
182 + lat = float(addr.get("lat")) or None
183 + lng = float(addr.get("lng")) or None
184 + except (TypeError, ValueError):
185 + lat = lng = None
186 +
187 + # fiche microsite (description, téléphone, galerie) — via le cache BD :
188 + # revisitée seulement si la ligne de l'index change
189 + ext_id = str(p.get("objectID") or "").strip()
190 + feed_key = hashlib.sha1("|".join(str(x) for x in (
191 + title, website, city, street, addr.get("zip"), lat, lng,
192 + )).encode("utf-8")).hexdigest()
193 + d: dict = {}
194 + try:
195 + d = self.detail(ext_id, feed_key,
196 + lambda: self._fetch_detail(website))
197 + except Exception:
198 + pass # budget détail épuisé ou microsite injoignable
199 +
200 + details: dict = {"property_manager_site": website}
201 + phone = (d.get("phone") or "").strip()
202 + if phone:
203 + details["contact"] = {"phone": phone}
204 +
205 + return Listing(
206 + source=self.source_id,
207 + external_id=ext_id,
208 + url=website,
209 + title=title,
210 + address=full_addr,
211 + city=city,
212 + province="ON",
213 + description=d.get("description") or "",
214 + images=list(d.get("images") or [])[: self.max_images],
215 + details=details,
216 + lat=lat,
217 + lng=lng,
218 + )
219 +
220 + # -- microsite : meta description + téléphone + galerie (HTML serveur) ------
221 + def _fetch_detail(self, url: str) -> dict:
222 + """Extrait les champs sûrs du HTML serveur du microsite Duda.
223 +
224 + Les prix/plans d'étage sont volontairement ignorés : ils sont chargés
225 + côté client par un GraphQL PropertyVista inaccessible (voir en-tête).
226 + """
227 + if self._detail_fetches >= self.max_details:
228 + raise RuntimeError("budget de microsites atteint") # non mis en cache
229 + self._detail_fetches += 1
230 + out: dict = {"description": "", "phone": "", "images": []}
231 + try:
232 + html = self.get(url).text
233 + except Exception:
234 + return out # microsite mort/parqué : cache vide, on n'y
235 + # retournera que si la ligne de l'index change
236 +
237 + m = _META_DESC_RE.search(html)
238 + if m:
239 + import html as _h
240 + out["description"] = _h.unescape(m.group(1)).strip()[:900]
241 +
242 + m = _TEL_RE.search(html)
243 + if m:
244 + out["phone"] = re.sub(r"\s+", " ", m.group(1)).strip()
245 +
246 + # galerie : uniquement les visuels du site lui-même (chemin contenant
247 + # l'alias Duda du microsite), hors logos/drapeaux/pictos
248 + alias_m = _SITE_ALIAS_RE.search(html)
249 + alias = alias_m.group(1) if alias_m else None
250 + images: list[str] = []
251 + for src in _DUDA_IMG_RE.findall(html):
252 + if alias and f"/{alias}/" not in src:
253 + continue # visuel partagé du gabarit (logo gestionnaire…)
254 + if _IMG_SKIP_RE.search(src):
255 + continue
256 + if src not in images:
257 + images.append(src)
258 + out["images"] = images[: self.max_images]
259 + return out
added louka/connectors/westdale.py +250 −0
@@ -0,0 +1,250 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/westdale.py : connecteur Westdale Properties (westdaleproperties.com)
5 +# Gestionnaire familial de Toronto/North York (14 immeubles résidentiels au
6 +# moment de l'écriture : Forest Hill, Redpath, St George, Don Mills…).
7 +# Site legacy ASP.NET « VICommunity » entièrement rendu serveur, AUCUN
8 +# anti-bot (requests direct suffit ; réponses gzip gérées par requests) :
9 +# - la PAGE D'ACCUEIL est la liste : blocs `.bld-content` avec
10 +# onclick="window.open('rental/<slug>','_self')", nom (h3), résumé
11 +# (p.brief), « N suite options | From $X » (p.options) et vignette ;
12 +# - la fiche /rental/<slug> donne l'adresse civique + téléphone (premier
13 +# <p><a maps>), la description (#txtDetail), la galerie (#mainslider),
14 +# les commodités (h2 Suite/Building amenities + Utilities -> tags), le
15 +# stationnement, et l'accordéon Options : une ligne `.btn_option` par
16 +# type de suite — nom, « N (lit) N (bain) » (icônes svg, baths peut
17 +# être 1.5), « Starting at $X », « Size: N ft² » — avec plan d'étage
18 +# et photos de la suite (#single_phoN, « Not Available. » = pas de
19 +# photos, PAS une indisponibilité).
20 +# Une annonce PAR TYPE DE SUITE (ligne d'accordéon avec prix), repli « une
21 +# annonce par immeuble » avec le prix « From $X » du bloc de la liste.
22 +# Les prix sont des « Starting at » de catalogue (« Please call for
23 +# availabilities ») : availability laissée vide — rien d'inventé. Idem
24 +# animaux : la section « Pet-Friendly » est vide partout -> pets None.
25 +# Fiches revisitées via self.detail() seulement quand le bloc de la liste
26 +# change (résumé, nombre d'options, prix plancher, vignette).
27 +# Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.
28 +# -----------------------------------------------------------------------------
29 +from __future__ import annotations
30 +
31 +import hashlib
32 +import os
33 +import re
34 +
35 +from bs4 import BeautifulSoup
36 +
37 +from ..schema import Listing, normalize_unit_type
38 +from .base import BaseConnector
39 +
40 +BASE = "https://www.westdaleproperties.com"
41 +
42 +# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent
43 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
44 +
45 +_SLUG_RE = re.compile(r"window\.open\('rental/([^']+)'")
46 +_PRICE_RE = re.compile(r"(?:From|Starting at)\s*\$\s*([\d,]+(?:\.\d{2})?)",
47 + re.I)
48 +_SQFT_RE = re.compile(r"Size:\s*([\d,]+)\s*ft", re.I)
49 +# colonnes lits/bains : « 1 <img class='bed-svg'…> 1.5 <img class='bath-svg'…> »
50 +_BED_RE = re.compile(r"([\d.]+)\s*<img[^>]*bed-svg")
51 +_BATH_RE = re.compile(r"([\d.]+)\s*<img[^>]*bath-svg")
52 +
53 +
54 +class WestdaleConnector(BaseConnector):
55 + source_id = "westdale"
56 + request_delay = 1.0
57 + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)
58 + max_properties = 25 # garde-fou (14 immeubles au 2026-08)
59 + max_listings = 150 # garde-fou lignes de suites (~66 au 2026-08)
60 + max_images = 15
61 +
62 + # -- liste : blocs .bld-content de la page d'accueil --------------------------
63 + def fetch(self) -> list[Listing]:
64 + soup = BeautifulSoup(self.get(BASE + "/").text, "html.parser")
65 + listings: list[Listing] = []
66 + count = 0
67 + for block in soup.select(".bld-content[onclick]"):
68 + m = _SLUG_RE.search(block.get("onclick") or "")
69 + if not m:
70 + continue # blocs non résidentiels (tel:, Admin/…)
71 + if count >= self.max_properties or \
72 + len(listings) >= self.max_listings:
73 + break
74 + count += 1
75 + try:
76 + listings.extend(self._listings(m.group(1), block))
77 + except Exception:
78 + continue
79 + return listings[: self.max_listings]
80 +
81 + # -- annonces d'un immeuble (une par type de suite) ---------------------------
82 + def _listings(self, slug: str, block) -> list[Listing]:
83 + # nom : le h3 du bloc texte (PAS le h3.bonus du bandeau promo)
84 + h3 = block.select_one(".text h3") or block.h3
85 + name = h3.get_text(" ", strip=True) if h3 else slug
86 + block_txt = block.get_text(" ", strip=True)
87 + img = block.find("img")
88 + thumb = self._abs((img.get("src") or "").strip()) if img else ""
89 +
90 + url = f"{BASE}/rental/{slug}"
91 + # fiche revisitée seulement quand le bloc de la liste change
92 + feed_key = hashlib.sha1(
93 + f"{block_txt}|{thumb}".encode("utf-8")).hexdigest()
94 + d = self.detail(slug, feed_key, lambda: self._fetch_detail(url))
95 +
96 + city = (d.get("city") or "").strip() or "Toronto"
97 + details: dict = {}
98 + if d.get("phone"):
99 + details["contact"] = {"phone": d["phone"]}
100 + if d.get("parking"):
101 + details["Stationnement"] = d["parking"]
102 +
103 + base_images = [u for u in (d.get("images") or []) if u] or \
104 + ([thumb] if thumb else [])
105 +
106 + common = dict(
107 + source=self.source_id, url=url,
108 + address=d.get("address") or "", city=city, province="ON",
109 + description=(d.get("description") or "")[:900],
110 + amenities=(d.get("amenities") or [])[:25],
111 + )
112 +
113 + out: list[Listing] = []
114 + for su in d.get("suites") or []:
115 + if su.get("price") is None:
116 + continue # ligne sans « Starting at » : rien d'inventé
117 + images = [u for u in (su.get("images") or []) + base_images
118 + if u][: self.max_images]
119 + beds = su.get("beds")
120 + out.append(Listing(
121 + external_id=f"{slug}-{su['id']}",
122 + title=f"{name} — {su['name']}" if su.get("name") else name,
123 + unit_type=self._unit_type(su.get("name") or "", beds),
124 + bedrooms=beds,
125 + bathrooms=su.get("baths"),
126 + price=su["price"],
127 + price_label=f"À partir de {su['price']:.0f} $ /mois",
128 + area_sqft=su.get("sqft"),
129 + details=dict(details),
130 + images=images,
131 + **common,
132 + ))
133 + if out:
134 + return out
135 +
136 + # repli : une annonce par immeuble avec le « From $X » du bloc liste
137 + mp = _PRICE_RE.search(block_txt)
138 + if not mp:
139 + return [] # ni suite avec prix ni prix plancher : rien
140 + price = float(mp.group(1).replace(",", ""))
141 + return [Listing(
142 + external_id=slug,
143 + title=name,
144 + price=price,
145 + price_label=f"À partir de {price:.0f} $ /mois",
146 + details=details,
147 + images=base_images[: self.max_images],
148 + **common,
149 + )]
150 +
151 + @staticmethod
152 + def _unit_type(su_name: str, beds: float | None) -> str:
153 + """Type d'unité : nom de la ligne (Bachelor -> Studio) sinon les cc."""
154 + ut = normalize_unit_type(su_name)
155 + if ut and ut != su_name.strip():
156 + return ut
157 + if beds == 0:
158 + return "Studio"
159 + if beds is not None:
160 + return normalize_unit_type(f"{int(beds)} chambres")
161 + return ut
162 +
163 + @staticmethod
164 + def _abs(u: str) -> str:
165 + return u if u.startswith("http") else (BASE + u if u else "")
166 +
167 + # -- fiche /rental/<slug> ------------------------------------------------------
168 + def _fetch_detail(self, url: str) -> dict:
169 + soup = BeautifulSoup(self.get(url).text, "html.parser")
170 + out: dict = {"suites": []}
171 +
172 + # adresse + téléphone : premier <p> sous le h1 (liens maps + tel:)
173 + h1 = soup.find("h1", class_="property_title")
174 + p = h1.find_next("p") if h1 else None
175 + if p:
176 + a_map = p.find("a", href=re.compile(r"google\.com/maps"))
177 + if a_map:
178 + addr = a_map.get_text(" ", strip=True)
179 + out["address"] = addr
180 + # « 484 Avenue Rd, Toronto, ON » -> ville avant « ON »
181 + parts = [x.strip() for x in addr.split(",")]
182 + if len(parts) >= 2 and parts[-1].upper().startswith("ON"):
183 + out["city"] = parts[-2]
184 + a_tel = p.find("a", href=re.compile(r"^tel:"))
185 + if a_tel:
186 + out["phone"] = a_tel.get_text(" ", strip=True)
187 +
188 + desc = soup.find(id="txtDetail")
189 + if desc:
190 + out["description"] = desc.get_text(" ", strip=True)
191 +
192 + # galerie de l'immeuble (#mainslider)
193 + images: list[str] = []
194 + slider = soup.find(id="mainslider")
195 + for a in (slider.find_all("a", class_="pics") if slider else []):
196 + u = self._abs((a.get("href") or "").strip())
197 + if u and u not in images:
198 + images.append(u)
199 + out["images"] = images[: self.max_images]
200 +
201 + # commodités (suite + immeuble) et services inclus
202 + amenities: list[str] = []
203 + for div in soup.select(".amenity_tags, .utility_tags"):
204 + t = div.get_text(" ", strip=True)
205 + if t and len(t) < 90 and t not in amenities:
206 + amenities.append(t)
207 + out["amenities"] = amenities
208 +
209 + # stationnement : <p> sous le h2 « Parking »
210 + for h2 in soup.find_all("h2"):
211 + if h2.get_text(strip=True).lower() == "parking":
212 + pp = h2.find_next("p")
213 + if pp:
214 + out["parking"] = pp.get_text(" ", strip=True)[:300]
215 + break
216 +
217 + # accordéon Options : une ligne .btn_option par type de suite
218 + for row in soup.select("#accordion .group .btn_option"):
219 + rid = (row.get("id") or "").strip()
220 + cols = row.select("[class*=col-sm]")
221 + su_name = cols[0].get_text(" ", strip=True) if cols else ""
222 + if not rid or not su_name:
223 + continue
224 + row_html = str(row)
225 + mb = _BED_RE.search(row_html)
226 + mba = _BATH_RE.search(row_html)
227 + txt = row.get_text(" ", strip=True)
228 + mp = _PRICE_RE.search(txt)
229 + msq = _SQFT_RE.search(txt)
230 + sqft = float(msq.group(1).replace(",", "")) if msq else None
231 + # photos propres à la suite (« Not Available. » = aucune)
232 + su_imgs: list[str] = []
233 + pho = soup.find(id=f"single_pho{rid}")
234 + for a in (pho.find_all("a") if pho else []):
235 + u = self._abs((a.get("href") or "").strip())
236 + if u and u not in su_imgs:
237 + su_imgs.append(u)
238 + beds = float(mb.group(1)) if mb else \
239 + (0.0 if re.search(r"bachelor|studio", su_name, re.I) else None)
240 + out["suites"].append({
241 + "id": rid,
242 + "name": su_name,
243 + "beds": beds,
244 + "baths": float(mba.group(1)) if mba else None,
245 + "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,
246 + "price": (float(mp.group(1).replace(",", ""))
247 + if mp else None),
248 + "images": su_imgs[:5],
249 + })
250 + return out
251