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): 20 nouveaux connecteurs ON + socle province, tout gaté LOUKA_ONTARIO=1 (zéro impact prod QC)

Socle : Listing.province + bbox coordonnées ON (schema.py), migration
province TEXT DEFAULT 'QC' (db.py), géocodage ON via Nominatim sans repli
Adresses Québec (geocode.py), townhouse/bungalow résidentiels (quality.py).

Nouveaux connecteurs (disabled hors gate) : liftsystem.py (13 clients
dynamiques lift_* via data/liftsystem_clients.json — Greenwin, Park,
Williams & McDaniel, Briarlane, DMS, Signet, Shelter, York, Terracorp,
RentSarnia, First North, TVM, Urbandale ; 922 propriétés résidentielles),
rentcafe.py (4 clients rc_* via data/rentcafe_clients.json — Effort Trust,
Osgoode ON, GWLRA, O'Shanter ; Scrapfly ASP), homestead.py + skyline.py
(passerelle publique website-gateway.rentsync.com), killam.py (blob
#available_prop RentCafe).

Connecteurs QC élargis sous gate (uids QC vérifiés identiques hors gate) :
capreit +189, boardwalk +74, minto +81, hazelview +14, interrent +83,
morguard +258, akelius +95, metcap +60, realstar +12, cogir +38,
centurion +52 annonces ON potentielles.

sources.json : +19 entrées ON (status inactif) + skyline recyclé.
Registre : 365 connecteurs sans gate (inchangé), 385 avec LOUKA_ONTARIO=1.
Potentiel à l'activation : ~1 200-1 600 annonces ON (~30 sources).

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

23 changed files +3,239 −279

added data/liftsystem_clients.json +180 −0
@@ -0,0 +1,180 @@
1 +{
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",
4 + "clients": [
5 + {
6 + "id": "greenwin",
7 + "name": "Greenwin Corp",
8 + "site": "https://www.greenwin.ca",
9 + "client_id": 2,
10 + "listing_url": "https://www.greenwin.ca/apartments-for-rent/cities/toronto",
11 + "regions": ["Toronto/GTA", "Kitchener", "Hamilton", "Brampton", "Guelph"],
12 + "status": "valide",
13 + "properties_on": 164,
14 + "notes": "~20 000 unités gérées ; 164 propriétés ON au flux (Toronto 76, Kitchener 16, North York 14, Hamilton 13)"
15 + },
16 + {
17 + "id": "park",
18 + "name": "Park Property Management",
19 + "site": "https://www.parkproperty.ca",
20 + "client_id": 65,
21 + "listing_url": "https://www.parkproperty.ca/apartments-for-rent",
22 + "regions": ["Toronto/GTA", "Stratford", "Waterloo", "Cambridge", "Burlington", "London"],
23 + "status": "valide",
24 + "properties_on": 64,
25 + "notes": "64 propriétés ON (Stratford 13, Toronto 13, Scarborough 9, Waterloo 7)"
26 + },
27 + {
28 + "id": "williamsmcdaniel",
29 + "name": "Williams & McDaniel",
30 + "site": "https://www.williamsandmcdaniel.com",
31 + "client_id": 12,
32 + "listing_url": "https://www.williamsandmcdaniel.com/apartments",
33 + "regions": ["Guelph", "Sault Ste. Marie", "Fergus", "Midland", "Kitchener", "Hamilton", "Stratford"],
34 + "status": "valide",
35 + "properties_on": 172,
36 + "notes": "177 propriétés au flux dont 172 ON + 5 AB (filtrées) ; Guelph 54, Sault Ste. Marie 30, Fergus 20"
37 + },
38 + {
39 + "id": "briarlane",
40 + "name": "Briarlane Rental Property Management",
41 + "site": "https://www.briarlane.ca",
42 + "client_id": 470,
43 + "listing_url": "https://www.briarlane.ca/apartments-for-rent",
44 + "regions": ["London", "Toronto/GTA", "Markham/York", "Brampton", "Mississauga"],
45 + "status": "valide",
46 + "properties_on": 203,
47 + "notes": "204 propriétés dont 203 ON + 1 SK (filtrée) ; London 52, Toronto 43, North York 34 — rare couverture Vaughan/York"
48 + },
49 + {
50 + "id": "dms",
51 + "name": "DMS Property Management",
52 + "site": "https://www.dmsproperty.com",
53 + "client_id": 122,
54 + "listing_url": "https://www.dmsproperty.com/apartments-for-rent",
55 + "regions": ["Toronto/GTA", "Hamilton", "Kingston", "Barrie", "Richmond Hill", "Welland"],
56 + "status": "valide",
57 + "properties_on": 97,
58 + "notes": "97 propriétés ON (Toronto 30, Hamilton 15, North York 14, Kingston 7)"
59 + },
60 + {
61 + "id": "signet",
62 + "name": "Signet Group",
63 + "site": "https://www.signetgroup.ca",
64 + "client_id": 84,
65 + "listing_url": "https://www.signetgroup.ca/apartments",
66 + "regions": ["Toronto", "Mississauga", "Kitchener", "Cambridge", "Stratford"],
67 + "status": "valide",
68 + "properties_on": 131,
69 + "notes": "131 propriétés ON (Toronto 71, Mississauga 12, Kitchener 7, Cambridge 7)"
70 + },
71 + {
72 + "id": "shelter",
73 + "name": "Shelter Canadian Properties",
74 + "site": "https://www.scpl.com",
75 + "client_id": 407,
76 + "listing_url": "https://www.scpl.com/residential-rental",
77 + "regions": ["Toronto", "Ottawa", "Belleville", "Thunder Bay", "Orillia"],
78 + "status": "valide",
79 + "properties_on": 26,
80 + "permalink_sub": ["/images/", "/residential-rental/"],
81 + "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 + },
83 + {
84 + "id": "york",
85 + "name": "York Property Management",
86 + "site": "https://www.yorkproperty.ca",
87 + "client_id": 71,
88 + "listing_url": "https://www.yorkproperty.ca/apartments-for-rent/london",
89 + "regions": ["Windsor", "London", "Sarnia", "Brantford", "Woodstock", "sud-ouest ON"],
90 + "status": "valide",
91 + "properties_on": 77,
92 + "notes": "77 propriétés ON (Windsor 32, London 23, Sarnia 7) ; client_id via EnhancedSearch.config.client.id = 71 sur les pages villes"
93 + },
94 + {
95 + "id": "terracorp",
96 + "name": "Terracorp Management (Auburn Developments)",
97 + "site": "https://terracorp.ca",
98 + "client_id": 684,
99 + "listing_url": "https://terracorp.ca/apartments-for-rent",
100 + "regions": ["London", "Windsor", "Kitchener", "Owen Sound"],
101 + "status": "valide",
102 + "properties_on": 33,
103 + "notes": "33 propriétés ON (London 19, Windsor 3, Kitchener 3)"
104 + },
105 + {
106 + "id": "rentsarnia",
107 + "name": "Mutual Property Management (RentSarnia)",
108 + "site": "https://rentsarnia.com",
109 + "client_id": 115,
110 + "listing_url": "https://rentsarnia.com/",
111 + "regions": ["Sarnia", "Corunna"],
112 + "status": "valide",
113 + "properties_on": 70,
114 + "notes": "70 propriétés ON (Sarnia 67, Corunna 3) ; portail dédié de Mutual PM"
115 + },
116 + {
117 + "id": "firstnorth",
118 + "name": "First North Property Management",
119 + "site": "https://firstnorthpm.com",
120 + "client_id": 558,
121 + "listing_url": "https://firstnorthpm.com/apartments-for-rent",
122 + "regions": ["Sudbury", "North Bay", "Timmins", "Nord de l'Ontario"],
123 + "status": "valide",
124 + "properties_on": 27,
125 + "notes": "28 propriétés dont 27 ON (Sudbury 16, North Bay 5, Timmins 4) + 1 sans province (filtrée)"
126 + },
127 + {
128 + "id": "tvm",
129 + "name": "TVM Group",
130 + "site": "https://www.tvm.ca",
131 + "client_id": 359,
132 + "listing_url": "https://www.tvm.ca/apartments-for-rent",
133 + "regions": ["Peterborough", "Cobourg", "Toronto", "Port Hope"],
134 + "status": "valide",
135 + "properties_on": 47,
136 + "notes": "47 propriétés ON (Peterborough 33, Cobourg 6, Toronto 5)"
137 + },
138 + {
139 + "id": "urbandale",
140 + "name": "Urbandale Corporation",
141 + "site": "https://urbandalecorporation.com",
142 + "client_id": 307,
143 + "listing_url": "https://urbandalecorporation.com/residential-rentals",
144 + "regions": ["Ottawa", "Gloucester", "Riverside South"],
145 + "status": "valide",
146 + "properties_on": 24,
147 + "notes": "24 propriétés ON (Ottawa 23, Gloucester 1) ; urbandale.com = volet construction, le locatif est sur urbandalecorporation.com"
148 + },
149 + {
150 + "id": "sterlingkaramar",
151 + "name": "Sterling Karamar Property Management",
152 + "site": "https://www.sterlingkaramar.com",
153 + "client_id": null,
154 + "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é"
158 + },
159 + {
160 + "id": "panoramic",
161 + "name": "Panoramic Properties",
162 + "site": "https://www.panoramicproperties.ca",
163 + "client_id": null,
164 + "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é"
168 + },
169 + {
170 + "id": "drewlo",
171 + "name": "Drewlo Holdings",
172 + "site": "https://www.drewloholdings.com",
173 + "client_id": null,
174 + "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"
178 + }
179 + ]
180 +}
added data/rentcafe_clients.json +75 −0
@@ -0,0 +1,75 @@
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).",
3 + "clients": [
4 + {
5 + "id": "effort",
6 + "name": "Effort Trust",
7 + "site": "https://www.effortrentals.com",
8 + "search_url": "https://www.effortrentals.com/searchlisting.aspx",
9 + "pattern": "searchlisting",
10 + "regions": "Hamilton, Kitchener-Waterloo, Cambridge, Guelph, Brantford, Woodstock, St. Catharines, Burlington",
11 + "status": "ok",
12 + "note": "153 propriétés ON au 2026-08-26 (toutes les cartes sont ON). Cartes li.property-box-hidden + carte available_prop_map ; plans fp-container embarqués dans la fiche default.aspx (PAS de page /floorplans, 404)."
13 + },
14 + {
15 + "id": "osgoode",
16 + "name": "Osgoode Properties (Ontario)",
17 + "site": "https://www.osgoodeproperties.com",
18 + "search_url": "https://www.osgoodeproperties.com/searchlisting.aspx",
19 + "pattern": "searchlisting",
20 + "regions": "Ottawa, Kingston",
21 + "status": "ok",
22 + "note": "17 cartes ON sur 23 (AB exclues par le filtre province, Gatineau QC couvert par le connecteur dédié osgoode.py). Plans sur <fiche>/floorplans (spans data-selenium-id)."
23 + },
24 + {
25 + "id": "gwlra",
26 + "name": "GWLRA Residential",
27 + "site": "https://www.gwlraresidential.com",
28 + "search_url": "https://www.gwlraresidential.com/searchlisting.aspx",
29 + "pattern": "searchlisting",
30 + "regions": "Toronto, Mississauga, Burlington, Ottawa",
31 + "status": "ok",
32 + "note": "16 cartes ON sur 26 (BC/AB exclues par le filtre province). Plans sur <fiche>/floorplans (gabarit cartes h2.card-title + nu-bed/nu-bathroom/nu-area)."
33 + },
34 + {
35 + "id": "oshanter",
36 + "name": "O'Shanter Development",
37 + "site": "https://www.oshanter.com",
38 + "search_url": "https://www.oshanter.com/searchlisting.aspx",
39 + "pattern": "searchlisting",
40 + "regions": "Toronto, East York, Mississauga",
41 + "status": "ok",
42 + "note": "17 cartes ON. Les fiches pointent vers des microsites RentCafe par immeuble (ex. 2150bromsgrove.com?rcstdid=…) — détail plus coûteux, budget serré."
43 + },
44 + {
45 + "id": "oldoak",
46 + "name": "Old Oak Properties",
47 + "site": "https://oldoak.com",
48 + "search_url": "https://oldoakproperties.securecafe.com/residentservices/apartmentsforrent/userlogin.aspx",
49 + "pattern": "securecafe",
50 + "regions": "London",
51 + "status": "echec",
52 + "note": "Vérifié 2026-08-26 : oldoak.com est un WordPress/JetEngine (pas de searchlisting.aspx, 404) et le portail SecureCafe « apartmentsforrent » exige un compte (userlogin/register seulement, même via Scrapfly). Piste restante : connecteur WP dédié (grille JetEngine /apartment-rentals/, ~10 immeubles/page)."
53 + },
54 + {
55 + "id": "paramount",
56 + "name": "Paramount Properties",
57 + "site": "https://paramountapts.com",
58 + "search_url": "https://paramountapts.securecafe.com/residentservices/apartmentsforrent/userlogin.aspx",
59 + "pattern": "securecafe",
60 + "regions": "Ottawa",
61 + "status": "echec",
62 + "note": "Vérifié 2026-08-26 : paramountapts.com est un WordPress (pages /location/<immeuble>/, pas de searchlisting.aspx) ; le portail SecureCafe est fermé derrière login. Piste restante : connecteur WP dédié (13 immeubles listés dans le menu)."
63 + },
64 + {
65 + "id": "tricar",
66 + "name": "The Tricar Group",
67 + "site": "https://www.tricar.com",
68 + "search_url": "https://tricar.securecafe.com/residentservices/apartmentsforrent/propertyselect.aspx",
69 + "pattern": "securecafe",
70 + "regions": "London, Sarnia, Guelph, Woodstock",
71 + "status": "echec",
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 +}
modified data/sources.json +196 −6
@@ -4101,13 +4101,13 @@
4101 4101 },
4102 4102 {
4103 4103 "id": "skyline",
4104 − "name": "Skyline Living (REIT pancanadien)",
4104 + "name": "Skyline Living",
4105 4105 "url": "https://www.skylineliving.ca",
4106 − "listing_url": "https://www.skylineliving.ca/en/apartments-for-rent",
4107 − "sectors": "—",
4108 − "connector": null,
4109 − "status": "non connectable — REIT pancanadien, aucun immeuble au Québec (API Rentsync vérifiée : 0 propriété QC)",
4110 − "region": "—"
4106 + "listing_url": "https://www.skylineliving.ca/apartments-for-rent",
4107 + "sectors": "Guelph, villes secondaires ON (~38 villes : Est, Niagara, Sud-Ouest, Nord, Centre)",
4108 + "connector": "skyline",
4109 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1 (0 propriété QC, connecteur ON via passerelle Rentsync)",
4110 + "region": "Ontario — multi-régions"
4111 4111 },
4112 4112 {
4113 4113 "id": "sofia",
@@ -4388,6 +4388,196 @@
4388 4388 "connector": "westwalk",
4389 4389 "status": "actif",
4390 4390 "region": "Montréal"
4391 + },
4392 + {
4393 + "id": "lift_greenwin",
4394 + "name": "Greenwin Corp",
4395 + "url": "https://www.greenwin.ca",
4396 + "listing_url": "https://www.greenwin.ca/apartments-for-rent/cities/toronto",
4397 + "sectors": "Toronto/GTA, Kitchener, Hamilton, Brampton, Guelph",
4398 + "connector": "liftsystem",
4399 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4400 + "region": "Ontario — Toronto/GTA"
4401 + },
4402 + {
4403 + "id": "lift_park",
4404 + "name": "Park Property Management",
4405 + "url": "https://www.parkproperty.ca",
4406 + "listing_url": "https://www.parkproperty.ca/apartments-for-rent",
4407 + "sectors": "Toronto/GTA, Stratford, Waterloo, Cambridge, Burlington, London",
4408 + "connector": "liftsystem",
4409 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4410 + "region": "Ontario — Toronto/GTA"
4411 + },
4412 + {
4413 + "id": "lift_williamsmcdaniel",
4414 + "name": "Williams & McDaniel",
4415 + "url": "https://www.williamsandmcdaniel.com",
4416 + "listing_url": "https://www.williamsandmcdaniel.com/apartments",
4417 + "sectors": "Guelph, Sault Ste. Marie, Fergus, Midland, Kitchener, Hamilton, Stratford",
4418 + "connector": "liftsystem",
4419 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4420 + "region": "Ontario — Sud-Ouest"
4421 + },
4422 + {
4423 + "id": "lift_briarlane",
4424 + "name": "Briarlane Rental Property Management",
4425 + "url": "https://www.briarlane.ca",
4426 + "listing_url": "https://www.briarlane.ca/apartments-for-rent",
4427 + "sectors": "London, Toronto/GTA, Markham/York, Brampton, Mississauga",
4428 + "connector": "liftsystem",
4429 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4430 + "region": "Ontario — Toronto/GTA"
4431 + },
4432 + {
4433 + "id": "lift_dms",
4434 + "name": "DMS Property Management",
4435 + "url": "https://www.dmsproperty.com",
4436 + "listing_url": "https://www.dmsproperty.com/apartments-for-rent",
4437 + "sectors": "Toronto/GTA, Hamilton, Kingston, Barrie, Richmond Hill, Welland",
4438 + "connector": "liftsystem",
4439 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4440 + "region": "Ontario — Toronto/GTA"
4441 + },
4442 + {
4443 + "id": "lift_signet",
4444 + "name": "Signet Group",
4445 + "url": "https://www.signetgroup.ca",
4446 + "listing_url": "https://www.signetgroup.ca/apartments",
4447 + "sectors": "Toronto, Mississauga, Kitchener, Cambridge, Stratford",
4448 + "connector": "liftsystem",
4449 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4450 + "region": "Ontario — Toronto/GTA"
4451 + },
4452 + {
4453 + "id": "lift_shelter",
4454 + "name": "Shelter Canadian Properties",
4455 + "url": "https://www.scpl.com",
4456 + "listing_url": "https://www.scpl.com/residential-rental",
4457 + "sectors": "Toronto, Ottawa, Belleville, Thunder Bay, Orillia",
4458 + "connector": "liftsystem",
4459 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4460 + "region": "Ontario — multi-régions"
4461 + },
4462 + {
4463 + "id": "lift_york",
4464 + "name": "York Property Management",
4465 + "url": "https://www.yorkproperty.ca",
4466 + "listing_url": "https://www.yorkproperty.ca/apartments-for-rent/london",
4467 + "sectors": "Windsor, London, Sarnia, Brantford, Woodstock",
4468 + "connector": "liftsystem",
4469 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4470 + "region": "Ontario — Sud-Ouest"
4471 + },
4472 + {
4473 + "id": "lift_terracorp",
4474 + "name": "Terracorp Management (Auburn Developments)",
4475 + "url": "https://terracorp.ca",
4476 + "listing_url": "https://terracorp.ca/apartments-for-rent",
4477 + "sectors": "London, Windsor, Kitchener, Owen Sound",
4478 + "connector": "liftsystem",
4479 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4480 + "region": "Ontario — Sud-Ouest"
4481 + },
4482 + {
4483 + "id": "lift_rentsarnia",
4484 + "name": "Mutual Property Management (RentSarnia)",
4485 + "url": "https://rentsarnia.com",
4486 + "listing_url": "https://rentsarnia.com/",
4487 + "sectors": "Sarnia, Corunna",
4488 + "connector": "liftsystem",
4489 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4490 + "region": "Ontario — Sud-Ouest"
4491 + },
4492 + {
4493 + "id": "lift_firstnorth",
4494 + "name": "First North Property Management",
4495 + "url": "https://firstnorthpm.com",
4496 + "listing_url": "https://firstnorthpm.com/apartments-for-rent",
4497 + "sectors": "Sudbury, North Bay, Timmins",
4498 + "connector": "liftsystem",
4499 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4500 + "region": "Ontario — Nord"
4501 + },
4502 + {
4503 + "id": "lift_tvm",
4504 + "name": "TVM Group",
4505 + "url": "https://www.tvm.ca",
4506 + "listing_url": "https://www.tvm.ca/apartments-for-rent",
4507 + "sectors": "Peterborough, Cobourg, Toronto, Port Hope",
4508 + "connector": "liftsystem",
4509 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4510 + "region": "Ontario — Centre-Est"
4511 + },
4512 + {
4513 + "id": "lift_urbandale",
4514 + "name": "Urbandale Corporation",
4515 + "url": "https://urbandalecorporation.com",
4516 + "listing_url": "https://urbandalecorporation.com/residential-rentals",
4517 + "sectors": "Ottawa, Gloucester, Riverside South",
4518 + "connector": "liftsystem",
4519 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4520 + "region": "Ontario — Ottawa"
4521 + },
4522 + {
4523 + "id": "homestead",
4524 + "name": "Homestead Land Holdings",
4525 + "url": "https://www.homestead.ca",
4526 + "listing_url": "https://www.homestead.ca/apartments",
4527 + "sectors": "Kingston, Toronto/GTA, Ottawa, Hamilton, London, Kitchener-Waterloo, Guelph, Sarnia",
4528 + "connector": "homestead",
4529 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4530 + "region": "Ontario — multi-régions"
4531 + },
4532 + {
4533 + "id": "killam",
4534 + "name": "Killam Apartment REIT (Ontario)",
4535 + "url": "https://www.killamreit.com",
4536 + "listing_url": "https://www.killamreit.com/apartments",
4537 + "sectors": "Toronto, Ottawa (Kanata), London, Kitchener-Waterloo, Cambridge, Mississauga",
4538 + "connector": "killam",
4539 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4540 + "region": "Ontario — multi-régions"
4541 + },
4542 + {
4543 + "id": "rc_effort",
4544 + "name": "Effort Trust",
4545 + "url": "https://www.effortrentals.com",
4546 + "listing_url": "https://www.effortrentals.com/searchlisting.aspx",
4547 + "sectors": "Hamilton, Kitchener-Waterloo, Cambridge, Guelph, Brantford, Woodstock, St. Catharines, Burlington",
4548 + "connector": "rentcafe",
4549 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4550 + "region": "Ontario — Hamilton/Niagara"
4551 + },
4552 + {
4553 + "id": "rc_osgoode",
4554 + "name": "Osgoode Properties (Ontario)",
4555 + "url": "https://www.osgoodeproperties.com",
4556 + "listing_url": "https://www.osgoodeproperties.com/searchlisting.aspx",
4557 + "sectors": "Ottawa, Kingston",
4558 + "connector": "rentcafe",
4559 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4560 + "region": "Ontario — Ottawa"
4561 + },
4562 + {
4563 + "id": "rc_gwlra",
4564 + "name": "GWLRA Residential (Ontario)",
4565 + "url": "https://www.gwlraresidential.com",
4566 + "listing_url": "https://www.gwlraresidential.com/searchlisting.aspx",
4567 + "sectors": "Toronto, Mississauga, Burlington, Ottawa",
4568 + "connector": "rentcafe",
4569 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4570 + "region": "Ontario — Toronto/GTA"
4571 + },
4572 + {
4573 + "id": "rc_oshanter",
4574 + "name": "O'Shanter Development",
4575 + "url": "https://www.oshanter.com",
4576 + "listing_url": "https://www.oshanter.com/searchlisting.aspx",
4577 + "sectors": "Toronto, East York, Mississauga",
4578 + "connector": "rentcafe",
4579 + "status": "inactif — expansion Ontario, gaté LOUKA_ONTARIO=1",
4580 + "region": "Ontario — Toronto/GTA"
4391 4581 }
4392 4582 ]
4393 4583 }
modified louka/connectors/akelius.py +37 −5
@@ -7,6 +7,12 @@
7 7 # des unités canadiennes (adresse, loyer, pi², chambres, photos). On filtre
8 8 # sur le Grand Montréal (Montréal, Westmount, Mont-Royal, Saint-Lambert,
9 9 # Greenfield Park) — Toronto/Ottawa/Gatineau exclus.
10 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,
11 +# les unités ON de Toronto (+ boroughs North York/Etobicoke…) et d'Ottawa
12 +# sont aussi conservées (province="ON", adresse suffixée « ON ») — le
13 +# TransferState de la page montreal contient déjà TOUTES les unités
14 +# canadiennes, aucune requête de plus. Sans la variable, comportement
15 +# strictement identique (Grand Montréal seulement).
10 16 # Chaque unité a aussi un JSON détail (/lettings/marketing/v2/CA/<id>.json)
11 17 # avec les keyfacts complets : inclusions (chauffage, eau chaude,
12 18 # électricité, internet), électroménagers, animaux, meublé, ascenseur,
@@ -17,6 +23,7 @@ from __future__ import annotations
17 23
18 24 import hashlib
19 25 import json
26 +import os
20 27 import re
21 28
22 29 from ..schema import Listing, strip_accents
@@ -38,6 +45,21 @@ _GM_CITIES = {
38 45 "greenfield park": ("Longueuil", "Greenfield Park"),
39 46 }
40 47
48 +# Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, GM seulement
49 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
50 +
51 +# Villes admissibles en Ontario (Toronto + boroughs fusionnés, Ottawa)
52 +# -> (ville normalisée, secteur imposé ou None)
53 +_ON_CITIES = {
54 + "toronto": ("Toronto", None), # secteur = borough du flux
55 + "north york": ("Toronto", "North York"),
56 + "east york": ("Toronto", "East York"),
57 + "etobicoke": ("Toronto", "Etobicoke"),
58 + "scarborough": ("Toronto", "Scarborough"),
59 + "york": ("Toronto", "York"),
60 + "ottawa": ("Ottawa", None),
61 +}
62 +
41 63 _BED_TYPES = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}
42 64
43 65 # keyfacts booléens du JSON détail -> libellé de commodité (si vrai)
@@ -232,12 +254,19 @@ class AkeliusConnector(BaseConnector):
232 254 def _unit_listing(self, u: dict) -> Listing | None:
233 255 addr = u.get("address") or {}
234 256 kf = u.get("keyfacts") or {}
235 − if (addr.get("province") or "").upper() != "QC":
236 − return None
257 + prov = (addr.get("province") or "").upper()
237 258 city_key = strip_accents((addr.get("city") or "").strip().lower())
238 − if city_key not in _GM_CITIES:
259 + if prov == "QC":
260 + if city_key not in _GM_CITIES:
261 + return None
262 + city, forced_sector = _GM_CITIES[city_key]
263 + elif prov == "ON" and _ONTARIO:
264 + # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO)
265 + if city_key not in _ON_CITIES:
266 + return None
267 + city, forced_sector = _ON_CITIES[city_key]
268 + else:
239 269 return None
240 − city, forced_sector = _GM_CITIES[city_key]
241 270 sector = forced_sector or (addr.get("borough") or "").strip()
242 271
243 272 uid = str(u.get("id") or "").strip()
@@ -286,15 +315,18 @@ class AkeliusConnector(BaseConnector):
286 315 images = [i for i in (u.get("imageUrls") or [])
287 316 if isinstance(i, str) and i.startswith("http")][:30]
288 317
318 + # adresse complète : suffixe « ON » pour l'Ontario (QC inchangé)
319 + last = f"ON {postal}".strip() if prov == "ON" else postal
289 320 title = f"{street} — unité {uid.split('-')[-1]}" if street else uid
290 321 return Listing(
291 322 source=self.source_id,
292 323 external_id=uid,
293 324 url=f"{BASE}/en/search/canada/detail/{uid}",
294 325 title=title,
295 − address=", ".join(x for x in [street, city, postal] if x),
326 + address=", ".join(x for x in [street, city, last] if x),
296 327 sector=sector,
297 328 city=city,
329 + province=prov,
298 330 unit_type=unit_type,
299 331 price=price,
300 332 price_label=f"{int(rent)} $/mois" if price else "",
modified louka/connectors/boardwalk.py +64 −2
@@ -10,10 +10,17 @@
10 10 # l'en-tête (p.address-and-phone). Les pages de suites ne servent plus qu'aux
11 11 # photos spécifiques, via le cache self.detail().
12 12 # REIT pancanadien : seules les propriétés du Grand Montréal sont couvertes.
13 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO=1 : mêmes pages de villes
14 +# rendues serveur en fr-ca pour London et Kitchener/Waterloo (libellés
15 +# français identiques : « À partir de … $ », « Superficie (pi ca) »,
16 +# « c. à c. », « sdb »). L'URL des propriétés ON porte le quartier :
17 +# /fr-ca/appartements-a-louer-a-<ville>/<quartier>/<propriété>. Sans la
18 +# variable d'environnement, comportement strictement identique (prod QC).
13 19 # -----------------------------------------------------------------------------
14 20 from __future__ import annotations
15 21
16 22 import hashlib
23 +import os
17 24 import re
18 25
19 26 from bs4 import BeautifulSoup
@@ -38,6 +45,28 @@ CITY_PAGES = {
38 45 PROP_RE = re.compile(
39 46 r'href="(/fr-ca/appartements-a-louer-a-montreal-[a-z0-9\-]+/'
40 47 r'[a-z0-9\-]+/[a-z0-9\-]+)"')
48 +
49 +# --- Expansion Ontario (gaté LOUKA_ONTARIO=1 ; prod QC intacte sans) ---------
50 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
51 +
52 +# Pages de villes de l'Ontario (fr-ca) -> ville réelle ; le quartier vient du
53 +# 2e segment de l'URL de la propriété (ex. /…-a-london/carling/<propriété>)
54 +CITY_PAGES_ON = {
55 + f"{BASE}/fr-ca/appartements-a-louer-a-london": "London",
56 + f"{BASE}/fr-ca/appartements-a-louer-a-kitchener": "Kitchener",
57 +}
58 +
59 +_POSTAL_RE = re.compile(r",\s*([A-Z]\d[A-Z]\s?\d[A-Z]\d)\s*$")
60 +
61 +
62 +def _addr_on(address: str) -> str:
63 + """Suffixe « ON » à l'adresse (avant le code postal s'il termine)."""
64 + if not address or re.search(r"\bON\b", address):
65 + return address
66 + m = _POSTAL_RE.search(address)
67 + if m:
68 + return f"{address[: m.start()].rstrip(', ')}, ON, {m.group(1)}"
69 + return f"{address}, ON"
41 70 PRICE_RE = re.compile(r'À partir de\s*([\d\s\u00a0,.]+\$)')
42 71 # superficie : « 900 », « 648 - 760 », « 1 408 » (séparateur de milliers) —
43 72 # le nombre de chambres qui suit (« 3 c. à c. ») ne doit pas être aspiré
@@ -67,7 +96,8 @@ def _suite_type(name: str) -> str:
67 96 class BoardwalkConnector(BaseConnector):
68 97 source_id = "boardwalk"
69 98 request_delay = 0.6
70 − max_properties = 25 # garde-fou
99 + max_properties = 25 # garde-fou (QC)
100 + max_properties_on = 25 # garde-fou distinct pour l'Ontario (gaté)
71 101 max_images = 25
72 102
73 103 def fetch(self) -> list[Listing]:
@@ -92,6 +122,34 @@ class BoardwalkConnector(BaseConnector):
92 122 except Exception:
93 123 continue
94 124
125 + # Expansion Ontario (gaté LOUKA_ONTARIO=1) : mêmes pages fr-ca, budget
126 + # de propriétés distinct pour ne rien enlever à la couverture QC
127 + if _ONTARIO:
128 + count_on = 0
129 + for city_url, city in CITY_PAGES_ON.items():
130 + try:
131 + html = self.get(city_url).text
132 + except Exception:
133 + continue
134 + prop_re = re.compile(
135 + r'href="(' + re.escape(city_url[len(BASE):]) +
136 + r'/[a-z0-9\-]+/[a-z0-9\-]+)"')
137 + for path in dict.fromkeys(prop_re.findall(html)):
138 + if path in seen:
139 + continue
140 + seen.add(path)
141 + if count_on >= self.max_properties_on:
142 + break
143 + count_on += 1
144 + # quartier dérivé de l'URL (ex. white-oaks -> White Oaks)
145 + sector = (path.rstrip("/").split("/")[-2]
146 + .replace("-", " ").title())
147 + try:
148 + listings.extend(self._property_listings(
149 + path, city, sector, province="ON"))
150 + except Exception:
151 + continue
152 +
95 153 # Unicité des external_id (deux cartes peuvent pointer vers la même
96 154 # page de type de suite avec des prix différents)
97 155 used: dict[str, int] = {}
@@ -103,7 +161,8 @@ class BoardwalkConnector(BaseConnector):
103 161 return listings
104 162
105 163 def _property_listings(self, path: str, city: str,
106 − default_sector: str) -> list[Listing]:
164 + default_sector: str,
165 + province: str = "QC") -> list[Listing]:
107 166 url = BASE + path
108 167 prop_slug = path.rstrip("/").split("/")[-1]
109 168 html = self.get(url).text
@@ -130,6 +189,8 @@ class BoardwalkConnector(BaseConnector):
130 189 r'photo de la propri[ée]t[ée] pour le\s*([^"<>]{5,90})', html)
131 190 if am:
132 191 address = am.group(1).strip()
192 + if province == "ON":
193 + address = _addr_on(address)
133 194
134 195 # Galerie photo (swiper)
135 196 images: list[str] = []
@@ -248,6 +309,7 @@ class BoardwalkConnector(BaseConnector):
248 309 address=address,
249 310 sector=default_sector,
250 311 city=city,
312 + province=province,
251 313 unit_type=_suite_type(suite_name),
252 314 price=price,
253 315 price_label=price_label,
modified louka/connectors/capreit.py +100 −15
@@ -8,10 +8,22 @@
8 8 # serveur) fournissent les types d'unités, prix, disponibilités, commodités
9 9 # et la galerie photo. Une annonce par type d'unité disponible.
10 10 # Exclus : hors-province (province != QC) et villes hors des deux régions.
11 +#
12 +# Expansion Ontario (2026-08) — GATÉE par LOUKA_ONTARIO=1 : le MÊME flux
13 +# admin-ajax contient tout le Canada (~278 propriétés, dont ~108 en ON) ;
14 +# le filtre provincial est purement côté client. Quand la variable
15 +# d'environnement est posée, le fetch inclut AUSSI les villes ontariennes
16 +# (Toronto + arrondissements Scarborough/North York/Etobicoke/York, GTA
17 +# Peel/Halton/Durham/York Region, Ottawa dont Orléans, London,
18 +# Kitchener-Waterloo, Hamilton) avec province="ON" et adresse suffixée
19 +# « ON ». Les fiches détail existent en français (mêmes sélecteurs, URLs
20 +# /fr/) : aucune autre mécanique n'est nécessaire. Sans la variable, le
21 +# comportement est STRICTEMENT identique à la prod QC actuelle.
11 22 # -----------------------------------------------------------------------------
12 23 from __future__ import annotations
13 24
14 25 import hashlib
26 +import os
15 27 import re
16 28
17 29 from bs4 import BeautifulSoup
@@ -24,6 +36,9 @@ from .base import BaseConnector
24 36 BASE = "https://www.capreit.ca"
25 37 FEED_URL = f"{BASE}/wp-admin/admin-ajax.php?action=property_json&language=fr"
26 38
39 +# Expansion Ontario : opt-in explicite (prod QC inchangée sans la variable)
40 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
41 +
27 42 # Villes du flux correspondant à la région Québec/Lévis
28 43 _QC_CITIES = {
29 44 "ville de quebec", "quebec", "beauport", "levis", "sainte-foy",
@@ -62,6 +77,49 @@ _GM_CITIES = {
62 77 "sainte-therese": "Sainte-Thérèse",
63 78 "blainville": "Blainville",
64 79 }
80 +
81 +# Villes ontariennes ciblées (LOUKA_ONTARIO=1) : clé normalisée du flux ->
82 +# nom d'affichage. Le flux CAPREIT distingue les anciens arrondissements de
83 +# Toronto (Scarborough, North York…) : on les garde comme villes, cohérent
84 +# avec l'usage local. Sarnia (hors marchés cibles) volontairement exclue.
85 +_ON_CITIES = {
86 + # Toronto et arrondissements (ancienne métropole)
87 + "toronto": "Toronto",
88 + "scarborough": "Scarborough",
89 + "north york": "North York",
90 + "etobicoke": "Etobicoke",
91 + "york": "York",
92 + "east york": "East York",
93 + # GTA — York Region
94 + "thornhill": "Thornhill",
95 + "vaughan": "Vaughan",
96 + "markham": "Markham",
97 + "richmond hill": "Richmond Hill",
98 + # GTA — Peel / Halton
99 + "mississauga": "Mississauga",
100 + "brampton": "Brampton",
101 + "oakville": "Oakville",
102 + "burlington": "Burlington",
103 + "milton": "Milton",
104 + # GTA — Durham
105 + "pickering": "Pickering",
106 + "ajax": "Ajax",
107 + "whitby": "Whitby",
108 + "oshawa": "Oshawa",
109 + # Ottawa (Orléans/Nepean/Kanata/Gloucester = secteurs d'Ottawa)
110 + "ottawa": "Ottawa",
111 + "orleans": "Ottawa",
112 + "nepean": "Ottawa",
113 + "kanata": "Ottawa",
114 + "gloucester": "Ottawa",
115 + # London
116 + "london": "London",
117 + # Hamilton / Kitchener-Waterloo
118 + "hamilton": "Hamilton",
119 + "kitchener": "Kitchener",
120 + "waterloo": "Waterloo",
121 + "cambridge": "Cambridge",
122 +}
65 123 _IMG_RE = re.compile(
66 124 r'https://www\.capreit\.ca/wp-content/uploads/[^"\'\s\\]+'
67 125 r'\.(?:jpg|jpeg|png|webp)', re.I)
@@ -73,6 +131,7 @@ class CapreitConnector(BaseConnector):
73 131 source_id = "capreit"
74 132 request_delay = 0.6
75 133 max_properties = 60 # garde-fou (région de Québec + Grand Montréal)
134 + max_properties_on = 110 # garde-fou Ontario (budget séparé, LOUKA_ONTARIO=1)
76 135 max_images = 25
77 136
78 137 @staticmethod
@@ -83,34 +142,48 @@ class CapreitConnector(BaseConnector):
83 142 props = self.get(FEED_URL).json()
84 143
85 144 listings: list[Listing] = []
86 − count = 0
145 + count = 0 # budget propriétés QC (inchangé)
146 + count_on = 0 # budget propriétés ON (séparé : ne gruge pas le QC)
87 147 for p in props:
88 148 try:
89 − if (p.get("province") or "").strip().upper() != "QC":
90 − continue
149 + prov = (p.get("province") or "").strip().upper()
91 150 ck = self._city_key(p.get("city", ""))
92 − if ck not in _QC_CITIES and ck not in _GM_CITIES:
93 − continue
94 − if not p.get("has_vacancies"):
95 − continue
96 − if count >= self.max_properties:
97 − break
98 − count += 1
99 − listings.extend(self._property_listings(p))
151 + if prov == "QC":
152 + if ck not in _QC_CITIES and ck not in _GM_CITIES:
153 + continue
154 + if not p.get("has_vacancies"):
155 + continue
156 + if count >= self.max_properties:
157 + if not _ONTARIO:
158 + break
159 + continue # mode ON : continuer à balayer l'Ontario
160 + count += 1
161 + listings.extend(self._property_listings(p))
162 + elif _ONTARIO and prov == "ON":
163 + if ck not in _ON_CITIES:
164 + continue
165 + if not p.get("has_vacancies"):
166 + continue
167 + if count_on >= self.max_properties_on:
168 + continue
169 + count_on += 1
170 + listings.extend(self._property_listings(p, province="ON"))
100 171 except Exception:
101 172 continue
102 173 return listings
103 174
104 − def _property_listings(self, p: dict) -> list[Listing]:
175 + def _property_listings(self, p: dict,
176 + province: str = "QC") -> list[Listing]:
105 177 pid = str(p.get("id"))
106 178 url = p.get("url") or ""
107 179 title = (p.get("title") or "").strip()
108 180 address = (p.get("address") or "").strip()
109 181 feed_city = (p.get("city") or "").strip()
110 − # adresse complète : rue + ville + code postal (tous fournis au flux)
182 + # adresse complète : rue + ville + province + code postal (du flux) —
183 + # suffixe « QC » comme avant, « ON » pour les propriétés ontariennes
111 184 postal = (p.get("postal_code") or "").strip()
112 185 if address and feed_city:
113 − address = f"{address}, {feed_city}" + (f", QC {postal}"
186 + address = f"{address}, {feed_city}" + (f", {province} {postal}"
114 187 if postal else "")
115 188 # coordonnées GPS du flux
116 189 try:
@@ -121,7 +194,17 @@ class CapreitConnector(BaseConnector):
121 194 incentive = (p.get("incentive") or "").strip()
122 195 # secteur : ville précise du flux (ex. Beauport) sinon intersection
123 196 city_key = self._city_key(feed_city)
124 − if city_key in _GM_CITIES:
197 + if province == "ON":
198 + # Ontario : la ville du flux est la vraie ville (nom d'affichage
199 + # de la whitelist) ; secteur = intersection la plus proche, sauf
200 + # quand la whitelist regroupe (ex. Orléans -> Ottawa) où l'ancien
201 + # nom de ville devient le secteur.
202 + city = _ON_CITIES.get(city_key, feed_city)
203 + if self._city_key(city) == city_key:
204 + sector = (p.get("nearest_intersection") or "").strip()
205 + else:
206 + sector = feed_city
207 + elif city_key in _GM_CITIES:
125 208 # Grand Montréal : la ville du flux est la vraie ville
126 209 sector = (p.get("nearest_intersection") or "").strip()
127 210 city = _GM_CITIES[city_key]
@@ -170,6 +253,7 @@ class CapreitConnector(BaseConnector):
170 253 address=address,
171 254 sector=sector,
172 255 city=city,
256 + province=province,
173 257 unit_type=ut,
174 258 price=parse_price(r["price"]),
175 259 price_label=r["price"],
@@ -197,6 +281,7 @@ class CapreitConnector(BaseConnector):
197 281 address=address,
198 282 sector=sector,
199 283 city=city,
284 + province=province,
200 285 unit_type=normalize_unit_type(
201 286 (p.get("bedroom_range") or "").split("-")[0]),
202 287 price=float(min_rent) if min_rent else None,
modified louka/connectors/centurion.py +70 −16
@@ -15,10 +15,18 @@
15 15 # à l'expansion provinciale 2026-08) + garde-fou province/ville.
16 16 # La fiche propriété (rendu serveur) fournit la galerie photo, via le cache
17 17 # BD self.detail() (revisitée seulement quand le flux change).
18 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,
19 +# le flux est interrogé SANS city_ids (tout le parc Centurion, ~104
20 +# propriétés dont ~52 en Ontario : Toronto/GTA, Ottawa, Kitchener-Waterloo,
21 +# Barrie, Huntsville…) ; les entrées province_code=="ON" sont acceptées
22 +# (plafond séparé max_properties_on), le garde-fou QC (_QC_CITIES +
23 +# province_code=="QC") reste appliqué tel quel aux entrées québécoises.
24 +# Sans la variable, comportement strictement identique (city_ids QC).
18 25 # -----------------------------------------------------------------------------
19 26 from __future__ import annotations
20 27
21 28 import hashlib
29 +import os
22 30 import re
23 31
24 32 from bs4 import BeautifulSoup
@@ -26,6 +34,9 @@ from bs4 import BeautifulSoup
26 34 from ..schema import Listing, infer_city, normalize_unit_type, strip_accents
27 35 from .base import BaseConnector
28 36
37 +# Expansion Ontario 2026-08 — gate d'environnement (défaut : QC seulement)
38 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
39 +
29 40 BASE = "https://www.cpliving.com"
30 41 SEARCH_PAGE = f"{BASE}/apartments-for-rent/quebec-city"
31 42 LIFT_API = "https://api.theliftsystem.com/v2/search"
@@ -56,7 +67,7 @@ _SKIP_IMG = re.compile(r"logo|icon|favicon|badge|/thumb", re.I)
56 67
57 68 def _city_key(city: str) -> str:
58 69 key = strip_accents((city or "").strip().lower())
59 − return re.sub(r"\s+qc$", "", key) # le flux accole parfois la province
70 + return re.sub(r"\s+(?:qc|on)$", "", key) # le flux accole parfois la province
60 71
61 72
62 73 class CenturionConnector(BaseConnector):
@@ -64,6 +75,9 @@ class CenturionConnector(BaseConnector):
64 75 request_delay = 0.7
65 76 max_properties = 30 # garde-fou (1 propriété à Québec aujourd'hui)
66 77 max_images = 20
78 + # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO) : plafond séparé pour le
79 + # parc ontarien (~52 propriétés au flux) — le budget QC reste intact
80 + max_properties_on = 100
67 81
68 82 # -- paramètres du flux (page + main.js, avec replis) ----------------------
69 83 def _feed_params(self) -> tuple[str, str, str]:
@@ -89,7 +103,7 @@ class CenturionConnector(BaseConnector):
89 103
90 104 def fetch(self) -> list[Listing]:
91 105 client_id, city_id, token = self._feed_params()
92 − props = self.get(LIFT_API, params={
106 + params = {
93 107 "client_id": client_id,
94 108 "auth_token": token,
95 109 "city_ids": f"{city_id},{GATINEAU_CITY_ID}",
@@ -98,24 +112,57 @@ class CenturionConnector(BaseConnector):
98 112 "show_amenities": "true",
99 113 "show_promotions": "true",
100 114 "limit": "1000",
101 − }, headers={"Accept": "application/json",
102 − "Referer": BASE + "/"}).json()
115 + }
116 + if _ONTARIO:
117 + # Expansion Ontario 2026-08 : sans city_ids, le flux renvoie tout
118 + # le parc Centurion (~104 propriétés) — le garde-fou QC ci-dessous
119 + # reste appliqué tel quel, les entrées ON sont acceptées en plus.
120 + del params["city_ids"]
121 + props = self.get(LIFT_API, params=params,
122 + headers={"Accept": "application/json",
123 + "Referer": BASE + "/"}).json()
103 124
104 125 listings: list[Listing] = []
105 − for p in props[: self.max_properties]:
126 + if not _ONTARIO:
127 + # comportement historique, strictement inchangé
128 + for p in props[: self.max_properties]:
129 + try:
130 + addr = p.get("address") or {}
131 + if (addr.get("province_code") or "").upper() != "QC":
132 + continue # REIT national : rester dans la province
133 + if _city_key(addr.get("city", "")) not in _QC_CITIES:
134 + continue # garde-fou région Québec/Lévis
135 + listings.append(self._listing(p))
136 + except Exception:
137 + continue
138 + return listings
139 +
140 + # LOUKA_ONTARIO=1 : QC (mêmes garde-fous/plafond) + Ontario (plafond
141 + # séparé) — le flux mêle les provinces, on compte donc par province
142 + qc_n = on_n = 0
143 + for p in props:
106 144 try:
107 145 addr = p.get("address") or {}
108 − if (addr.get("province_code") or "").upper() != "QC":
109 − continue # REIT national : rester dans la province
110 − if _city_key(addr.get("city", "")) not in _QC_CITIES:
111 − continue # garde-fou région Québec/Lévis
112 − listings.append(self._listing(p))
146 + prov = (addr.get("province_code") or "").upper()
147 + if prov == "QC":
148 + if _city_key(addr.get("city", "")) not in _QC_CITIES:
149 + continue # garde-fou région Québec/Lévis
150 + if qc_n >= self.max_properties:
151 + continue
152 + qc_n += 1
153 + listings.append(self._listing(p))
154 + elif prov == "ON":
155 + if on_n >= self.max_properties_on:
156 + continue
157 + on_n += 1
158 + listings.append(self._listing(p, province="ON"))
159 + # autres provinces (BC/AB/NS/MB…) : hors périmètre
113 160 except Exception:
114 161 continue
115 162 return listings
116 163
117 164 # -- une annonce par propriété ---------------------------------------------
118 − def _listing(self, p: dict) -> Listing:
165 + def _listing(self, p: dict, province: str = "QC") -> Listing:
119 166 pid = str(p.get("id"))
120 167 addr = p.get("address") or {}
121 168 url = p.get("permalink") or SEARCH_PAGE
@@ -123,16 +170,20 @@ class CenturionConnector(BaseConnector):
123 170
124 171 # adresse complète : rue + ville + code postal (tous fournis au flux)
125 172 ck = _city_key(addr.get("city", ""))
126 − if ck in ("quebec", "ville de quebec", "quebec city"):
173 + if province == "QC" and ck in ("quebec", "ville de quebec",
174 + "quebec city"):
127 175 city = "Québec"
128 176 else: # retirer le suffixe province du flux (« Gatineau QC »)
129 − city = re.sub(r"\s+QC$", "", (addr.get("city") or "").strip(),
130 − flags=re.I)
177 + city = re.sub(rf"\s+{province}$", "",
178 + (addr.get("city") or "").strip(), flags=re.I)
131 179 street = (addr.get("address") or "").strip()
132 180 postal = (addr.get("postal_code") or "").strip()
133 181 full_addr = ", ".join(x for x in (street, city) if x)
134 182 if postal:
135 − full_addr += f", QC {postal}"
183 + # adresse suffixée par la province (« ON » pour l'Ontario)
184 + full_addr += f", {province} {postal}"
185 + elif province == "ON" and full_addr:
186 + full_addr += ", ON"
136 187 sector = (addr.get("neighbourhood") or "").strip()
137 188
138 189 # coordonnées GPS structurées du flux
@@ -228,7 +279,10 @@ class CenturionConnector(BaseConnector):
228 279 title=name,
229 280 address=full_addr,
230 281 sector=sector,
231 − city=infer_city(sector, default=city or "Québec"),
282 + # infer_city (secteurs Lévis) n'a de sens qu'au Québec
283 + city=(infer_city(sector, default=city or "Québec")
284 + if province == "QC" else (city or "Toronto")),
285 + province=province,
232 286 unit_type=unit_type,
233 287 price=price,
234 288 price_label=price_label,
modified louka/connectors/cogir.py +226 −152
@@ -9,18 +9,30 @@
9 9 # une fiche avec un tableau « Modèles disponibles » (type + prix à partir de)
10 10 # et une galerie photos (DATA/PHOTO). Une annonce par modèle d'unité, sinon
11 11 # par immeuble.
12 −# Exclus : Ontario/Halifax/etc., résidences pour aînés/retraités/étudiantes.
12 +# Exclus : Halifax/etc., résidences pour aînés/retraités/étudiantes.
13 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,
14 +# les immeubles de la ville de Toronto (slugs toronto/scarborough/
15 +# north-york/east-york/etobicoke) sont aussi ingérés (province="ON",
16 +# budget d'immeubles séparé — le parc QC n'est jamais rogné). Les fiches
17 +# ON qui redirigent vers un microsite externe (608church.com, etc.) sont
18 +# sautées : seules celles servies par cogir.net (même gabarit français,
19 +# tableau « Modèles disponibles ») sont parsées. Sans la variable,
20 +# comportement strictement identique (QC seulement).
13 21 # -----------------------------------------------------------------------------
14 22 from __future__ import annotations
15 23
24 +import os
16 25 import re
17 −from urllib.parse import unquote
26 +from urllib.parse import unquote, urlparse
18 27
19 28 from bs4 import BeautifulSoup
20 29
21 30 from ..schema import Listing, infer_city, normalize_unit_type, parse_price
22 31 from .base import BaseConnector
23 32
33 +# Expansion Ontario 2026-08 — gate d'environnement (défaut : QC seulement)
34 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
35 +
24 36
25 37 def _dedup_address(raw: str) -> str:
26 38 """Retire les segments dupliqués des adresses Cogir
@@ -96,13 +108,29 @@ _GM_SLUGS = {
96 108 "hull": ("Hull", "Gatineau"),
97 109 "aylmer": ("Aylmer", "Gatineau"),
98 110 }
111 +# Slugs de la ville de Toronto -> (secteur, ville) — expansion Ontario
112 +# 2026-08, consommés seulement sous LOUKA_ONTARIO=1.
113 +_ON_SLUGS = {
114 + "toronto": ("", "Toronto"),
115 + "scarborough": ("Scarborough", "Toronto"),
116 + "north-york": ("North York", "Toronto"),
117 + "east-york": ("East York", "Toronto"),
118 + "etobicoke": ("Etobicoke", "Toronto"),
119 +}
99 120 # Slugs génériques : remplacés par un slug plus précis quand disponible
100 −_GENERIC_SLUGS = {"quebec", "montreal"}
121 +# (« toronto » : certains immeubles apparaissent aussi sous leur borough —
122 +# sans effet hors gate puisqu'aucun slug ON n'est alors collecté)
123 +_GENERIC_SLUGS = {"quebec", "montreal", "toronto"}
101 124 _BUILDING_RE = re.compile(
102 125 r'href="(immeuble-residentiel-([a-z0-9\-]+)/(\d+)-[^"]+\.html)"')
103 126 _EXCLUDE_RE = re.compile(
104 127 r"résidence[s]? pour (aîné|retrait)|résidence[s]? étudiante|"
105 128 r"stationnement|commercial|rangement|entreposage", re.I)
129 +# Variante anglaise (fiches Ontario seulement — jamais appliquée au QC)
130 +_EXCLUDE_EN_RE = re.compile(
131 + r"retirement (?:residence|home|living|community)|"
132 + r"senior[s']*\s+(?:residence|living|housing)|"
133 + r"student (?:residence|housing)|parking|storage", re.I)
106 134
107 135
108 136 class CogirConnector(BaseConnector):
@@ -110,6 +138,9 @@ class CogirConnector(BaseConnector):
110 138 request_delay = 0.6
111 139 max_buildings = 150 # garde-fou (région de Québec + Grand Montréal)
112 140 max_images = 25
141 + # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO) : budget d'immeubles
142 + # séparé — le parc QC garde tout son budget max_buildings
143 + max_buildings_on = 40
113 144
114 145 def fetch(self) -> list[Listing]:
115 146 html = self.get(LIST_URL).text
@@ -119,184 +150,227 @@ class CogirConnector(BaseConnector):
119 150 buildings: dict[str, dict] = {}
120 151 for m in _BUILDING_RE.finditer(html):
121 152 path, slug, bid = m.group(1), m.group(2), m.group(3)
122 − if slug not in _QC_SLUGS and slug not in _GM_SLUGS:
153 + if slug not in _QC_SLUGS and slug not in _GM_SLUGS \
154 + and not (_ONTARIO and slug in _ON_SLUGS):
123 155 continue
124 156 cur = buildings.get(bid)
125 157 if cur is None or (cur["slug"] in _GENERIC_SLUGS
126 158 and slug not in _GENERIC_SLUGS):
127 159 buildings[bid] = {"path": path, "slug": slug, "id": bid}
128 160
161 + # QC d'abord (ordre et plafond historiques), Ontario ensuite avec son
162 + # propre plafond (jamais vide hors gate : aucun slug ON collecté)
163 + qc_buildings = [b for b in buildings.values()
164 + if b["slug"] not in _ON_SLUGS]
165 + on_buildings = [b for b in buildings.values()
166 + if b["slug"] in _ON_SLUGS]
167 +
129 168 listings: list[Listing] = []
130 − for i, b in enumerate(buildings.values()):
169 + for i, b in enumerate(qc_buildings):
131 170 if i >= self.max_buildings:
132 171 break
172 + self._ingest_building(b, "QC", listings)
173 + for i, b in enumerate(on_buildings):
174 + if i >= self.max_buildings_on:
175 + break
133 176 try:
134 − page = self.get(f"{BASE}/{b['path']}").text
177 + self._ingest_building(b, "ON", listings)
135 178 except Exception:
136 − continue
137 − soup = BeautifulSoup(page, "html.parser")
179 + continue # l'échec ON ne doit jamais faire tomber le QC
180 + return listings
138 181
139 − h1 = soup.select_one("h1")
140 − name = h1.get_text(strip=True) if h1 else f"Immeuble {b['id']}"
141 − addr_el = soup.select_one("p.adresse")
142 − address = ""
143 − if addr_el:
144 − address = addr_el.get_text(" ", strip=True)
145 − address = re.sub(r"Coordonnées complètes.*|Visite virtuelle.*",
146 − "", address).strip(" »")
147 − address = _dedup_address(address)
182 + def _ingest_building(self, b: dict, province: str,
183 + listings: list[Listing]) -> None:
184 + """Parse une fiche immeuble et ajoute ses annonces à `listings`."""
185 + try:
186 + # ON : ne pas suivre les redirections — les fiches redirigées vers
187 + # un microsite externe (608church.com, 107redpath.com…) sont hors
188 + # gabarit cogir.net et souvent 403 (éviterait d'escalader la
189 + # chaîne anti-bot pour rien) : on les saute.
190 + resp = self.get(f"{BASE}/{b['path']}",
191 + allow_redirects=(province != "ON"))
192 + if province == "ON" and (
193 + 300 <= resp.status_code < 400
194 + or "cogir.net" not in (urlparse(resp.url).hostname or "")):
195 + return
196 + page = resp.text
197 + except Exception:
198 + return
199 + soup = BeautifulSoup(page, "html.parser")
148 200
149 − # description (+ éventuelle section « Promotion » en tête)
150 − desc = ""
151 − d_h2 = soup.find("h2", string=re.compile("Description", re.I))
152 − if d_h2 and d_h2.find_parent():
153 − desc = d_h2.find_parent().get_text(" ", strip=True)
154 − desc = re.sub(r"^Description\s*", "", desc)[:600]
155 − p_h2 = soup.find("h2", string=re.compile(r"^\s*Promotion", re.I))
156 − if p_h2 and p_h2.find_parent():
157 − promo = p_h2.find_parent().get_text(" ", strip=True)
158 − promo = re.sub(r"^Promotion\s*", "", promo).strip()[:200]
159 − if promo:
160 − desc = f"Promotion : {promo}. {desc}".strip()[:700]
201 + h1 = soup.select_one("h1")
202 + name = h1.get_text(strip=True) if h1 else f"Immeuble {b['id']}"
203 + addr_el = soup.select_one("p.adresse")
204 + address = ""
205 + if addr_el:
206 + address = addr_el.get_text(" ", strip=True)
207 + address = re.sub(r"Coordonnées complètes.*|Visite virtuelle.*",
208 + "", address).strip(" »")
209 + address = _dedup_address(address)
210 + # Ontario : adresse suffixée « ON » (standard expansion 2026-08) —
211 + # sauf si la province y figure déjà (« ON » ou « Ontario »)
212 + if province == "ON" and address \
213 + and not re.search(r",?\s+(?:ON|Ontario)\b", address):
214 + address = f"{address}, ON"
161 215
162 − # exclusion aînés / étudiantes / commercial
163 − if _EXCLUDE_RE.search(name) or _EXCLUDE_RE.search(desc[:200]):
164 − continue
216 + # description (+ éventuelle section « Promotion » en tête)
217 + desc = ""
218 + d_h2 = soup.find("h2", string=re.compile("Description", re.I))
219 + if d_h2 and d_h2.find_parent():
220 + desc = d_h2.find_parent().get_text(" ", strip=True)
221 + desc = re.sub(r"^Description\s*", "", desc)[:600]
222 + p_h2 = soup.find("h2", string=re.compile(r"^\s*Promotion", re.I))
223 + if p_h2 and p_h2.find_parent():
224 + promo = p_h2.find_parent().get_text(" ", strip=True)
225 + promo = re.sub(r"^Promotion\s*", "", promo).strip()[:200]
226 + if promo:
227 + desc = f"Promotion : {promo}. {desc}".strip()[:700]
165 228
166 − # commodités
167 − amenities: list[str] = []
168 − s_h2 = soup.find("h2", string=re.compile(
169 − "Services dans l'immeuble", re.I))
170 − if s_h2:
171 − ul = s_h2.find_next("ul")
172 − if ul:
173 − amenities = [li.get_text(strip=True)
174 − for li in ul.select("li")][:20]
229 + # exclusion aînés / étudiantes / commercial
230 + if _EXCLUDE_RE.search(name) or _EXCLUDE_RE.search(desc[:200]):
231 + return
232 + # fiches Ontario : variante anglaise (jamais appliquée au QC)
233 + if province == "ON" and (_EXCLUDE_EN_RE.search(name)
234 + or _EXCLUDE_EN_RE.search(desc[:200])):
235 + return
175 236
176 − # photos (relatives DATA/PHOTO/... -> absolues)
177 − images: list[str] = []
178 − for im in soup.select("img[src]"):
179 − src = im["src"]
180 − if "DATA/PHOTO" not in src:
181 − continue
182 − absu = src if src.startswith("http") else f"{BASE}/{src.lstrip('/')}"
183 − if absu not in images:
184 − images.append(absu)
185 − images = images[: self.max_images]
237 + # commodités
238 + amenities: list[str] = []
239 + s_h2 = soup.find("h2", string=re.compile(
240 + "Services dans l'immeuble", re.I))
241 + if s_h2:
242 + ul = s_h2.find_next("ul")
243 + if ul:
244 + amenities = [li.get_text(strip=True)
245 + for li in ul.select("li")][:20]
186 246
187 − # contact de l'immeuble (liens tel:/mailto:, hors pied de page
188 − # corporatif info@cogir.net / 1-866-671-6381)
189 − contact: dict = {}
190 − for a in soup.select('a[href^="tel:"]'):
191 − digits = re.sub(r"\D", "", a.get("href", ""))[-10:]
192 − if len(digits) == 10 and not digits.startswith("866"):
193 − contact["phone"] = (f"{digits[:3]}-{digits[3:6]}-"
194 − f"{digits[6:]}")
195 − break
196 − for a in soup.select('a[href^="mailto:"]'):
197 − email = unquote(a["href"].removeprefix("mailto:")).strip()
198 − if email and email.lower() != "info@cogir.net":
199 − contact["email"] = email
200 − break
201 − details_extra = {"contact": contact} if contact else {}
247 + # photos (relatives DATA/PHOTO/... -> absolues)
248 + images: list[str] = []
249 + for im in soup.select("img[src]"):
250 + src = im["src"]
251 + if "DATA/PHOTO" not in src:
252 + continue
253 + absu = src if src.startswith("http") else f"{BASE}/{src.lstrip('/')}"
254 + if absu not in images:
255 + images.append(absu)
256 + images = images[: self.max_images]
202 257
203 − if b["slug"] in _GM_SLUGS:
204 − sector, city = _GM_SLUGS[b["slug"]]
205 − else:
206 − sector = _SLUG_TO_SECTOR.get(b["slug"], "")
207 − if not sector:
208 − # essaie de déduire le secteur du nom (« Loretteville »)
209 − for s in _SLUG_TO_SECTOR.values():
210 − if s and s.lower() in name.lower():
211 − sector = s
212 − break
213 − city = infer_city(sector or b["slug"], default="Québec")
214 − url = f"{BASE}/{b['path']}"
258 + # contact de l'immeuble (liens tel:/mailto:, hors pied de page
259 + # corporatif info@cogir.net / 1-866-671-6381)
260 + contact: dict = {}
261 + for a in soup.select('a[href^="tel:"]'):
262 + digits = re.sub(r"\D", "", a.get("href", ""))[-10:]
263 + if len(digits) == 10 and not digits.startswith("866"):
264 + contact["phone"] = (f"{digits[:3]}-{digits[3:6]}-"
265 + f"{digits[6:]}")
266 + break
267 + for a in soup.select('a[href^="mailto:"]'):
268 + email = unquote(a["href"].removeprefix("mailto:")).strip()
269 + if email and email.lower() != "info@cogir.net":
270 + contact["email"] = email
271 + break
272 + details_extra = {"contact": contact} if contact else {}
215 273
216 − # 2) Une annonce par modèle du tableau « Modèles disponibles »
217 − # (#tableModele : colType / colModele / colPrix). Les modèles
218 − # marqués « Pas disponible » ou « Liste d'attente » sont exclus.
219 − rows = []
220 − table = soup.select_one("table#tableModele") or soup.find("table")
221 − for tr in table.select("tbody tr") if table else []:
222 − typ_el = tr.select_one("td.colType")
223 − mod_el = tr.select_one("td.colModele")
224 − prix_el = tr.select_one("td.colPrix")
225 − tds = tr.select("td")
226 − typ = (typ_el.get_text(" ", strip=True) if typ_el
227 − else (tds[0].get_text(" ", strip=True) if tds else ""))
228 − modele = mod_el.get_text(" ", strip=True) if mod_el else ""
229 − # certains immeubles mettent « À partir de » dans colModele
230 − if re.fullmatch(r"à partir de\s*", modele, re.I):
231 − modele = ""
232 − if prix_el is not None:
233 − price_txt = prix_el.get_text(" ", strip=True)
234 − else:
235 − price_txt = next((td.get_text(" ", strip=True)
236 − for td in tds[1:]
237 − if "$" in td.get_text()), "")
238 − if not typ or not re.match(
239 − r"^\d\s*1/2|^\d\s*½|^Studio|^Loft|^Penthouse",
240 − typ, re.I):
241 − continue
242 − if re.search(r"pas disponible|liste d'attente", price_txt,
243 − re.I):
244 − continue # modèle affiché mais non offert
245 − price_txt = re.sub(r"à partir de", "", price_txt, flags=re.I)
246 − rows.append((typ, modele, price_txt.strip()))
274 + if province == "ON":
275 + # ville de Toronto : slug -> (secteur/borough, ville)
276 + sector, city = _ON_SLUGS[b["slug"]]
277 + elif b["slug"] in _GM_SLUGS:
278 + sector, city = _GM_SLUGS[b["slug"]]
279 + else:
280 + sector = _SLUG_TO_SECTOR.get(b["slug"], "")
281 + if not sector:
282 + # essaie de déduire le secteur du nom (« Loretteville »)
283 + for s in _SLUG_TO_SECTOR.values():
284 + if s and s.lower() in name.lower():
285 + sector = s
286 + break
287 + city = infer_city(sector or b["slug"], default="Québec")
288 + url = f"{BASE}/{b['path']}"
247 289
248 − if rows:
249 − seen_ext: set[str] = set()
250 − for typ, modele, price_txt in rows:
251 − ut = normalize_unit_type(typ)
252 − ext = f"{b['id']}-{re.sub(r'[^a-z0-9]+', '', ut.lower()) or 'u'}"
253 − if ext in seen_ext:
254 − # 2e modèle du même type (ex. « 3 1/2 + den ») :
255 − # suffixe du nom de modèle pour un uid unique
256 − suffix = re.sub(r"[^a-z0-9]+", "-",
257 − modele.lower()).strip("-")
258 − ext = f"{ext}-{suffix or len(seen_ext)}"
259 − if ext in seen_ext:
260 − continue
261 − seen_ext.add(ext)
262 − label = (f"{ut} ({modele})"
263 − if modele and modele.lower() != typ.lower()
264 − else ut)
265 − listings.append(Listing(
266 − source=self.source_id,
267 − external_id=ext,
268 − url=url,
269 − title=f"{name} — {label}",
270 − address=address,
271 − sector=sector,
272 − city=city,
273 − unit_type=ut,
274 − price=parse_price(price_txt),
275 − price_label=(f"À partir de {price_txt}"
276 − if price_txt else ""),
277 − availability="Disponible" if price_txt else "",
278 − description=desc,
279 − amenities=amenities,
280 − details=dict(details_extra),
281 − images=images,
282 − ))
290 + # 2) Une annonce par modèle du tableau « Modèles disponibles »
291 + # (#tableModele : colType / colModele / colPrix). Les modèles
292 + # marqués « Pas disponible » ou « Liste d'attente » sont exclus.
293 + rows = []
294 + table = soup.select_one("table#tableModele") or soup.find("table")
295 + for tr in table.select("tbody tr") if table else []:
296 + typ_el = tr.select_one("td.colType")
297 + mod_el = tr.select_one("td.colModele")
298 + prix_el = tr.select_one("td.colPrix")
299 + tds = tr.select("td")
300 + typ = (typ_el.get_text(" ", strip=True) if typ_el
301 + else (tds[0].get_text(" ", strip=True) if tds else ""))
302 + modele = mod_el.get_text(" ", strip=True) if mod_el else ""
303 + # certains immeubles mettent « À partir de » dans colModele
304 + if re.fullmatch(r"à partir de\s*", modele, re.I):
305 + modele = ""
306 + if prix_el is not None:
307 + price_txt = prix_el.get_text(" ", strip=True)
283 308 else:
309 + price_txt = next((td.get_text(" ", strip=True)
310 + for td in tds[1:]
311 + if "$" in td.get_text()), "")
312 + if not typ or not re.match(
313 + r"^\d\s*1/2|^\d\s*½|^Studio|^Loft|^Penthouse",
314 + typ, re.I):
315 + continue
316 + if re.search(r"pas disponible|liste d'attente", price_txt,
317 + re.I):
318 + continue # modèle affiché mais non offert
319 + price_txt = re.sub(r"à partir de", "", price_txt, flags=re.I)
320 + rows.append((typ, modele, price_txt.strip()))
321 +
322 + if rows:
323 + seen_ext: set[str] = set()
324 + for typ, modele, price_txt in rows:
325 + ut = normalize_unit_type(typ)
326 + ext = f"{b['id']}-{re.sub(r'[^a-z0-9]+', '', ut.lower()) or 'u'}"
327 + if ext in seen_ext:
328 + # 2e modèle du même type (ex. « 3 1/2 + den ») :
329 + # suffixe du nom de modèle pour un uid unique
330 + suffix = re.sub(r"[^a-z0-9]+", "-",
331 + modele.lower()).strip("-")
332 + ext = f"{ext}-{suffix or len(seen_ext)}"
333 + if ext in seen_ext:
334 + continue
335 + seen_ext.add(ext)
336 + label = (f"{ut} ({modele})"
337 + if modele and modele.lower() != typ.lower()
338 + else ut)
284 339 listings.append(Listing(
285 340 source=self.source_id,
286 − external_id=b["id"],
341 + external_id=ext,
287 342 url=url,
288 − title=name,
343 + title=f"{name} — {label}",
289 344 address=address,
290 345 sector=sector,
291 346 city=city,
292 − unit_type="",
293 − price=None,
294 − price_label="",
295 − availability="",
347 + province=province,
348 + unit_type=ut,
349 + price=parse_price(price_txt),
350 + price_label=(f"À partir de {price_txt}"
351 + if price_txt else ""),
352 + availability="Disponible" if price_txt else "",
296 353 description=desc,
297 354 amenities=amenities,
298 355 details=dict(details_extra),
299 356 images=images,
300 357 ))
301 −
302 − return listings
358 + else:
359 + listings.append(Listing(
360 + source=self.source_id,
361 + external_id=b["id"],
362 + url=url,
363 + title=name,
364 + address=address,
365 + sector=sector,
366 + city=city,
367 + province=province,
368 + unit_type="",
369 + price=None,
370 + price_label="",
371 + availability="",
372 + description=desc,
373 + amenities=amenities,
374 + details=dict(details_extra),
375 + images=images,
376 + ))
modified louka/connectors/hazelview.py +39 −10
@@ -11,11 +11,17 @@
11 11 # loyer. Une annonce par immeuble et par type d'unité. La page immeuble du
12 12 # site (via cache BD) embarque un JSON complet (attribut data-locations de
13 13 # la carte) : commodités, galerie photos, animaux détaillés, suites.
14 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO=1 : on inclut TOUTES les
15 +# villes province_code=="ON" retournées par /v2/cities (Ottawa, GTA,
16 +# Hamilton, London, KW… selon les disponibilités du moment), sans whitelist
17 +# de villes — la whitelist Grand Montréal reste le garde-fou QC. Sans la
18 +# variable d'environnement, comportement strictement identique (prod QC).
14 19 # -----------------------------------------------------------------------------
15 20 from __future__ import annotations
16 21
17 22 import html as htmllib
18 23 import json
24 +import os
19 25 import re
20 26
21 27 from bs4 import BeautifulSoup
@@ -32,6 +38,9 @@ AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # jeton public (présent dans main.js)
32 38 SEARCH_PARAMS = ("only_available_suites=true&show_all_properties=false"
33 39 "&min_bath=-1&max_bath=10&min_rate=0&max_rate=10000")
34 40
41 +# Expansion Ontario (gaté LOUKA_ONTARIO=1 ; prod QC intacte sans)
42 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
43 +
35 44 # Villes QC admissibles (Grand Montréal) -> (ville, secteur imposé)
36 45 _GM_CITIES = {
37 46 "montreal": ("Montréal", None),
@@ -73,7 +82,8 @@ def _pets_from_flags(d: dict) -> str | None:
73 82 class HazelviewConnector(BaseConnector):
74 83 source_id = "hazelview"
75 84 request_delay = 0.6
76 − max_cities = 10 # garde-fou
85 + max_cities = 10 # garde-fou (QC)
86 + max_cities_on = 40 # garde-fou distinct pour l'Ontario (gaté)
77 87 max_details = 150 # garde-fou pages immeuble (vraies requêtes)
78 88
79 89 def _api(self, path: str, extra: str = "") -> list | dict:
@@ -84,17 +94,32 @@ class HazelviewConnector(BaseConnector):
84 94 def fetch(self) -> list[Listing]:
85 95 cities = self._api("cities")
86 96 qc = []
97 + on = [] # villes ON (expansion, gaté LOUKA_ONTARIO)
87 98 for c in cities if isinstance(cities, list) else []:
88 − if (c.get("province_code") or "").upper() != "QC":
89 − continue
90 − key = strip_accents((c.get("city_name") or "").strip().lower())
91 − if key in _GM_CITIES:
92 − qc.append((c.get("id"), key))
99 + code = (c.get("province_code") or "").upper()
100 + if code == "QC":
101 + key = strip_accents((c.get("city_name") or "").strip().lower())
102 + if key in _GM_CITIES:
103 + qc.append((c.get("id"), key))
104 + elif _ONTARIO and code == "ON":
105 + # pas de whitelist : toutes les villes ON publiées par l'API
106 + name = (c.get("city_name") or "").strip()
107 + if name:
108 + on.append((c.get("id"), name))
93 109
94 110 listings: list[Listing] = []
95 111 bed_range: dict[str, tuple[int, int]] = {}
96 − for city_id, key in qc[: self.max_cities]:
97 − city, forced_sector = _GM_CITIES[key]
112 +
113 + # (city_id, ville affichée, secteur imposé, province)
114 + targets = [(cid, *_GM_CITIES[key], "QC")
115 + for cid, key in qc[: self.max_cities]]
116 + if _ONTARIO:
117 + targets += [(cid, name, None, "ON")
118 + for cid, name in on[: self.max_cities_on]]
119 + # budget de pages immeuble élargi pour absorber le parc ON
120 + self.max_details = max(self.max_details, 400)
121 +
122 + for city_id, city, forced_sector, province in targets:
98 123 for min_bed, max_bed, unit_type in _BED_QUERIES:
99 124 try:
100 125 props = self._api(
@@ -108,7 +133,8 @@ class HazelviewConnector(BaseConnector):
108 133 for p in props:
109 134 try:
110 135 lst = self._prop_listing(p, unit_type, min_bed,
111 − city, forced_sector)
136 + city, forced_sector,
137 + province=province)
112 138 if lst:
113 139 listings.append(lst)
114 140 bed_range[lst.external_id] = (min_bed, max_bed)
@@ -136,7 +162,8 @@ class HazelviewConnector(BaseConnector):
136 162 return listings
137 163
138 164 def _prop_listing(self, p: dict, unit_type: str, beds: int,
139 − city: str, forced_sector: str | None) -> Listing | None:
165 + city: str, forced_sector: str | None,
166 + province: str = "QC") -> Listing | None:
140 167 if not p.get("availability_count"):
141 168 return None
142 169 addr = p.get("address") or {}
@@ -217,9 +244,11 @@ class HazelviewConnector(BaseConnector):
217 244 title=f"{name} — {unit_type}",
218 245 address=", ".join(x for x in [
219 246 (addr.get("address") or "").strip(), city,
247 + "ON" if province == "ON" else "",
220 248 (addr.get("postal_code") or "").strip()] if x),
221 249 sector=sector,
222 250 city=city,
251 + province=province,
223 252 unit_type=unit_type,
224 253 price=price,
225 254 price_label=price_label,
added louka/connectors/homestead.py +310 −0
@@ -0,0 +1,310 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/homestead.py : connecteur Homestead Land Holdings (homestead.ca)
5 +# Un des 5 plus gros gestionnaires de l'Ontario (~27 000 unités, siège à
6 +# Kingston) : Toronto/GTA, Ottawa, Hamilton, London, Kitchener-Waterloo,
7 +# Kingston, Guelph, Sarnia… Le site est une SPA Rentsync « nouvelle
8 +# génération » (bundle cdn.rentsync.com/site/homestead_rebuild) qui parle à
9 +# la passerelle JSON PUBLIQUE (aucune auth, aucun anti-bot) :
10 +# https://website-gateway.rentsync.com/v1/homestead_rebuild/
11 +# properties?limit=500 → 172 immeubles (adresse,
12 +# GPS, description, animaux, permaLink, cityId, modified)
13 +# cities/property-summary?limit=100 → cityId → nom + province
14 +# units?where=buildingId~in:a|b|…,status~in:enabled
15 +# → types d'unités avec bed/bath/pi²/prix/dispo/date (séparateur
16 +# multi-valeurs : « | » ; paginer via meta.totalPages)
17 +# properties/{id}/photos + /utilities → galerie + services inclus
18 +# Photos : https://s3.amazonaws.com/lws_lift/homestead/images/gallery/full/…
19 +# (clé S3 « homestead », PAS « homestead_rebuild » qui sert au contenu CMS).
20 +# On émet UNE annonce par type d'unité DISPONIBLE (available=1), regroupées
21 +# par (immeuble, type, cc, sdb) — certains immeubles listent chaque logement.
22 +# Fiche (photos+services) via le cache BD self.detail(), clé = modified.
23 +# -----------------------------------------------------------------------------
24 +# Expansion Ontario — activer via LOUKA_ONTARIO=1
25 +from __future__ import annotations
26 +
27 +import hashlib
28 +import os
29 +import re
30 +
31 +from bs4 import BeautifulSoup
32 +
33 +from ..schema import Listing, normalize_unit_type
34 +from .base import BaseConnector
35 +
36 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
37 +
38 +BASE = "https://www.homestead.ca"
39 +GATEWAY = "https://website-gateway.rentsync.com/v1/homestead_rebuild"
40 +# galerie S3 : tailles 512/768/1152/full — « full » validé live
41 +IMG_BASE = "https://s3.amazonaws.com/lws_lift/homestead/images/gallery/full"
42 +
43 +_ISO_DATE = re.compile(r"^20\d{2}-\d{2}-\d{2}")
44 +
45 +
46 +def _slug(s: str) -> str:
47 + return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")
48 +
49 +
50 +def _txt(html: str) -> str:
51 + """HTML de la passerelle → texte plat."""
52 + if not html:
53 + return ""
54 + return BeautifulSoup(html, "html.parser").get_text(" ", strip=True)
55 +
56 +
57 +class HomesteadConnector(BaseConnector):
58 + source_id = "homestead"
59 + request_delay = 0.8
60 + disabled = not _ONTARIO # Expansion Ontario — activer via LOUKA_ONTARIO=1
61 + max_properties = 300 # garde-fou (172 immeubles au 2026-08)
62 + max_images = 15
63 + chunk_size = 25 # immeubles par requête « units »
64 +
65 + # -- passerelle JSON --------------------------------------------------------
66 + def _api(self, path: str, **params) -> dict:
67 + resp = self.get(f"{GATEWAY}/{path}", params=params,
68 + headers={"Accept": "application/json",
69 + "Origin": BASE, "Referer": BASE + "/"})
70 + return resp.json()
71 +
72 + def _api_all(self, path: str, **params) -> list[dict]:
73 + """Toutes les pages d'une collection (meta.totalPages)."""
74 + params.setdefault("limit", 500)
75 + out: list[dict] = []
76 + page = 1
77 + while True:
78 + d = self._api(path, page=page, **params)
79 + out.extend(d.get("data") or [])
80 + meta = d.get("meta") or {}
81 + total = meta.get("totalPages") or 1
82 + if page >= total:
83 + return out
84 + page += 1
85 +
86 + # -- villes : cityId → (nom, code province) ---------------------------------
87 + def _cities(self) -> dict[int, tuple[str, str]]:
88 + cities: dict[int, tuple[str, str]] = {}
89 + try:
90 + for c in self._api_all("cities/property-summary", limit=100):
91 + cid = c.get("cityId")
92 + if cid is not None:
93 + cities[int(cid)] = ((c.get("cityName") or "").strip(),
94 + (c.get("provinceCode") or "").strip().upper())
95 + except Exception:
96 + pass # repli : ville dérivée du permaLink dans _listing
97 + return cities
98 +
99 + def fetch(self) -> list[Listing]:
100 + cities = self._cities()
101 + props = self._api_all("properties")[: self.max_properties]
102 + by_id = {int(p["id"]): p for p in props if p.get("id") is not None}
103 +
104 + # types d'unités actifs, par lots d'immeubles (séparateur « | »)
105 + units: list[dict] = []
106 + ids = list(by_id)
107 + for i in range(0, len(ids), self.chunk_size):
108 + chunk = "|".join(str(x) for x in ids[i:i + self.chunk_size])
109 + try:
110 + units.extend(self._api_all(
111 + "units", where=f"buildingId~in:{chunk},status~in:enabled"))
112 + except Exception:
113 + continue
114 +
115 + # regrouper les unités DISPONIBLES par (immeuble, type, cc, sdb) —
116 + # certains immeubles publient une ligne par logement individuel
117 + groups: dict[tuple, list[dict]] = {}
118 + for u in units:
119 + if not u.get("available") or u.get("hideSuiteTypeWebsite"):
120 + continue
121 + bid = int(u.get("buildingId") or 0)
122 + if bid not in by_id:
123 + continue
124 + key = (bid, _slug(u.get("typeName") or ""),
125 + u.get("bed"), u.get("bath"))
126 + groups.setdefault(key, []).append(u)
127 +
128 + listings: list[Listing] = []
129 + for key, grp in groups.items():
130 + try:
131 + listings.append(self._listing(by_id[key[0]], grp, cities))
132 + except Exception:
133 + continue
134 + return listings
135 +
136 + # -- une annonce par type d'unité disponible dans un immeuble ---------------
137 + def _listing(self, p: dict, grp: list[dict],
138 + cities: dict[int, tuple[str, str]]) -> Listing:
139 + pid = int(p["id"])
140 + u0 = grp[0]
141 + type_name = (u0.get("typeName") or "").strip()
142 + name = (p.get("buildingName") or "").strip()
143 + perma = (p.get("permaLink") or "").strip()
144 + url = f"{BASE}/residential/{perma}" if perma else BASE
145 +
146 + # ville : mapping cityId → nom officiel ; repli = dernier segment du slug
147 + city, prov = cities.get(int(p.get("cityId") or 0), ("", "ON"))
148 + if not city and perma:
149 + city = perma.rsplit("-", 1)[-1].replace("-", " ").title()
150 +
151 + # adresse complète : rue + ville + ON + code postal
152 + street = " ".join(x for x in ((p.get("streetNumber") or "").strip(),
153 + (p.get("streetName") or "").strip()) if x)
154 + postal = (p.get("postal") or "").strip()
155 + full_addr = ", ".join(x for x in (street, city) if x)
156 + if full_addr:
157 + full_addr += f", ON {postal}".rstrip()
158 +
159 + # coordonnées GPS structurées de la passerelle
160 + try:
161 + lat = float(p["latitude"]) if p.get("latitude") else None
162 + lng = float(p["longitude"]) if p.get("longitude") else None
163 + except (TypeError, ValueError):
164 + lat = lng = None
165 +
166 + # prix : plus bas tarif affichable du groupe (0 = prix masqué)
167 + rates = []
168 + for u in grp:
169 + if u.get("hideRateWebsites"):
170 + continue
171 + try:
172 + r = float(u.get("rate") or 0)
173 + except (TypeError, ValueError):
174 + r = 0.0
175 + if r > 0:
176 + rates.append(r)
177 + price = min(rates) if rates else None
178 + price_label = ""
179 + if price is not None:
180 + price_label = (f"À partir de {price:.0f} $"
181 + if len(grp) > 1 or (rates and max(rates) != price)
182 + else f"{price:.0f} $ /mois")
183 +
184 + # disponibilité : plus proche date du groupe (None = maintenant)
185 + dates = sorted(str(u.get("availabilityDate") or "")[:10]
186 + for u in grp if _ISO_DATE.match(
187 + str(u.get("availabilityDate") or "")))
188 + avail_date = dates[0] if dates else None
189 + availability = (f"Disponible le {avail_date}" if avail_date
190 + else "Disponible maintenant")
191 +
192 + # superficie : plus petite valeur plausible du groupe
193 + area = None
194 + for u in grp:
195 + for k in ("sqFt", "sqFtMin"):
196 + try:
197 + v = float(u.get(k) or 0)
198 + except (TypeError, ValueError):
199 + continue
200 + if 80 <= v <= 20000 and (area is None or v < area):
201 + area = v
202 +
203 + # chambres / salles de bain : champs structurés de l'unité
204 + try:
205 + bedrooms = float(u0["bed"]) if u0.get("bed") is not None else None
206 + bathrooms = float(u0["bath"]) if u0.get("bath") is not None else None
207 + except (TypeError, ValueError):
208 + bedrooms = bathrooms = None
209 + unit_type = ("Studio" if bedrooms == 0
210 + else normalize_unit_type(type_name))
211 +
212 + # animaux : indicateurs structurés de l'immeuble
213 + if p.get("petsNotAllowed"):
214 + pets = "non"
215 + elif p.get("petFriendly"):
216 + pets = "oui"
217 + else:
218 + pets = None
219 +
220 + # description : aperçu de l'immeuble + détails de suites (HTML → texte)
221 + desc = " ".join(x for x in (
222 + _txt(p.get("buildingOverview") or ""),
223 + _txt(p.get("suiteDetails") or ""),
224 + ) if x)[:800]
225 +
226 + # commodités : caractéristiques de l'immeuble + services inclus (fiche)
227 + amenities: list[str] = []
228 + for t in re.split(r"[\n;•]|</li>|<li>",
229 + p.get("buildingFeatures") or ""):
230 + t = _txt(t)
231 + if t and t not in amenities:
232 + amenities.append(t)
233 +
234 + # champs structurés
235 + details: dict = {}
236 + contact: dict = {}
237 + if (p.get("phone") or "").strip():
238 + contact["phone"] = p["phone"].strip()
239 + if (p.get("email") or "").strip():
240 + contact["email"] = p["email"].strip()
241 + if contact:
242 + details["contact"] = contact
243 + if (p.get("neighbourhood") or "").strip():
244 + details["Quartier"] = p["neighbourhood"].strip()
245 +
246 + # fiche (galerie photo + services inclus) via le cache BD : revisitée
247 + # seulement quand l'immeuble change (horodatage « modified »)
248 + det_key = hashlib.sha1(str(p.get("modified") or "").encode()).hexdigest()
249 + d = self.detail(f"b{pid}", det_key, lambda: self._fetch_detail(pid))
250 + for t in d.get("utilities") or []:
251 + t = f"{t} incluse" if t in ("Eau", "Électricité") else t
252 + if t and t not in amenities:
253 + amenities.append(t)
254 + images = list(d.get("images") or [])
255 +
256 + return Listing(
257 + source=self.source_id,
258 + external_id=f"{pid}-{_slug(type_name) or 'unite'}-"
259 + f"{u0.get('bed')}cc-{u0.get('bath')}sdb",
260 + url=url,
261 + title=f"{name} — {type_name}" if type_name else name,
262 + address=full_addr,
263 + sector=(p.get("neighbourhood") or "").strip(),
264 + city=city,
265 + province=prov or "ON",
266 + unit_type=unit_type,
267 + bedrooms=bedrooms,
268 + bathrooms=bathrooms,
269 + price=price,
270 + price_label=price_label,
271 + availability=availability,
272 + availability_date=avail_date,
273 + area_sqft=area,
274 + pets=pets,
275 + description=desc,
276 + amenities=amenities[:25],
277 + details=details,
278 + images=images[: self.max_images],
279 + lat=lat,
280 + lng=lng,
281 + )
282 +
283 + # -- fiche immeuble : galerie photo + services inclus (2 appels, cachés) ----
284 + def _fetch_detail(self, pid: int) -> dict:
285 + out: dict = {"images": [], "utilities": []}
286 + # traductions FR des services inclus les plus fréquents
287 + fr = {"Water": "Eau", "Heat": "Chauffage", "Hydro": "Électricité",
288 + "Electricity": "Électricité", "Internet": "Internet",
289 + "Cable": "Câble"}
290 + try:
291 + photos = self._api_all(f"properties/{pid}/photos", limit=100)
292 + except Exception:
293 + photos = []
294 + photos = [ph for ph in photos
295 + if ph.get("active") and (ph.get("image") or "").strip()]
296 + photos.sort(key=lambda ph: (0 if ph.get("mainGallery") else 1,
297 + ph.get("orderBy") or 0))
298 + for ph in photos:
299 + u = f"{IMG_BASE}/{ph['image'].strip()}"
300 + if u not in out["images"]:
301 + out["images"].append(u)
302 + out["images"] = out["images"][: self.max_images]
303 + try:
304 + for ut in self._api_all(f"properties/{pid}/utilities", limit=50):
305 + t = (ut.get("name") or "").strip()
306 + if t:
307 + out["utilities"].append(fr.get(t, t))
308 + except Exception:
309 + pass
310 + return out
modified louka/connectors/interrent.py +66 −9
@@ -7,13 +7,19 @@
7 7 # (adresse, quartier, photos, commodités Amenities/Utilities, animaux
8 8 # PetFriendly*, stationnement, contact, description via référence $NN)
9 9 # avec leurs suites disponibles (type, chambres, sdb, pi², loyer, date).
10 −# Une seule requête suffit.
10 +# Une seule requête suffit : le flux RSC embarque TOUTES les communautés
11 +# du REIT (QC, ON, BC), peu importe la ville demandée dans l'URL.
11 12 # REIT pancanadien : filtre strict sur les villes du Grand Montréal.
13 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,
14 +# les communautés ON des marchés cibles (Toronto/GTA, Ottawa, Hamilton,
15 +# Kitchener-Waterloo) sont aussi conservées (province="ON", adresse
16 +# suffixée « ON »). Sans la variable, comportement strictement identique.
12 17 # -----------------------------------------------------------------------------
13 18 from __future__ import annotations
14 19
15 20 import html as htmllib
16 21 import json
22 +import os
17 23 import re
18 24
19 25 from ..schema import Listing, strip_accents
@@ -22,6 +28,9 @@ from .base import BaseConnector
22 28 BASE = "https://www.irent.com"
23 29 CITY_URL = f"{BASE}/communities/city/montreal"
24 30
31 +# Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, QC seulement
32 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
33 +
25 34 CHUNK_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)')
26 35 OBJ_START_RE = re.compile(r'\{"Id":\d+,"ImportId"')
27 36
@@ -39,6 +48,40 @@ _GM_CITIES = {
39 48 "dollard-des-ormeaux": "Dollard-des-Ormeaux",
40 49 }
41 50
51 +# Villes admissibles en Ontario (expansion 2026-08, sous LOUKA_ONTARIO) :
52 +# marchés cibles Toronto/GTA, Ottawa, Hamilton, Kitchener-Waterloo.
53 +# clé sans accents/minuscules -> (ville normalisée, secteur imposé ou None) ;
54 +# les anciens boroughs de Toronto/Ottawa sont repliés sur la ville fusionnée.
55 +_ON_CITIES = {
56 + "toronto": ("Toronto", None),
57 + "etobicoke": ("Toronto", "Etobicoke"),
58 + "north york": ("Toronto", "North York"),
59 + "york": ("Toronto", "York"),
60 + "east york": ("Toronto", "East York"),
61 + "scarborough": ("Toronto", "Scarborough"),
62 + "mississauga": ("Mississauga", None),
63 + "brampton": ("Brampton", None),
64 + "markham": ("Markham", None),
65 + "vaughan": ("Vaughan", None),
66 + "richmond hill": ("Richmond Hill", None),
67 + "ajax": ("Ajax", None),
68 + "pickering": ("Pickering", None),
69 + "whitby": ("Whitby", None),
70 + "oshawa": ("Oshawa", None),
71 + "oakville": ("Oakville", None),
72 + "burlington": ("Burlington", None),
73 + "milton": ("Milton", None),
74 + "hamilton": ("Hamilton", None),
75 + "ottawa": ("Ottawa", None),
76 + "nepean": ("Ottawa", "Nepean"),
77 + "kanata": ("Ottawa", "Kanata"),
78 + "gloucester": ("Ottawa", "Gloucester"),
79 + "orleans": ("Ottawa", "Orléans"),
80 + "kitchener": ("Kitchener", None),
81 + "waterloo": ("Waterloo", None),
82 + "cambridge": ("Cambridge", None),
83 +}
84 +
42 85 _BED_TYPES = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}
43 86
44 87
@@ -174,23 +217,37 @@ class InterrentConnector(BaseConnector):
174 217 refs: dict[str, str] | None = None) -> list[Listing]:
175 218 refs = refs or {}
176 219 loc = c.get("Location") or {}
177 − if (loc.get("ProvinceCode") or "").upper() != "QC":
178 − return []
220 + prov = (loc.get("ProvinceCode") or "").upper()
179 221 raw_city = _clean(loc.get("City") or "")
180 222 key = strip_accents(raw_city.lower()).strip()
181 − city = _GM_CITIES.get(key)
182 − if not city:
183 − return [] # hors Grand Montréal
223 + forced_sector = None
224 + if prov == "QC":
225 + city = _GM_CITIES.get(key)
226 + if not city:
227 + return [] # hors Grand Montréal
228 + elif prov == "ON" and _ONTARIO:
229 + # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO)
230 + city, forced_sector = _ON_CITIES.get(key, (None, None))
231 + if not city:
232 + return [] # hors marchés cibles ON
233 + else:
234 + return []
184 235 sector = _clean(loc.get("Neighbourhood") or c.get("TagLine") or "")
185 236 if strip_accents(sector.lower()) == strip_accents(city.lower()):
186 237 sector = "" if city != "Montréal" else sector
187 − if key in ("cote-saint-luc", "verdun", "lasalle") and not sector:
238 + if prov == "QC" and key in ("cote-saint-luc", "verdun", "lasalle") \
239 + and not sector:
188 240 sector = raw_city if city == "Montréal" else ""
241 + if forced_sector and not sector:
242 + sector = forced_sector # ex. Etobicoke -> Toronto / Etobicoke
189 243
190 244 name = _clean(c.get("Name") or "")
191 245 url = c.get("Url") or f"{BASE}/communities/{c.get('PermaLink', '')}"
192 246 address = _clean(loc.get("Address") or "")
193 247 postal = _clean(loc.get("PostalCode") or "")
248 + # adresse complète : suffixe « ON » pour l'Ontario (QC inchangé)
249 + last = f"ON {postal}".strip() if prov == "ON" else postal
250 + full_address = ", ".join(x for x in [address, city, last] if x)
194 251
195 252 # Photos de la communauté
196 253 images: list[str] = []
@@ -339,10 +396,10 @@ class InterrentConnector(BaseConnector):
339 396 external_id=str(s.get("Id")),
340 397 url=url,
341 398 title=label,
342 − address=", ".join(x for x in [address, city, postal]
343 − if x),
399 + address=full_address,
344 400 sector=sector,
345 401 city=city,
402 + province=prov,
346 403 unit_type=unit_type,
347 404 price=price,
348 405 price_label=f"{int(rate)} $/mois" if price else "",
added louka/connectors/killam.py +343 −0
@@ -0,0 +1,343 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/killam.py : connecteur Killam Apartment REIT (killamreit.com)
5 +# REIT national (~17 000 unités, Maritimes + Ontario + Alberta/C.-B.) — on ne
6 +# garde ici QUE l'Ontario : Toronto, Ottawa (Kanata), London, Kitchener-
7 +# Waterloo, Cambridge, Mississauga (~29 propriétés au moment de l'écriture).
8 +# Le site est un « corporate site » Yardi RentCafe derrière Cloudflare
9 +# (challenge JS : curl nu = 403 « Just a moment… »). Aucune API interne à
10 +# rejouer : la page de recherche /apartments embarque TOUT le portefeuille
11 +# (204 propriétés) dans un input caché `#available_prop` — un JSON doublement
12 +# encodé (chaîne JSON contenant du JSON aux guillemets "). On le décode
13 +# avec deux json.loads, sans exécuter de JavaScript : Scrapfly ASP suffit
14 +# (asp=true, PAS de render_js), via la chaîne de secours de BaseConnector.get
15 +# (Oxylabs résidentiel → Scrapfly → Bright Data).
16 +# Le flux liste donne nom, adresse civique, ville/province/code postal,
17 +# lat/lng, fourchettes loyer/chambres/sdb/pi², politique animaux, téléphone
18 +# et l'URL de la fiche. La fiche propriété (rendu serveur, même anti-bot)
19 +# fournit description, galerie photo (resource.rentcafe.com) et les cartes
20 +# « plans d'étage » (nom, chambres, sdb, pi², « Starting at $X /Month ») —
21 +# visitée via le cache BD self.detail() (clé = hash de la ligne du flux).
22 +# On émet UNE annonce PAR PLAN D'ÉTAGE (granularité type d'unité), avec un
23 +# repli « une annonce par propriété » si les cartes sont introuvables.
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import hashlib
28 +import json
29 +import os
30 +import re
31 +
32 +from bs4 import BeautifulSoup
33 +
34 +from ..schema import Listing, normalize_unit_type
35 +from .base import BaseConnector
36 +
37 +BASE = "https://www.killamreit.com"
38 +SEARCH_PAGE = f"{BASE}/apartments"
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 (zéro impact prod).
42 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
43 +
44 +# blob JSON du portefeuille dans la page de recherche (input caché)
45 +_PROP_BLOB_RE = re.compile(
46 + r"id=\"available_prop\"\s+value='(.*?)'", re.S)
47 +
48 +# cartes plans d'étage de la fiche : « 1 Bed », « 1.5 Bath », « 415 Sq. Ft. »
49 +_BED_RE = re.compile(r"(\d+)\s*Bed")
50 +_BATH_RE = re.compile(r"([\d.]+)\s*Bath")
51 +_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I)
52 +_PRICE_RE = re.compile(r"\$\s*([\d,]+)")
53 +_FPID_RE = re.compile(r"#fp-modal-(\d+)")
54 +# disclaimer promo servi DANS le paragraphe descriptif — retiré, pas interprété
55 +_DISCLAIMER_RE = re.compile(
56 + r"Limited-time offer[^.]*\.\s*(Subject to [^.]*\.\s*)?", re.I)
57 +_BOILERPLATE_RE = re.compile(
58 + r"marketing text|cookie|consent|unsubscribe|privacy policy", re.I)
59 +
60 +# IDs de commodités RentCafe (checkboxes du filtre corpsearch) -> libellé FR
61 +_AMENITY_FR = {
62 + "1": "Air climatisé", "2": "Lave-vaisselle", "3": "Espace de rangement",
63 + "7": "Planchers de bois franc", "8": "Grands placards",
64 + "9": "Balcon/terrasse", "10": "Laveuse-sécheuse",
65 + "11": "Entrées laveuse-sécheuse", "12": "Accès fauteuil roulant",
66 + "13": "Aire BBQ/pique-nique", "14": "Centre d'affaires",
67 + "15": "Salle de réception", "16": "Accès contrôlé",
68 + "17": "Stationnement couvert", "18": "Garage",
69 + "19": "Internet haute vitesse", "20": "Spa", "21": "Buanderie",
70 + "22": "Salle multimédia", "23": "Bail court terme", "24": "Piscine",
71 + "25": "Salle d'entraînement", "26": "Terrain de basketball",
72 + "27": "Terrain de tennis", "28": "Service de garde", "29": "Aire de jeux",
73 + "30": "Supports à vélos", "31": "Électroménagers écoénergétiques",
74 + "32": "Thermostat électronique", "33": "Bâtiment écologique",
75 + "34": "Transport en commun à proximité", "35": "Recyclage",
76 +}
77 +
78 +# villes du flux à normaliser (le flux écrit parfois « Kanata Ottawa »)
79 +_CITY_FIX = {
80 + "kanata ottawa": ("Ottawa", "Kanata"),
81 + "kanata-ottawa": ("Ottawa", "Kanata"),
82 +}
83 +
84 +
85 +def _money(txt: str) -> float | None:
86 + m = _PRICE_RE.search(txt or "")
87 + return float(m.group(1).replace(",", "")) if m else None
88 +
89 +
90 +class KillamConnector(BaseConnector):
91 + source_id = "killam"
92 + request_delay = 1.2 # Cloudflare/Scrapfly : rester très poli
93 + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1)
94 + max_properties = 40 # garde-fou (29 propriétés ON aujourd'hui)
95 + max_images = 20
96 +
97 + # -- portefeuille : blob embarqué de la page de recherche -------------------
98 + def _properties(self) -> list[dict]:
99 + page = self.get(SEARCH_PAGE).text
100 + m = _PROP_BLOB_RE.search(page)
101 + if not m:
102 + raise RuntimeError("Killam : blob #available_prop introuvable "
103 + "(gabarit RentCafe modifié ?)")
104 + # doublement encodé : value='"[{"companyid":0,…}]"'
105 + return json.loads(json.loads(m.group(1)))
106 +
107 + def fetch(self) -> list[Listing]:
108 + props = self._properties()
109 + listings: list[Listing] = []
110 + count = 0
111 + for p in props:
112 + if (p.get("propertyState") or "").strip().upper() != "ON":
113 + continue # REIT national : Ontario seulement ici
114 + if count >= self.max_properties:
115 + break
116 + count += 1
117 + try:
118 + listings.extend(self._listings(p))
119 + except Exception:
120 + continue
121 + return listings
122 +
123 + # -- annonces d'une propriété (une par plan d'étage, repli propriété) -------
124 + def _listings(self, p: dict) -> list[Listing]:
125 + pid = str(p.get("propertyid"))
126 + url = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "")
127 + .strip().rstrip("?")) or SEARCH_PAGE
128 + name = (p.get("propertyName") or "").strip()
129 +
130 + # adresse complète : rue + ville + « ON » + code postal
131 + city_raw = (p.get("propertyCity") or "").strip()
132 + city, sector = _CITY_FIX.get(city_raw.lower(), (city_raw, ""))
133 + street = (p.get("propertyAddress") or "").strip()
134 + postal = (p.get("propertyZipCode") or "").strip()
135 + full_addr = ", ".join(x for x in (street, city) if x) + ", ON"
136 + if postal:
137 + full_addr += f" {postal}"
138 +
139 + # coordonnées GPS du flux (finalize() valide la bbox Ontario)
140 + try:
141 + lat = float(p.get("propertyLat")) or None
142 + lng = float(p.get("propertyLng")) or None
143 + except (TypeError, ValueError):
144 + lat = lng = None
145 +
146 + # politique animaux : {"bNoPetsAllowed":false,"bCats":true,"bDogs":true}
147 + pets = None
148 + try:
149 + pol = json.loads(p.get("bPetPolicy") or "{}")
150 + if pol.get("bNoPetsAllowed") is True:
151 + pets = "non"
152 + elif pol.get("bCats") and pol.get("bDogs"):
153 + pets = "oui"
154 + elif pol.get("bCats") or pol.get("bDogs"):
155 + pets = "conditions"
156 + except (ValueError, TypeError):
157 + pass
158 +
159 + # commodités : IDs RentCafe -> libellés français
160 + amenities = [_AMENITY_FR[a] for a in (p.get("sAmenity") or [])
161 + if a in _AMENITY_FR]
162 +
163 + # disponibilité : le flux n'expose qu'un booléen d'occupation + une
164 + # date minimale (« 1/1/0001 » = aucune affichée) — rien d'inventé
165 + occupied = p.get("IsFullyOccupied") is True
166 + availability = ("Aucune unité disponible" if occupied
167 + else "Unités disponibles")
168 + avail_date = None
169 + mad = str(p.get("dtMinUnitAvailable") or "").strip()
170 + md = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(20\d{2})", mad)
171 + if md:
172 + avail_date = f"{md.group(3)}-{int(md.group(1)):02d}-{int(md.group(2)):02d}"
173 +
174 + details: dict = {}
175 + phone = (p.get("phone") or "").strip()
176 + if phone:
177 + details["contact"] = {"phone": phone}
178 +
179 + # fiche propriété (description, galerie, plans d'étage) — via le cache
180 + # BD : revisitée seulement quand la ligne du flux change
181 + feed_key = hashlib.sha1("|".join(str(x) for x in (
182 + p.get("propertyMinRent"), p.get("propertyMaxRent"),
183 + p.get("propertyMinBed"), p.get("propertyMaxBed"),
184 + p.get("propertyMinArea"), p.get("propertyMaxArea"),
185 + p.get("IsFullyOccupied"), mad, p.get("propertyThumb"),
186 + )).encode("utf-8")).hexdigest()
187 + d = self.detail(pid, feed_key, lambda: self._fetch_detail(url))
188 +
189 + desc = d.get("description") or ""
190 + images = list(d.get("images") or [])
191 + thumb = (p.get("propertyThumb") or "").strip()
192 + if thumb and thumb not in images:
193 + images.insert(0, thumb)
194 + images = images[: self.max_images]
195 +
196 + common = dict(
197 + source=self.source_id, url=url, address=full_addr, sector=sector,
198 + city=city, province="ON", availability=availability,
199 + availability_date=avail_date, pets=pets, description=desc,
200 + amenities=amenities[:25], images=images, lat=lat, lng=lng,
201 + )
202 +
203 + # une annonce par plan d'étage (prix/chambres/sdb/pi² précis)
204 + out: list[Listing] = []
205 + for fp in d.get("floorplans") or []:
206 + price = fp.get("price")
207 + beds = fp.get("beds")
208 + out.append(Listing(
209 + external_id=f"{pid}-{fp['fpid']}",
210 + title=f"{name} — {fp['name']}" if fp.get("name") else name,
211 + unit_type=("Studio" if beds == 0 else normalize_unit_type(
212 + f"{int(beds)} chambres") if beds is not None else ""),
213 + bedrooms=beds,
214 + bathrooms=fp.get("baths"),
215 + price=price,
216 + price_label=(f"À partir de {price:.0f} $ /mois"
217 + if price is not None else ""),
218 + area_sqft=fp.get("sqft"),
219 + details=dict(details),
220 + **common,
221 + ))
222 + if out:
223 + return out
224 +
225 + # repli : une annonce par propriété (fourchettes du flux liste)
226 + price = None
227 + try:
228 + price = float(p.get("propertyMinRent") or 0) or None
229 + except (TypeError, ValueError):
230 + pass
231 + price_label = ""
232 + if price is not None:
233 + try:
234 + pmax = float(p.get("propertyMaxRent") or 0)
235 + except (TypeError, ValueError):
236 + pmax = 0.0
237 + price_label = (f"À partir de {price:.0f} $"
238 + if pmax and pmax != price else f"{price:.0f} $ /mois")
239 + beds = baths = None
240 + try:
241 + bmin, bmax = float(p.get("propertyMinBed") or -1), \
242 + float(p.get("propertyMaxBed") or -1)
243 + if bmin >= 0 and bmin == bmax:
244 + beds = bmin
245 + if float(p.get("propertyMinBath") or 0) > 0:
246 + baths = float(p["propertyMinBath"])
247 + except (TypeError, ValueError):
248 + pass
249 + area = None
250 + try:
251 + v = float(p.get("propertyMinArea") or 0)
252 + if 80 <= v <= 20000:
253 + area = v
254 + except (TypeError, ValueError):
255 + pass
256 + return [Listing(
257 + external_id=pid,
258 + title=name,
259 + unit_type=("Studio" if beds == 0 else normalize_unit_type(
260 + f"{int(beds)} chambres") if beds is not None else ""),
261 + bedrooms=beds,
262 + bathrooms=baths,
263 + price=price,
264 + price_label=price_label,
265 + area_sqft=area,
266 + details=details,
267 + **common,
268 + )]
269 +
270 + # -- fiche propriété : description + galerie + plans d'étage ----------------
271 + def _fetch_detail(self, url: str) -> dict:
272 + out: dict = {"description": "", "images": [], "floorplans": []}
273 + try:
274 + page = self.get(url).text
275 + except Exception:
276 + return out
277 + soup = BeautifulSoup(page, "html.parser")
278 +
279 + # plans d'étage : cartes serveur « Nom / N Bed / N Bath / N Sq. Ft. /
280 + # Starting at $X /Month » (modal #fp-modal-<id> = identifiant stable)
281 + fp_imgs: set[str] = set()
282 + for card in soup.select(".card"):
283 + txt = card.get_text(" ", strip=True)
284 + if "Bed" not in txt and "Studio" not in txt:
285 + continue
286 + ref = card.find(attrs={"data-target": _FPID_RE})
287 + mid = _FPID_RE.search(ref["data-target"]) if ref else None
288 + if not mid:
289 + continue
290 + h = card.find(["h2", "h3", "h4"])
291 + mb = _BED_RE.search(txt)
292 + beds = (float(mb.group(1)) if mb
293 + else 0.0 if "Studio" in txt else None)
294 + mba = _BATH_RE.search(txt)
295 + msq = _SQFT_RE.search(txt)
296 + sqft = float(msq.group(1).replace(",", "")) if msq else None
297 + self._append_fp(out, {
298 + "fpid": mid.group(1),
299 + "name": h.get_text(" ", strip=True) if h else "",
300 + "beds": beds,
301 + "baths": float(mba.group(1)) if mba else None,
302 + "sqft": sqft if sqft and 80 <= sqft <= 20000 else None,
303 + "price": _money(txt),
304 + })
305 + for img in card.find_all("img"):
306 + src = img.get("src") or img.get("data-src") or ""
307 + if src:
308 + fp_imgs.add(src)
309 +
310 + # galerie : visuels resource.rentcafe.com HORS logos et plans d'étage
311 + images: list[str] = []
312 + for img in soup.find_all("img"):
313 + src = (img.get("src") or img.get("data-src") or "").strip()
314 + if ("resource.rentcafe.com" not in src or src in fp_imgs
315 + or img.find_parent(class_=re.compile(r"ysi-logo"))):
316 + continue
317 + if not img.find_parent(class_=re.compile(r"ysi-picture")):
318 + continue
319 + # variante généreuse de la transformation Cloudinary
320 + src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?",
321 + "c_limit,w_1200", src)
322 + if src not in images:
323 + images.append(src)
324 + out["images"] = images[: self.max_images]
325 +
326 + # description : paragraphes de présentation (sans le boilerplate
327 + # consentement/cookies ni le disclaimer promo)
328 + paras = []
329 + for ptag in soup.find_all("p"):
330 + t = ptag.get_text(" ", strip=True)
331 + if len(t) < 150 or _BOILERPLATE_RE.search(t):
332 + continue
333 + t = _DISCLAIMER_RE.sub("", t).strip()
334 + if t and t not in paras:
335 + paras.append(t)
336 + out["description"] = " ".join(paras)[:900]
337 + return out
338 +
339 + @staticmethod
340 + def _append_fp(out: dict, fp: dict) -> None:
341 + """Ajoute un plan d'étage en dédoublonnant sur son identifiant."""
342 + if fp["fpid"] not in {f["fpid"] for f in out["floorplans"]}:
343 + out["floorplans"].append(fp)
added louka/connectors/liftsystem.py +307 −0
@@ -0,0 +1,307 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/liftsystem.py : connecteur GÉNÉRIQUE Rentsync/LiftSystem
5 +# Des dizaines de gestionnaires (surtout en Ontario) publient leur parc via
6 +# la même plateforme Rentsync « classique », dont le flux JSON officiel est
7 +# `https://api.theliftsystem.com/v2/search?client_id=<N>&auth_token=<token>`.
8 +# Le token est universel (embarqué dans le /scripts/main.js de chaque site) ;
9 +# seul le client_id change. Plutôt que N modules copiés-collés, ce module lit
10 +# le registre data/liftsystem_clients.json ({id, name, site, client_id…},
11 +# découverts et validés en live le 2026-08-26) et GÉNÈRE une sous-classe de
12 +# BaseConnector par client (source_id = "lift_<id>", ex. "lift_greenwin"),
13 +# déposée dans les globals du module pour que le registre auto-découvrant
14 +# (connectors/__init__.py) les enregistre toutes. Mapping identique au
15 +# connecteur canonique centurion.py (même plateforme, client_id 21) : adresse
16 +# complète, geocode, prix min « À partir de », galerie assets.rentsync.com
17 +# via le cache BD self.detail() (revisitée quand la ligne du flux change).
18 +# Différence : les flux sont pancanadiens — on ne garde que ON et QC, et le
19 +# champ Listing.province est renseigné selon la propriété.
20 +#
21 +# Expansion Ontario — activer via LOUKA_ONTARIO=1
22 +# (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les
23 +# classes générées sont `disabled` et donc exclues du registre / de la prod.
24 +# -----------------------------------------------------------------------------
25 +from __future__ import annotations
26 +
27 +import hashlib
28 +import json
29 +import os
30 +import re
31 +from pathlib import Path
32 +
33 +from bs4 import BeautifulSoup
34 +
35 +from ..schema import Listing, infer_city, normalize_unit_type
36 +from .base import BaseConnector
37 +
38 +# Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=1
39 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
40 +
41 +REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \
42 + "liftsystem_clients.json"
43 +LIFT_API = "https://api.theliftsystem.com/v2/search"
44 +
45 +# Jeton universel Rentsync/LiftSystem — le même pour tous les sites clients
46 +# (vérifié dans le main.js de cpliving, parkproperty, williamsandmcdaniel,
47 +# yorkproperty… et validé sur les 13 client_id du registre le 2026-08-26).
48 +# Une entrée du registre peut le surcharger via sa clé "auth_token".
49 +DEFAULT_AUTH_TOKEN = "sswpREkUtyeYjeoahA2i"
50 +
51 +# Provinces couvertes par Lou-Ka — plusieurs clients sont pancanadiens
52 +# (Shelter Canadian : MB/AB/SK…) : tout le reste est ignoré au fetch.
53 +_PROVINCES = {"ON", "QC"}
54 +
55 +# Types de propriété NON résidentiels du flux (property_type, chaîne libre) —
56 +# plusieurs clients mélangent bureaux/commerces (tarifs au pi², ex. « 18 $ »)
57 +# et chantiers sans unités : hors sujet pour Lou-Ka (logements au mois).
58 +_NON_RESIDENTIAL = {
59 + "office", "retail", "warehouse", "industrial", "commercial", "land",
60 + "construction", "motel", "hotel", "parking", "storage",
61 +}
62 +
63 +# galerie de la fiche propriété (img + backgrounds CSS), comme centurion.py
64 +_IMG_RE = re.compile(
65 + r"https://assets\.rentsync\.com/[^\"'\\)\s]+\.(?:jpg|jpeg|png|webp)", re.I)
66 +_SKIP_IMG = re.compile(r"logo|icon|favicon|badge|/thumb", re.I)
67 +
68 +
69 +def _load_registry() -> list[dict]:
70 + """Entrées validées du registre (client_id présent, status != a_verifier)."""
71 + try:
72 + data = json.loads(REGISTRY_PATH.read_text("utf-8"))
73 + except (OSError, ValueError):
74 + return []
75 + return [c for c in data.get("clients") or []
76 + if c.get("client_id") and c.get("status") == "valide"]
77 +
78 +
79 +class LiftSystemConnector(BaseConnector):
80 + """Base commune des connecteurs LiftSystem générés — non enregistrée
81 + elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de
82 + registre dans l'attribut de classe `client`."""
83 +
84 + source_id = "" # les sous-classes générées le définissent
85 + client: dict = {} # entrée du registre (name, site, client_id…)
86 + request_delay = 0.7 # politesse — même rythme que centurion.py
87 + max_properties = 400 # garde-fou (plus gros client : ~204 propriétés)
88 + max_images = 20
89 +
90 + def fetch(self) -> list[Listing]:
91 + props = self.get(LIFT_API, params={
92 + "client_id": str(self.client["client_id"]),
93 + "auth_token": self.client.get("auth_token") or DEFAULT_AUTH_TOKEN,
94 + "show_all_properties": "true",
95 + "show_custom_fields": "true",
96 + "show_amenities": "true",
97 + "show_promotions": "true",
98 + "limit": "1000",
99 + }, headers={"Accept": "application/json",
100 + "Referer": (self.client.get("site") or "") + "/"}).json()
101 +
102 + listings: list[Listing] = []
103 + for p in props:
104 + if len(listings) >= self.max_properties: # garde-fou APRÈS filtres
105 + break
106 + try:
107 + addr = p.get("address") or {}
108 + if (addr.get("province_code") or "").upper() not in _PROVINCES:
109 + continue # flux pancanadiens : ON et QC seulement
110 + ptype = str(p.get("property_type") or "").strip().lower()
111 + if ptype in _NON_RESIDENTIAL:
112 + continue # bureaux/commerces/chantiers : hors sujet
113 + listings.append(self._listing(p))
114 + except Exception:
115 + continue
116 + return listings
117 +
118 + # -- une annonce par propriété (mapping aligné sur centurion.py) -----------
119 + def _listing(self, p: dict) -> Listing:
120 + pid = str(p.get("id"))
121 + addr = p.get("address") or {}
122 + url = p.get("permalink") or self.client.get("listing_url") or \
123 + self.client.get("site") or ""
124 + # certains clients publient des permaliens cassés (ex. Shelter :
125 + # « /images/<slug> » → 404, la vraie fiche est « /residential-rental/ »)
126 + # — correctif déclaré au registre : "permalink_sub": [avant, après]
127 + sub = self.client.get("permalink_sub") or []
128 + if len(sub) == 2 and sub[0] in url:
129 + url = url.replace(sub[0], sub[1], 1)
130 + name = (p.get("name") or "").strip()
131 + prov = (addr.get("province_code") or "").upper()
132 +
133 + # adresse complète : rue + ville + « , ON/QC <postal> » selon le flux
134 + city = re.sub(r"\s+(?:ON|QC)$", "",
135 + (addr.get("city") or "").strip(), flags=re.I)
136 + street = (addr.get("address") or "").strip()
137 + postal = (addr.get("postal_code") or "").strip()
138 + full_addr = ", ".join(x for x in (street, city) if x)
139 + if full_addr:
140 + full_addr += f", {prov} {postal}".rstrip()
141 + sector = (addr.get("neighbourhood") or "").strip()
142 +
143 + # coordonnées GPS structurées du flux
144 + geo = p.get("geocode") or {}
145 + try:
146 + lat = float(geo["latitude"]) if geo.get("latitude") else None
147 + lng = float(geo["longitude"]) if geo.get("longitude") else None
148 + except (TypeError, ValueError):
149 + lat = lng = None
150 +
151 + # sommaire des unités disponibles (rempli seulement s'il y a vacance)
152 + stats = ((p.get("statistics") or {}).get("suites") or {})
153 + rates = stats.get("rates") or {}
154 + beds = stats.get("bedrooms") or {}
155 + baths = stats.get("bathrooms") or {}
156 + sqft = stats.get("square_feet") or {}
157 + price = float(rates["min"]) if rates.get("min") else None
158 + price_label = ""
159 + if price is not None:
160 + price_label = (f"À partir de {price:.0f} $"
161 + if rates.get("max") and rates["max"] != rates["min"]
162 + else f"{price:.0f} $ /mois")
163 + # type d'unité : seulement si la gamme est sans ambiguïté
164 + unit_type = ""
165 + if beds.get("min") is not None and beds.get("min") == beds.get("max"):
166 + n = int(beds["min"])
167 + unit_type = "Studio" if n == 0 else normalize_unit_type(
168 + f"{n} chambres")
169 + # superficie : le flux publie parfois « 0.0 » — ignorer
170 + area = None
171 + try:
172 + v = float(sqft.get("min") or 0)
173 + if 80 <= v <= 20000:
174 + area = v
175 + except (TypeError, ValueError):
176 + pass
177 +
178 + # disponibilité : libellé du flux (« No Vacancy », « X Vacancies »…)
179 + availability = (p.get("availability_status_label") or "").strip()
180 + avail_date = None
181 + mad = str(p.get("min_availability_date") or "").strip()
182 + if re.fullmatch(r"20\d{2}-\d{2}-\d{2}", mad[:10]):
183 + avail_date = mad[:10]
184 +
185 + # description : aperçu HTML du flux (rendu texte)
186 + details_src = p.get("details") or {}
187 + desc = BeautifulSoup(details_src.get("overview") or "",
188 + "html.parser").get_text(" ", strip=True)
189 + promo = p.get("promotion") or {}
190 + promo_txt = (promo.get("title") or promo.get("name") or "").strip() \
191 + if isinstance(promo, dict) else ""
192 + if promo_txt:
193 + desc = f"Promotion : {promo_txt}. {desc}".strip()
194 +
195 + # commodités : liste du flux + champ personnalisé Rentsync (CSV)
196 + amenities: list[str] = []
197 + for a in p.get("amenities") or []:
198 + t = (a.get("name") if isinstance(a, dict) else str(a) or "").strip()
199 + if t and t not in amenities:
200 + amenities.append(t)
201 + cf = p.get("custom_fields") or {}
202 + for t in (cf.get("amenities") or "").split(","):
203 + t = t.strip()
204 + if t and t not in amenities:
205 + amenities.append(t)
206 +
207 + # champs structurés du flux
208 + details: dict = {}
209 + contact = p.get("contact") or {}
210 + if contact.get("phone"):
211 + details["contact"] = {"phone": contact["phone"]}
212 + if contact.get("email"):
213 + details.setdefault("contact", {})["email"] = contact["email"]
214 + # pet_friendly=false ne distingue pas « interdit » de « non renseigné »
215 + pets = "oui" if p.get("pet_friendly") is True else None
216 +
217 + # galerie photo de la fiche propriété — via le cache BD : revisitée
218 + # seulement quand la ligne du flux change
219 + feed_key = hashlib.sha1("|".join(str(x) for x in (
220 + p.get("availability_count"), p.get("availability_status"),
221 + rates.get("min"), rates.get("max"), mad, p.get("photo"),
222 + )).encode("utf-8")).hexdigest()
223 + d = self.detail(pid, feed_key, lambda: self._fetch_gallery(url))
224 + images = list(d.get("images") or [])
225 + photo = (p.get("photo_path") or "").strip()
226 + if photo and photo not in images:
227 + images.insert(0, photo)
228 +
229 + # infer_city (secteur -> Lévis…) n'a de sens que côté Québec
230 + final_city = infer_city(sector, default=city) if prov == "QC" else city
231 +
232 + return Listing(
233 + source=self.source_id,
234 + external_id=pid,
235 + url=url,
236 + title=name,
237 + address=full_addr,
238 + sector=sector,
239 + city=final_city,
240 + province=prov,
241 + unit_type=unit_type,
242 + price=price,
243 + price_label=price_label,
244 + availability=availability,
245 + availability_date=avail_date,
246 + area_sqft=area,
247 + pets=pets,
248 + description=desc[:600] + (
249 + f" Salles de bain : {baths['min']:g}+."
250 + if baths.get("min") else ""),
251 + amenities=amenities[:25],
252 + details=details,
253 + images=images[: self.max_images],
254 + lat=lat,
255 + lng=lng,
256 + )
257 +
258 + def _fetch_gallery(self, url: str) -> dict:
259 + """Scrape la galerie photo (assets.rentsync.com) de la fiche propriété."""
260 + out: dict = {"images": []}
261 + if not url:
262 + return out
263 + try:
264 + page = self.get(url).text
265 + except Exception:
266 + return out
267 + images: list[str] = []
268 + for u in _IMG_RE.findall(page):
269 + if _SKIP_IMG.search(u):
270 + continue
271 + # variante pleine résolution de la galerie (…/gallery/full/…)
272 + u = re.sub(r"/gallery/\d{3,4}/", "/gallery/full/", u)
273 + if u not in images:
274 + images.append(u)
275 + out["images"] = images[: self.max_images]
276 + return out
277 +
278 +
279 +# -----------------------------------------------------------------------------
280 +# Génération : une sous-classe par client du registre, déposée dans les globals
281 +# du module — connectors/__init__.py (scan de vars(module)) les découvre alors
282 +# comme n'importe quel connecteur écrit à la main.
283 +# -----------------------------------------------------------------------------
284 +def _make_connector(entry: dict) -> type[LiftSystemConnector]:
285 + cls = type(
286 + f"Lift{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",
287 + (LiftSystemConnector,),
288 + {
289 + "source_id": f"lift_{entry['id']}",
290 + "client": entry,
291 + # Expansion Ontario — activer via LOUKA_ONTARIO=1
292 + # (voir gestion-immobiliere-ontario.md)
293 + "disabled": not _ONTARIO,
294 + "__doc__": f"Connecteur LiftSystem généré — {entry.get('name')} "
295 + f"(client_id {entry.get('client_id')}).",
296 + },
297 + )
298 + return cls
299 +
300 +
301 +def _register_all() -> None:
302 + for entry in _load_registry():
303 + cls = _make_connector(entry)
304 + globals()[cls.__name__] = cls
305 +
306 +
307 +_register_all()
modified louka/connectors/metcap.py +61 −11
@@ -12,10 +12,16 @@
12 12 # photos d'unité ; les fiches /property/... la galerie photo de l'immeuble.
13 13 # Fiches visitées via self.detail(...) (cache BD). Gestionnaire pancanadien :
14 14 # seules les villes du Grand Montréal sont couvertes.
15 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,
16 +# la page /province/ontario (province=113) est aussi balayée pour les villes
17 +# Toronto/GTA + Barrie (province="ON", adresse suffixée « ON », compteur
18 +# d'unités séparé — le parc QC n'est jamais rogné). Sans la variable,
19 +# comportement strictement identique (QC seulement).
15 20 # -----------------------------------------------------------------------------
16 21 from __future__ import annotations
17 22
18 23 import hashlib
24 +import os
19 25 import re
20 26 import urllib.parse
21 27
@@ -24,8 +30,12 @@ from bs4 import BeautifulSoup
24 30 from ..schema import Listing, parse_price, strip_accents
25 31 from .base import BaseConnector
26 32
33 +# Expansion Ontario 2026-08 — gate d'environnement (défaut : QC seulement)
34 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
35 +
27 36 BASE = "https://www.metcap.com"
28 37 QC_PROVINCE_URL = f"{BASE}/province/quebec?lang=en"
38 +ON_PROVINCE_URL = f"{BASE}/province/ontario?lang=en"
29 39
30 40 # Villes admissibles (Grand Montréal), clés sans accents/minuscules
31 41 _GM_CITIES = {
@@ -43,6 +53,22 @@ _GM_CITIES = {
43 53 "dorval": ("Dorval", ""),
44 54 }
45 55
56 +# Villes admissibles Ontario (Toronto/GTA + Barrie) — expansion 2026-08,
57 +# consommées seulement sous LOUKA_ONTARIO=1. Clés sans accents/minuscules.
58 +_ON_CITIES = {
59 + "toronto": ("Toronto", ""),
60 + "north york": ("Toronto", "North York"),
61 + "scarborough": ("Toronto", "Scarborough"),
62 + "etobicoke": ("Toronto", "Etobicoke"),
63 + "east york": ("Toronto", "East York"),
64 + "thornhill": ("Thornhill", ""),
65 + "mississauga": ("Mississauga", ""),
66 + "brampton": ("Brampton", ""),
67 + "burlington": ("Burlington", ""),
68 + "oshawa": ("Oshawa", ""),
69 + "barrie": ("Barrie", ""),
70 +}
71 +
46 72 _TYPE_MAP = [
47 73 (re.compile(r"bachelor|studio", re.I), "Studio"),
48 74 (re.compile(r"1\s*bed", re.I), "3½"),
@@ -80,29 +106,47 @@ class MetcapConnector(BaseConnector):
80 106
81 107 def fetch(self) -> list[Listing]:
82 108 self._real_details = 0
83 − html = self.get(QC_PROVINCE_URL).text
84 − # Liens de villes QC : /province-search-results?...province=115&city=X
109 + listings: list[Listing] = []
110 + # Québec (comportement historique, inchangé)
111 + self._scan_province(QC_PROVINCE_URL, "115", _GM_CITIES, "QC",
112 + listings)
113 + # Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO (compteur d'unités
114 + # séparé : le parc QC garde tout son budget max_units)
115 + if _ONTARIO:
116 + try:
117 + self._scan_province(ON_PROVINCE_URL, "113", _ON_CITIES, "ON",
118 + listings)
119 + except Exception:
120 + pass # l'échec ON ne doit jamais faire tomber la synchro QC
121 + return listings
122 +
123 + def _scan_province(self, province_url: str, province_id: str,
124 + cities_map: dict, province: str,
125 + listings: list[Listing]) -> None:
126 + """Balaye une page province (liens de villes -> pages résultats)."""
127 + html = self.get(province_url).text
128 + # Liens de villes : /province-search-results?...province=<id>&city=X
85 129 cities = []
86 130 for href in re.findall(r'href="(/province-search-results\?[^"]+)"',
87 131 html):
88 132 q = urllib.parse.parse_qs(urllib.parse.urlparse(
89 133 href.replace("&amp;", "&")).query)
90 − if (q.get("province") or [""])[0] != "115":
134 + if (q.get("province") or [""])[0] != province_id:
91 135 continue
92 136 city = (q.get("city") or [""])[0]
93 137 if city and city not in cities:
94 138 cities.append(city)
95 139
96 − listings: list[Listing] = []
97 140 count = 0
98 141 for city_name in cities:
99 142 key = strip_accents(city_name.lower()).replace(".", "").strip()
100 − if key not in _GM_CITIES:
101 − continue # hors Grand Montréal (garde REIT pancanadien)
102 − city, sector = _GM_CITIES[key]
143 + if key not in cities_map:
144 + continue # hors région couverte (garde REIT pancanadien)
145 + city, sector = cities_map[key]
103 146 try:
104 147 page = self.get(
105 − f"{BASE}/province-search-results?lang=en&province=115"
148 + f"{BASE}/province-search-results?lang=en"
149 + f"&province={province_id}"
106 150 f"&city={urllib.parse.quote(city_name)}").text
107 151 except Exception:
108 152 continue
@@ -133,12 +177,12 @@ class MetcapConnector(BaseConnector):
133 177 text = a.get_text(" ", strip=True)
134 178 lst = self._unit_listing(
135 179 a.get("href", ""), text, address, prop_name,
136 − prop_path, city, sector, lat, lng)
180 + prop_path, city, sector, lat, lng,
181 + province=province)
137 182 if lst:
138 183 listings.append(lst)
139 184 except Exception:
140 185 continue
141 − return listings
142 186
143 187 # -- pages détail (via cache BD self.detail) -------------------------------
144 188 def _gallery(self, prop_path: str) -> list[str]:
@@ -239,7 +283,8 @@ class MetcapConnector(BaseConnector):
239 283 # -- construction d'une annonce --------------------------------------------
240 284 def _unit_listing(self, href: str, card_text: str, address: str,
241 285 prop_name: str, prop_path: str, city: str, sector: str,
242 − lat: float | None, lng: float | None) -> Listing | None:
286 + lat: float | None, lng: float | None,
287 + province: str = "QC") -> Listing | None:
243 288 path = href.split("?")[0]
244 289 slug = path.rstrip("/").split("/")[-1]
245 290 if not slug:
@@ -324,12 +369,17 @@ class MetcapConnector(BaseConnector):
324 369 title_type = re.sub(r"\s*from \$[\d,.].*$", "", card_text).strip()
325 370 title = (f"{prop_name} — {title_type}" if prop_name
326 371 else f"{address} — {title_type}")
372 + # Ontario : adresse suffixée « ON » (standard expansion 2026-08)
373 + if province == "ON" and address \
374 + and not re.search(r",\s*ON\b", address):
375 + address = f"{address}, ON"
327 376 return Listing(
328 377 source=self.source_id,
329 378 external_id=slug,
330 379 url=url,
331 380 title=title,
332 381 address=address,
382 + province=province,
333 383 sector=sector,
334 384 city=city,
335 385 unit_type=unit_type,
modified louka/connectors/minto.py +84 −8
@@ -11,9 +11,17 @@
11 11 # complète, le quartier (« Neighbourhood: ») et le téléphone/courriel ;
12 12 # les sections Features listent les commodités (items à coche).
13 13 # Une annonce par type de suite disponible.
14 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO=1 : Minto est d'abord
15 +# ottavien ; on ajoute les pages « projects » d'Ottawa
16 +# (/ottawa/apartment-rentals/…) et de la GTA
17 +# (/gta/<Secteur>-apartment-rentals/… : Toronto, Etobicoke, North York,
18 +# Mississauga, Oakville). Même structure de fiches (cartes
19 +# .projects-apartamets-unit-card). Sans la variable d'environnement,
20 +# comportement strictement identique (prod QC).
14 21 # -----------------------------------------------------------------------------
15 22 from __future__ import annotations
16 23
24 +import os
17 25 import re
18 26
19 27 from bs4 import BeautifulSoup
@@ -42,11 +50,37 @@ _KNOWN_SECTORS = [
42 50 _CITY_SECTORS = {"westmount": "Westmount", "mont-royal": "Mont-Royal",
43 51 "cote-saint-luc": "Côte-Saint-Luc"}
44 52
53 +# --- Expansion Ontario (gaté LOUKA_ONTARIO=1 ; prod QC intacte sans) ---------
54 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
55 +
56 +LIST_URLS_ON = [
57 + f"{BASE}/ottawa/apartment-rentals/projects.html",
58 + f"{BASE}/gta/apartment-rentals/projects.html",
59 +]
60 +# /<région>/<sous-chemin>/<slug>/main.html — le sous-chemin GTA porte le
61 +# secteur (ex. North-York-apartment-rentals), celui d'Ottawa est simplement
62 +# apartment-rentals
63 +PROJECT_ON_RE = re.compile(
64 + r'https?://www\.mintoapartments\.com/(ottawa|gta)/'
65 + r'([A-Za-z0-9\-]+)/([A-Za-z0-9\-]+)/main\.html')
66 +
67 +# secteur d'URL GTA (minuscule, sans -apartment-rentals) -> (ville, secteur)
68 +_ON_AREAS = {
69 + "ottawa": ("Ottawa", ""),
70 + "toronto": ("Toronto", ""),
71 + "downtown-toronto": ("Toronto", "Downtown"),
72 + "etobicoke": ("Toronto", "Etobicoke"),
73 + "north-york": ("Toronto", "North York"),
74 + "mississauga": ("Mississauga", ""),
75 + "oakville": ("Oakville", ""),
76 +}
77 +
45 78
46 79 class MintoConnector(BaseConnector):
47 80 source_id = "minto"
48 81 request_delay = 0.6
49 − max_projects = 12 # garde-fou
82 + max_projects = 12 # garde-fou (QC)
83 + max_projects_on = 40 # garde-fou distinct pour l'Ontario (gaté)
50 84 max_images = 25
51 85
52 86 def fetch(self) -> list[Listing]:
@@ -59,10 +93,41 @@ class MintoConnector(BaseConnector):
59 93 listings.extend(self._project_listings(slug))
60 94 except Exception:
61 95 continue
96 +
97 + # Expansion Ontario (gaté LOUKA_ONTARIO=1) : Ottawa + GTA, budget de
98 + # projets distinct pour ne rien enlever à la couverture QC
99 + if _ONTARIO:
100 + n_on = 0
101 + for list_url in LIST_URLS_ON:
102 + try:
103 + on_html = self.get(list_url).text
104 + except Exception:
105 + continue
106 + for region, mid, slug in dict.fromkeys(
107 + PROJECT_ON_RE.findall(on_html)):
108 + # exclure les meublés courte durée (furnished-apartments)
109 + if not mid.endswith("apartment-rentals"):
110 + continue
111 + if n_on >= self.max_projects_on:
112 + break
113 + n_on += 1
114 + area = (re.sub(r"-?apartment-rentals$", "", mid)
115 + .strip("-").lower() or region)
116 + city, sector = _ON_AREAS.get(
117 + area, (area.replace("-", " ").title(), ""))
118 + try:
119 + listings.extend(self._project_listings(
120 + slug, region=region, subpath=mid,
121 + on_city=city, on_sector=sector, province="ON"))
122 + except Exception:
123 + continue
62 124 return listings
63 125
64 − def _project_listings(self, slug: str) -> list[Listing]:
65 − url = f"{BASE}/montreal/apartment-rentals/{slug}/main.html"
126 + def _project_listings(self, slug: str, region: str = "montreal",
127 + subpath: str = "apartment-rentals",
128 + on_city: str = "", on_sector: str = "",
129 + province: str = "QC") -> list[Listing]:
130 + url = f"{BASE}/{region}/{subpath}/{slug}/main.html"
66 131 html = self.get(url).text
67 132 soup = BeautifulSoup(html, "html.parser")
68 133
@@ -79,12 +144,18 @@ class MintoConnector(BaseConnector):
79 144 nm = re.search(r"Neighbourhood:\s*([^<\n]{2,50})", html)
80 145 if nm:
81 146 sector = nm.group(1).strip()
147 + if province == "ON":
148 + # ville/secteur dérivés de l'URL de la page « projects » (GTA) ;
149 + # le « Neighbourhood: » de la fiche a priorité s'il est publié
150 + city = on_city
151 + sector = sector or on_sector
82 152 else:
83 − for s in _KNOWN_SECTORS:
84 − if s.lower() in title_text.lower():
85 − sector = "Centre-ville" if s == "Downtown" else s
86 − break
87 − city = _CITY_SECTORS.get(strip_accents(sector.lower()), "Montréal")
153 + if not sector:
154 + for s in _KNOWN_SECTORS:
155 + if s.lower() in title_text.lower():
156 + sector = "Centre-ville" if s == "Downtown" else s
157 + break
158 + city = _CITY_SECTORS.get(strip_accents(sector.lower()), "Montréal")
88 159 if sector == city:
89 160 sector = ""
90 161
@@ -101,6 +172,10 @@ class MintoConnector(BaseConnector):
101 172 r'[^<>"{}]{3,60}', html, re.I)
102 173 if am:
103 174 address = re.sub(r"\s+", " ", am.group(0)).strip().rstrip(",")
175 + if province == "ON" and address:
176 + address = re.sub(r",\s*,", ",", address) # « Drive,, Etobicoke »
177 + if not re.search(r"\bON\b", address):
178 + address = f"{address}, ON"
104 179
105 180 # Contact location (téléphone / courriel) -> details.contact
106 181 details: dict = {}
@@ -195,6 +270,7 @@ class MintoConnector(BaseConnector):
195 270 address=address,
196 271 sector=sector,
197 272 city=city,
273 + province=province,
198 274 unit_type=normalize_unit_type(suite_name),
199 275 price=price,
200 276 price_label=f"{price_label} /mois",
modified louka/connectors/morguard.py +43 −6
@@ -9,10 +9,17 @@
9 9 # (code postal H/J/G) : 15 immeubles — centre-ville de Montréal (Maisonneuve,
10 10 # Crescent, Mackay, des Pins…), Dollard-des-Ormeaux, Côte-Saint-Luc et
11 11 # Pointe-Claire. Une annonce par unité disponible (available == 1).
12 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,
13 +# les immeubles ON des marchés cibles (Toronto/GTA, Ottawa, Hamilton, KW)
14 +# sont aussi conservés via leur cityId Rentsync (le gateway n'expose pas de
15 +# nom de ville ni de province — mapping cityId->ville validé par FSA postal ;
16 +# London/Windsor hors marchés cibles exclus). province="ON", adresse « ON ».
17 +# Sans la variable, comportement strictement identique (QC seulement).
12 18 # -----------------------------------------------------------------------------
13 19 from __future__ import annotations
14 20
15 21 import html as htmllib
22 +import os
16 23 import re
17 24
18 25 from ..schema import Listing
@@ -22,6 +29,9 @@ SITE = "https://www.morguardliving.ca"
22 29 API = "https://website-gateway.rentsync.com/v1/morguard_invest"
23 30 IMG_BASE = "https://s3.amazonaws.com/lws_lift/morguard_invest/images/gallery/1152"
24 31
32 +# Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, QC seulement
33 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
34 +
25 35 # cityId Rentsync -> ville (le gateway n'expose pas les noms de villes)
26 36 _CITIES = {
27 37 1863: "Montréal",
@@ -30,6 +40,23 @@ _CITIES = {
30 40 2213: "Pointe-Claire",
31 41 }
32 42
43 +# cityId Rentsync -> ville Ontario (expansion 2026-08, sous LOUKA_ONTARIO).
44 +# Marchés cibles : Toronto/GTA, Ottawa, Hamilton, Kitchener-Waterloo.
45 +# Mapping validé par FSA des codes postaux du gateway (2026-08-26) ;
46 +# 1607 (London, N6A) et 3370 (Windsor, N8Y) volontairement exclus.
47 +_CITIES_ON = {
48 + 3133: "Toronto", # M6K (Parkdale…)
49 + 2015: "Toronto", # M3A (North York)
50 + 1837: "Mississauga", # L4X
51 + 329: "Brampton", # L6W
52 + 2042: "Oakville", # L6K
53 + 387: "Burlington", # L7T
54 + 1174: "Hamilton", # L8S
55 + 2084: "Ottawa", # K1V…
56 + 1425: "Kitchener", # N2C
57 + 3284: "Waterloo", # N2L
58 +}
59 +
33 60 # Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres)
34 61 _BED_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"}
35 62
@@ -46,16 +73,20 @@ def _clean(txt: str) -> str:
46 73 class MorguardConnector(BaseConnector):
47 74 source_id = "morguard"
48 75 request_delay = 0.6
49 − max_units = 300 # garde-fou (84 unités QC dispo à l'écriture)
76 + # garde-fou (84 unités QC dispo à l'écriture ; plafond relevé avec l'ON)
77 + max_units = 900 if _ONTARIO else 300
50 78
51 79 def fetch(self) -> list[Listing]:
52 80 props = (self.get(f"{API}/properties", params={"limit": "300"})
53 81 .json().get("data") or [])
54 − # Québec seulement : codes postaux H (Mtl), J (couronnes), G (est)
82 + # Québec : codes postaux H (Mtl), J (couronnes), G (est) ;
83 + # Ontario (sous LOUKA_ONTARIO) : cityId des marchés cibles.
55 84 buildings: dict[int, dict] = {}
56 85 for p in props:
57 86 postal = (p.get("postal") or "").strip().upper()
58 − if postal[:1] not in ("H", "J", "G"):
87 + is_qc = postal[:1] in ("H", "J", "G")
88 + is_on = _ONTARIO and int(p.get("cityId") or 0) in _CITIES_ON
89 + if not (is_qc or is_on):
59 90 continue
60 91 if (p.get("status") or "") != "enabled":
61 92 continue
@@ -66,7 +97,8 @@ class MorguardConnector(BaseConnector):
66 97 ids = "|".join(str(i) for i in sorted(buildings))
67 98 units = (self.get(f"{API}/units", params={
68 99 "where": f"buildingId~in:{ids},status~in:enabled",
69 − "limit": "500",
100 + # ON inclus : ~6x plus d'immeubles, plafond relevé en conséquence
101 + "limit": "1500" if _ONTARIO else "500",
70 102 }).json().get("data") or [])
71 103
72 104 listings: list[Listing] = []
@@ -114,9 +146,13 @@ class MorguardConnector(BaseConnector):
114 146 name = _clean(b.get("buildingName") or "")
115 147 street = _clean(f"{b.get('streetNumber') or ''} "
116 148 f"{b.get('streetName') or ''}")
117 − city = _CITIES.get(int(b.get("cityId") or 0), "Montréal")
149 + cid = int(b.get("cityId") or 0)
150 + if _ONTARIO and cid in _CITIES_ON:
151 + prov, city = "ON", _CITIES_ON[cid]
152 + else:
153 + prov, city = "QC", _CITIES.get(cid, "Montréal")
118 154 postal = (b.get("postal") or "").strip().upper()
119 − address = f"{street}, {city}, QC {postal}".strip(" ,")
155 + address = f"{street}, {city}, {prov} {postal}".strip(" ,")
120 156 sector = _clean(b.get("neighbourhood") or "")
121 157
122 158 try:
@@ -187,6 +223,7 @@ class MorguardConnector(BaseConnector):
187 223 address=address,
188 224 sector=sector,
189 225 city=city,
226 + province=prov,
190 227 unit_type=unit_type,
191 228 bedrooms=float(bed) if bed is not None else None,
192 229 bathrooms=bath,
modified louka/connectors/realstar.py +117 −6
@@ -13,6 +13,12 @@
13 13 # Une annonce par propriété (uid stables). Les pages détail passent par
14 14 # self.detail(...) (cache BD) : Firecrawl n'est rappelé que si la carte
15 15 # liste a changé.
16 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1,
17 +# un rendu supplémentaire de ?province=Ontario alimente les cartes
18 +# /apartments/on/ des 7 régions couvertes (Toronto/GTA, Ottawa, Hamilton,
19 +# Kitchener-Waterloo, London, Cobourg), avec plafonds propriétés/rendus
20 +# SÉPARÉS (le budget QC n'est jamais entamé). Sans la variable,
21 +# comportement strictement identique (QC seulement).
16 22 # -----------------------------------------------------------------------------
17 23 from __future__ import annotations
18 24
@@ -25,7 +31,11 @@ from bs4 import BeautifulSoup
25 31 from ..schema import Listing, parse_price
26 32 from .base import FIRECRAWL_API, BaseConnector
27 33
34 +# Expansion Ontario 2026-08 — gate d'environnement (défaut : QC seulement)
35 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
36 +
28 37 SEARCH_URL = "https://www.realstar.ca/searchlisting?province=Quebec"
38 +ON_SEARCH_URL = "https://www.realstar.ca/searchlisting?province=Ontario"
29 39
30 40 # Ville du chemin /apartments/qc/<ville>/<slug> -> (ville affichée, secteur)
31 41 _GM_CITIES = {
@@ -43,6 +53,32 @@ _GM_CITIES = {
43 53 "sherbrooke": ("Sherbrooke", ""),
44 54 }
45 55
56 +# Ville du chemin /apartments/on/<ville>/<slug> -> (ville affichée, secteur).
57 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : 7 régions (Toronto/GTA,
58 +# Ottawa, Hamilton, Kitchener-Waterloo, London, Cobourg).
59 +_ON_CITIES = {
60 + "toronto": ("Toronto", ""),
61 + "north-york": ("Toronto", "North York"),
62 + "etobicoke": ("Toronto", "Etobicoke"),
63 + "scarborough": ("Toronto", "Scarborough"),
64 + "mississauga": ("Mississauga", ""),
65 + "brampton": ("Brampton", ""),
66 + "oakville": ("Oakville", ""),
67 + "milton": ("Milton", ""),
68 + "burlington": ("Burlington", ""),
69 + "aurora": ("Aurora", ""),
70 + "newmarket": ("Newmarket", ""),
71 + "whitby": ("Whitby", ""),
72 + "oshawa": ("Oshawa", ""),
73 + "ottawa": ("Ottawa", ""),
74 + "hamilton": ("Hamilton", ""),
75 + "kitchener": ("Kitchener", ""),
76 + "waterloo": ("Waterloo", ""),
77 + "cambridge": ("Cambridge", ""),
78 + "london": ("London", ""),
79 + "cobourg": ("Cobourg", ""),
80 +}
81 +
46 82 _BED_TYPES = {"0": "Studio", "1": "3½", "2": "4½", "3": "5½", "4": "6½"}
47 83 _SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder", re.I)
48 84 _PRICE_RE = re.compile(r"\$[\d,]+(?:\.\d{2})?")
@@ -58,6 +94,10 @@ class RealstarConnector(BaseConnector):
58 94 max_properties = 12 # garde-fou (2 appels Firecrawl par propriété)
59 95 max_images = 25
60 96 max_renders = 20 # plafond d'appels Firecrawl par sync (hors cache)
97 + # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO) : plafonds séparés pour
98 + # ne jamais entamer le budget QC
99 + max_properties_on = 12
100 + max_renders_on = 24
61 101
62 102 # -- Firecrawl avec attente de rendu (SPA + Cloudflare) -------------------
63 103 def _rendered(self, url: str, wait_ms: int = 9000) -> str:
@@ -109,11 +149,60 @@ class RealstarConnector(BaseConnector):
109 149 self._property_listing(card, url, city_slug, slug))
110 150 except Exception:
111 151 continue
152 +
153 + # Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO (rendu et budgets
154 + # séparés : l'échec ON ne doit jamais faire tomber la synchro QC)
155 + if _ONTARIO:
156 + try:
157 + listings.extend(self._fetch_ontario(seen))
158 + except Exception:
159 + pass
160 + return listings
161 +
162 + def _fetch_ontario(self, seen: set[str]) -> list[Listing]:
163 + """Cartes /apartments/on/ de la recherche Ontario (mêmes gabarits
164 + RentCafe que le QC), plafonds propriétés/rendus dédiés."""
165 + # budget de rendus dédié à l'Ontario (le QC a déjà consommé le sien)
166 + self.max_renders = self._renders + self.max_renders_on
167 + html = self._rendered(ON_SEARCH_URL, 12000)
168 + soup = BeautifulSoup(html, "html.parser")
169 + cards = soup.select("li.property-box")
170 + if not cards: # rendu incomplet : une seconde chance
171 + html = self._rendered(ON_SEARCH_URL, 15000)
172 + soup = BeautifulSoup(html, "html.parser")
173 + cards = soup.select("li.property-box")
174 +
175 + listings: list[Listing] = []
176 + count = 0
177 + for card in cards:
178 + try:
179 + a = card.select_one("a[href*='/apartments/on/']")
180 + if not a:
181 + continue # autre province
182 + url = (a.get("href") or "").split("?")[0]
183 + url = url.replace("http://", "https://")
184 + m = re.search(r"/apartments/on/([a-z0-9\-.]+)/([a-z0-9\-]+)",
185 + url)
186 + if not m or url in seen:
187 + continue
188 + seen.add(url)
189 + city_slug, slug = m.group(1), m.group(2)
190 + if city_slug not in _ON_CITIES:
191 + continue # ville ON hors des 7 régions couvertes
192 + if count >= self.max_properties_on:
193 + break
194 + count += 1
195 + listings.append(
196 + self._property_listing(card, url, city_slug, slug,
197 + province="ON"))
198 + except Exception:
199 + continue
112 200 return listings
113 201
114 202 def _property_listing(self, card, url: str, city_slug: str,
115 − slug: str) -> Listing:
116 − city, sector = _GM_CITIES[city_slug]
203 + slug: str, province: str = "QC") -> Listing:
204 + city, sector = (_ON_CITIES if province == "ON"
205 + else _GM_CITIES)[city_slug]
117 206 name = ""
118 207 fav = card.select_one("[data-property]")
119 208 if fav:
@@ -127,6 +216,10 @@ class RealstarConnector(BaseConnector):
127 216
128 217 addr_el = card.select_one(".card-prop-address")
129 218 address = addr_el.get_text(" ", strip=True) if addr_el else ""
219 + # Ontario : adresse suffixée « ON » (standard expansion 2026-08)
220 + if province == "ON" and address \
221 + and not re.search(r",?\s+ON\b", address):
222 + address = f"{address}, ON"
130 223
131 224 meta = card.select_one(".card-bed-bath-rent")
132 225 beds = baths = sqft = ""
@@ -182,8 +275,11 @@ class RealstarConnector(BaseConnector):
182 275 key = hashlib.sha1(
183 276 f"{name}|{address}|{beds}|{baths}|{sqft}|{price_label}"
184 277 .encode("utf-8")).hexdigest()
278 + # uid : slug inchangé au QC ; préfixe « on- » en Ontario (aucun risque
279 + # de collision inter-provinces, cache détail séparé)
280 + ext = slug if province != "ON" else f"on-{slug}"
185 281 try:
186 − payload = self.detail(slug, key, lambda: self._fetch_detail(url))
282 + payload = self.detail(ext, key, lambda: self._fetch_detail(url))
187 283 except _BudgetReached:
188 284 payload = {}
189 285 except Exception:
@@ -239,12 +335,13 @@ class RealstarConnector(BaseConnector):
239 335 desc_parts.append("Disponibles : " + " ; ".join(plan_bits))
240 336 return Listing(
241 337 source=self.source_id,
242 − external_id=slug,
338 + external_id=ext,
243 339 url=url,
244 340 title=name,
245 341 address=address,
246 342 sector=sector,
247 343 city=city,
344 + province=province,
248 345 unit_type=unit_type,
249 346 price=price,
250 347 price_label=price_label,
@@ -284,6 +381,9 @@ class RealstarConnector(BaseConnector):
284 381 text = psoup.get_text("\n", strip=True)
285 382 hm = re.search(r"Points forts de la propri[ée]t[ée]\n(.*?)\n"
286 383 r"(?:Photos|Emplacement|Votre)", text, re.S)
384 + if not hm: # gabarit anglais (fiches Ontario)
385 + hm = re.search(r"Property Highlights\n(.*?)\n"
386 + r"(?:Photos|Location|Your)", text, re.S)
287 387 if hm:
288 388 amenities = []
289 389 for t in hm.group(1).split("\n"):
@@ -322,7 +422,8 @@ class RealstarConnector(BaseConnector):
322 422 sqft = None
323 423 sq_el = cont.select_one("span[data-selenium-id$='SqFt']")
324 424 if sq_el:
325 − sm = re.search(r"([\d,]{2,})\s*Pi",
425 + # « Pi. Ca. » (gabarit FR) ou « Sq. Ft. » (gabarit EN/ON)
426 + sm = re.search(r"([\d,]{2,})\s*(?:Pi|Sq)",
326 427 sq_el.get_text(" ", strip=True), re.I)
327 428 if sm:
328 429 v = float(sm.group(1).replace(",", ""))
@@ -339,8 +440,18 @@ class RealstarConnector(BaseConnector):
339 440 if um:
340 441 n = int(um.group(1))
341 442 unit_type = "6½+" if n >= 6 else f"{n}½"
342 − elif re.match(r"(?i)^\s*studio", name):
443 + elif re.match(r"(?i)^\s*(?:studio|bachelor)", name):
343 444 unit_type = "Studio"
445 + else:
446 + # gabarits anglais (plans Ontario) : « 2 Bed 1 Bath A »,
447 + # « One Bedroom », etc. -> convention QC (n + 2)½
448 + wm = re.match(r"(?i)^\s*(\d|one|two|three|four)\s*bed",
449 + name)
450 + if wm:
451 + n = {"one": 1, "two": 2, "three": 3, "four": 4}.get(
452 + wm.group(1).lower()) or int(wm.group(1))
453 + # aligné sur _BED_TYPES : 4 chambres -> 6½
454 + unit_type = "6½+" if n >= 5 else f"{n + 2}½"
344 455 plans.append({"name": name, "available": avail,
345 456 "sqft": sqft, "price": price,
346 457 "unit_type": unit_type})
added louka/connectors/rentcafe.py +476 −0
@@ -0,0 +1,476 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/rentcafe.py : connecteur GÉNÉRIQUE Yardi RentCafe/SecureCafe
5 +# (multi-clients Ontario — levier n° 2 de l'expansion, voir
6 +# gestion-immobiliere-ontario.md §10)
7 +#
8 +# Une sous-classe est générée dynamiquement par client du registre
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).
12 +#
13 +# Pattern « searchlisting » (validé sur les 4 clients actifs) :
14 +# 1) <site>/searchlisting.aspx via Scrapfly (Cloudflare 403 en direct,
15 +# contenu rendu côté serveur -> render_js inutile) :
16 +# - cartes li.property-box-hidden : nom, lien fiche, adresse complète
17 +# (« …, Kingston, ON K7P 1M8 »), lits/sdb/pi², fourchette de prix,
18 +# téléphone, vignette resource.rentcafe.com ;
19 +# - champ caché available_prop_map (JSON doublement encodé) :
20 +# propertyid -> lat/lng + fourchette de prix des épingles de carte.
21 +# Seules les cartes dont l'adresse est en Ontario sont conservées
22 +# (Osgoode/GWLRA listent aussi AB/BC ; le QC reste aux connecteurs QC).
23 +# 2) fiche propriété (+ /floorplans au besoin) via self.detail() (cache BD,
24 +# budget Scrapfly par synchronisation) : galerie, description, plans
25 +# structurés fp-container (2 gabarits : spans data-selenium-id ou cartes
26 +# h2.card-title + nu-bed/nu-bathroom/nu-area + data-floorplan-*).
27 +# Une annonce par propriété (uid stable = propertyid RentCafe) ; comme chez
28 +# Osgoode, AUCUN décompte d'unités disponibles n'est publié -> availability
29 +# reste vide (rien d'inventé).
30 +#
31 +# Pattern « securecafe » (<client>.securecafe.com/residentservices/
32 +# apartmentsforrent/…) : vérifié fermé derrière login chez Old Oak,
33 +# Paramount et Tricar — entrées « echec » du registre, aucune classe
34 +# générée (voir les notes du registre avant de réessayer).
35 +#
36 +# ⚠ Gate Ontario : les classes ne sont actives que si LOUKA_ONTARIO=1 —
37 +# sans cette variable, disabled=True et le registre des connecteurs les
38 +# ignore (zéro impact sur la prod Québec).
39 +# -----------------------------------------------------------------------------
40 +from __future__ import annotations
41 +
42 +import hashlib
43 +import html as htmllib
44 +import json
45 +import os
46 +import re
47 +
48 +from bs4 import BeautifulSoup
49 +
50 +from ..schema import Listing, parse_price
51 +from .base import BaseConnector
52 +
53 +# Gate expansion Ontario (voir en-tête)
54 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
55 +
56 +_REGISTRY_PATH = os.path.join(os.path.dirname(__file__), "..", "..",
57 + "data", "rentcafe_clients.json")
58 +
59 +# « $1,449.00 - $1,899.00 » / « $1,499.00 » (format RentCafe, virgule = milliers)
60 +_PRICE_RE = re.compile(r"\$[\d,]+(?:\.\d{2})?(?:\s*(?:-|to|à)+\s*"
61 + r"\$[\d,]+(?:\.\d{2})?)?")
62 +_NUM_RE = re.compile(r"[\d,]+(?:\.\d+)?")
63 +_SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder|\.svg", re.I)
64 +_PHONE_RE = re.compile(r"\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})")
65 +
66 +# nombre de chambres -> type d'unité (normalize_unit_type fera la conversion
67 +# canonique n½ à l'ingestion ; on passe le texte source, rien d'inventé)
68 +_BED_TYPES = {0: "Studio", 1: "1 Bed", 2: "2 Beds", 3: "3 Beds", 4: "4 Beds"}
69 +
70 +
71 +def _to_float(txt: str) -> float | None:
72 + m = _NUM_RE.search(txt or "")
73 + if not m:
74 + return None
75 + try:
76 + return float(m.group(0).replace(",", ""))
77 + except ValueError:
78 + return None
79 +
80 +
81 +def _low_price(txt: str) -> float | None:
82 + """Borne basse d'une fourchette « $1,449.00 - $1,899.00 » (100-20000 $)."""
83 + vals = [_to_float(v) for v in re.findall(r"\$[\d,]+(?:\.\d{2})?", txt or "")]
84 + vals = [v for v in vals if v is not None and 100 <= v <= 20000]
85 + return min(vals) if vals else None
86 +
87 +
88 +class _DetailBudget(Exception):
89 + """Budget de nouvelles fiches Scrapfly épuisé pour cette synchronisation."""
90 +
91 +
92 +class RentCafeClientConnector(BaseConnector):
93 + """Classe de base des clients RentCafe — ne PAS l'enregistrer telle quelle
94 + (source_id vide) : les sous-classes concrètes sont générées plus bas à
95 + partir du registre data/rentcafe_clients.json."""
96 +
97 + source_id = "" # vide -> ignorée par connectors/__init__.py
98 + disabled = not _ONTARIO # gate expansion Ontario
99 + request_delay = 1.5 # Scrapfly coûte : politesse renforcée
100 + client: dict = {} # entrée du registre (site, search_url…)
101 + max_properties = 160 # garde-fou (Effort Trust : 153 cartes ON)
102 + max_details = 8 # nouvelles fiches Scrapfly max par sync
103 + max_images = 20
104 +
105 + # -- Scrapfly (Cloudflare -> ASP ; contenu rendu serveur, pas de JS) -------
106 + def _page(self, url: str) -> str:
107 + res = self.scrapfly(url, render_js=False, asp=True, country="ca")
108 + if (res.get("status_code") or 0) != 200:
109 + return ""
110 + return res.get("content") or ""
111 +
112 + # -- fetch ------------------------------------------------------------------
113 + def fetch(self) -> list[Listing]:
114 + if (self.client.get("pattern") or "searchlisting") != "searchlisting":
115 + return [] # « securecafe » public : aucun client validé
116 + html = self._page(self.client["search_url"])
117 + if not html:
118 + raise RuntimeError(
119 + f"searchlisting inaccessible via Scrapfly ({self.source_id})")
120 + soup = BeautifulSoup(html, "html.parser")
121 + pins = self._map_pins(html)
122 +
123 + self._detail_fetches = 0
124 + listings: list[Listing] = []
125 + seen: set[str] = set()
126 + cards = soup.select("li.property-box-hidden") \
127 + or soup.select("li.property-box, .property-box")
128 + for card in cards:
129 + if len(listings) >= self.max_properties:
130 + break
131 + try:
132 + lst = self._property_listing(card, pins)
133 + if lst and lst.external_id not in seen:
134 + seen.add(lst.external_id)
135 + listings.append(lst)
136 + except Exception:
137 + continue
138 + return listings
139 +
140 + # -- épingles de carte (champ caché available_prop_map) ----------------------
141 + @staticmethod
142 + def _map_pins(html: str) -> dict[str, dict]:
143 + """propertyid -> {lat, lng, price, beds} (JSON doublement encodé)."""
144 + m = re.search(r"available_prop_map[^>]*value=(['\"])(.*?)\1", html, re.S)
145 + if not m:
146 + return {}
147 + try:
148 + data = json.loads(htmllib.unescape(m.group(2)))
149 + if isinstance(data, str):
150 + data = json.loads(data)
151 + pins = data.get("ListingsPins")
152 + if isinstance(pins, str):
153 + pins = json.loads(pins)
154 + except (ValueError, AttributeError):
155 + return {}
156 + out: dict[str, dict] = {}
157 + for grp in (pins or {}).get("groups") or []:
158 + for p in grp.get("points") or []:
159 + pid = str(p.get("propertyid") or p.get("id") or "")
160 + if not pid:
161 + continue
162 + hover = p.get("hover") or {}
163 + out[pid] = {"lat": p.get("y"), "lng": p.get("x"),
164 + "price": hover.get("Price") or "",
165 + "beds": hover.get("Beds") or ""}
166 + return out
167 +
168 + # -- carte propriété ----------------------------------------------------------
169 + def _property_listing(self, card, pins: dict[str, dict]) -> Listing | None:
170 + a = card.select_one(".property-name a") or card.select_one("h3 a")
171 + if not a or not a.get("href"):
172 + return None
173 + url = (a.get("href") or "").strip()
174 + if url.startswith("/"):
175 + url = self.client["site"].rstrip("/") + url
176 + url = url.split("?")[0].rstrip("/")
177 +
178 + name = re.sub(r"\s*opens in a new tab\s*", "",
179 + a.get_text(" ", strip=True)).strip()
180 +
181 + addr_el = card.select_one(".card-prop-address")
182 + address = addr_el.get_text(" ", strip=True) if addr_el else ""
183 + # Ontario seulement (Osgoode/GWLRA listent aussi AB/BC ; QC = connecteurs QC)
184 + if not re.search(r",\s*ON(?:\s|,|$)", address):
185 + return None
186 + city = ""
187 + parts = [p.strip() for p in address.split(",")]
188 + for i, p in enumerate(parts):
189 + if re.match(r"^ON(\s|$)", p) and i > 0:
190 + city = parts[i - 1]
191 + break
192 +
193 + # propertyid RentCafe stable (classe track-propertyurl-<id> ou épingle)
194 + pid = ""
195 + for el in card.select("[class*='track-propertyurl-']"):
196 + for cl in el.get("class") or []:
197 + if cl.startswith("track-propertyurl-"):
198 + pid = cl.rsplit("-", 1)[-1]
199 + break
200 + slug = re.sub(r"[^a-z0-9]+", "-",
201 + url.rstrip("/").rsplit("/", 1)[-1].lower()).strip("-")
202 + external_id = pid or slug
203 + if not external_id:
204 + return None
205 +
206 + # lits / sdb / pi² de la carte (« 1.0Beds - 2.0Beds », « 799 - 1,018 Sq. Ft. »)
207 + beds_txt = baths_txt = sqft_txt = ""
208 + meta = card.select_one(".card-bed-bath-rent")
209 + if meta:
210 + for li in meta.select("li"):
211 + it = li.get_text(" ", strip=True)
212 + if "Bed" in it:
213 + beds_txt = it
214 + elif "Bath" in it:
215 + baths_txt = it
216 + elif "Sq" in it:
217 + sqft_txt = re.sub(r"\s*to\s*-\s*", " - ", it)
218 + unit_type = ""
219 + bm = re.match(r"^(\d+)(?:\.\d+)?\s*Beds?", beds_txt or "")
220 + if bm and "-" not in beds_txt.split("Bed")[0]:
221 + unit_type = _BED_TYPES.get(int(bm.group(1)), "")
222 +
223 + # fourchette de prix : carte, sinon épingle de la carte interactive
224 + pin = pins.get(external_id) or {}
225 + pm = _PRICE_RE.search(card.get_text(" ", strip=True))
226 + price_label = pm.group(0) if pm else (pin.get("price") or "")
227 + price_label = re.sub(r"\s*(?:to|à)\s*", " - ", price_label).strip()
228 + price = _low_price(price_label)
229 + if price is not None and "-" in price_label:
230 + price_label = "À partir de " + price_label
231 + if price is None:
232 + price_label = "" # « Call for Details » : rien d'inventé
233 +
234 + phone = ""
235 + tel = card.select_one("a[href^='tel:']")
236 + if tel:
237 + tm = _PHONE_RE.search(tel.get("href") or "")
238 + if tm:
239 + phone = f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}"
240 +
241 + images: list[str] = []
242 + img = card.select_one("img[src*='rentcafe']") or card.select_one("img")
243 + if img and (img.get("src") or "").startswith("http") \
244 + and not _SKIP_IMG.search(img.get("src") or ""):
245 + images.append(img["src"])
246 +
247 + # fiche + plans via cache BD (clé = contenu de la carte liste)
248 + key = hashlib.sha1(
249 + f"{name}|{address}|{beds_txt}|{baths_txt}|{sqft_txt}|{price_label}"
250 + .encode("utf-8")).hexdigest()
251 + try:
252 + payload = self.detail(external_id, key,
253 + lambda: self._fetch_detail(url))
254 + except _DetailBudget:
255 + payload = {}
256 + except Exception:
257 + payload = {}
258 +
259 + for im in payload.get("images") or []:
260 + if im not in images:
261 + images.append(im)
262 +
263 + # plans structurés : prix « à partir de » réel + résumé fidèle
264 + plans = payload.get("floorplans") or []
265 + prices = [p["price"] for p in plans
266 + if p.get("price") and 100 <= p["price"] <= 20000]
267 + if prices:
268 + price = min(prices)
269 + price_label = (f"À partir de {price:,.0f} $/mois".replace(",", " ")
270 + if len(plans) > 1 or "-" in price_label
271 + else f"{price:,.0f} $/mois".replace(",", " "))
272 + sqfts = [p["sqft"] for p in plans if p.get("sqft")]
273 + area_sqft = min(sqfts) if sqfts else None
274 + if len(plans) == 1 and plans[0].get("unit_type"):
275 + unit_type = plans[0]["unit_type"]
276 + plan_bits = []
277 + for p in plans[:8]:
278 + seg = p.get("name") or ""
279 + if p.get("sqft"):
280 + seg += f" ({p['sqft']:.0f} pi²)"
281 + if p.get("price"):
282 + seg += f" : {p['price']:,.0f} $/mois".replace(",", " ")
283 + if seg:
284 + plan_bits.append(seg)
285 +
286 + bathrooms = None
287 + tb = re.match(r"^(\d+(?:\.\d+)?)\s*Bath", baths_txt or "")
288 + if tb and "-" not in baths_txt.split("Bath")[0]:
289 + bathrooms = float(tb.group(1))
290 +
291 + details: dict = {}
292 + if phone:
293 + details["contact"] = {"phone": phone}
294 +
295 + desc_parts = ([payload["description"]]
296 + if payload.get("description") else [])
297 + desc_parts += [b for b in [beds_txt, baths_txt, sqft_txt] if b]
298 + if plan_bits:
299 + desc_parts.append("Plans : " + " ; ".join(plan_bits))
300 +
301 + lat = pin.get("lat")
302 + lng = pin.get("lng")
303 +
304 + return Listing(
305 + source=self.source_id,
306 + external_id=str(external_id),
307 + url=url,
308 + title=name or slug.replace("-", " ").title(),
309 + address=address,
310 + sector="", # le gabarit RentCafe ne publie pas le quartier
311 + city=city,
312 + province="ON",
313 + unit_type=unit_type,
314 + bathrooms=bathrooms,
315 + price=price,
316 + price_label=price_label,
317 + availability="", # aucun décompte d'unités publié (cf. en-tête)
318 + area_sqft=area_sqft,
319 + description=" — ".join(desc_parts)[:900],
320 + details=details,
321 + images=images[: self.max_images],
322 + lat=float(lat) if isinstance(lat, (int, float)) else None,
323 + lng=float(lng) if isinstance(lng, (int, float)) else None,
324 + )
325 +
326 + # -- fiche propriété (galerie + description + plans) --------------------------
327 + def _fetch_detail(self, url: str) -> dict:
328 + if self._detail_fetches >= self.max_details:
329 + raise _DetailBudget()
330 + self._detail_fetches += 1
331 +
332 + payload: dict = {"description": "", "images": [], "floorplans": []}
333 + html = self._page(url)
334 + if html:
335 + soup = BeautifulSoup(html, "html.parser")
336 + for im in soup.select("img[src*='resource.rentcafe.com']"):
337 + src = im.get("src") or ""
338 + if src and not _SKIP_IMG.search(src) \
339 + and src not in payload["images"]:
340 + payload["images"].append(src)
341 + paras = [p.get_text(" ", strip=True) for p in soup.find_all("p")]
342 + paras = [p for p in paras if len(p) > 80]
343 + if paras:
344 + payload["description"] = " ".join(paras[:2])[:600]
345 + payload["floorplans"] = self._parse_floorplans(soup)
346 +
347 + # plans absents de la fiche (gabarit Osgoode/GWLRA) -> page /floorplans
348 + if not payload["floorplans"] and not url.endswith("default.aspx") \
349 + and self._detail_fetches < self.max_details:
350 + self._detail_fetches += 1
351 + fp_html = self._page(url + "/floorplans")
352 + if fp_html:
353 + payload["floorplans"] = self._parse_floorplans(
354 + BeautifulSoup(fp_html, "html.parser"))
355 + return payload
356 +
357 + # -- plans fp-container (2 gabarits RentCafe) ----------------------------------
358 + @staticmethod
359 + def _parse_floorplans(soup) -> list[dict]:
360 + """Cartes de plans : nom, chambres, sdb, pi², prix (borne basse d'une
361 + fourchette). Gabarits : spans data-selenium-id (Osgoode) OU cartes
362 + h2.card-title + icônes nu-bed/nu-bathroom/nu-area + attributs
363 + data-floorplan-* (Effort, GWLRA). Dédoublonnés par id (carrousels)."""
364 + plans: list[dict] = []
365 + seen: set[str] = set()
366 + for cont in soup.select("div[id^='fp-container-']"):
367 + fpid = (cont.get("id") or "").rsplit("-", 1)[-1]
368 + if fpid in seen:
369 + continue
370 + seen.add(fpid)
371 + try:
372 + plan: dict = {}
373 + # gabarit 1 : spans data-selenium-id
374 + name_el = cont.select_one("span[data-selenium-id$='Name']")
375 + # gabarit 2 : cartes (titre + icônes)
376 + if name_el is None:
377 + name_el = cont.select_one("h2.card-title, .card-title")
378 + if name_el is not None:
379 + plan["name"] = name_el.get_text(" ", strip=True)
380 +
381 + beds_el = cont.select_one("span[data-selenium-id$='Beds']")
382 + beds_txt = (beds_el.get_text(" ", strip=True) if beds_el
383 + else "")
384 + if not beds_txt:
385 + ic = cont.select_one(".nu-bed")
386 + if ic and ic.parent:
387 + beds_txt = ic.parent.get_text(" ", strip=True)
388 + bm = re.search(r"(\d+)\s*Bed", beds_txt)
389 + if bm:
390 + plan["bedrooms"] = float(bm.group(1))
391 + plan["unit_type"] = _BED_TYPES.get(int(bm.group(1)), "")
392 + elif re.search(r"studio", (plan.get("name") or "") + beds_txt,
393 + re.I):
394 + plan["bedrooms"] = 0.0
395 + plan["unit_type"] = "Studio"
396 +
397 + baths_el = cont.select_one("span[data-selenium-id$='Baths']")
398 + baths_txt = (baths_el.get_text(" ", strip=True) if baths_el
399 + else "")
400 + if not baths_txt:
401 + ic = cont.select_one(".nu-bathroom")
402 + if ic and ic.parent:
403 + baths_txt = ic.parent.get_text(" ", strip=True)
404 + tm = re.search(r"(\d+(?:\.\d+)?)\s*Bath", baths_txt)
405 + if tm:
406 + plan["bathrooms"] = float(tm.group(1))
407 +
408 + sq_el = cont.select_one("span[data-selenium-id$='SqFt']")
409 + sq_txt = sq_el.get_text(" ", strip=True) if sq_el else ""
410 + if not sq_txt:
411 + ic = cont.select_one(".nu-area")
412 + if ic and ic.parent:
413 + sq_txt = ic.parent.get_text(" ", strip=True)
414 + sv = _to_float(sq_txt)
415 + if sv and 80 <= sv <= 20000:
416 + plan["sqft"] = sv
417 +
418 + # prix : attribut structuré data-floorplan-price (« 2113 -2163 »),
419 + # sinon encadré « Starting at $2,113.00 /Month », sinon span Rent
420 + pv = None
421 + btn = cont.select_one("[data-floorplan-price]")
422 + if btn:
423 + nums = [_to_float(x) for x in _NUM_RE.findall(
424 + btn.get("data-floorplan-price") or "")]
425 + nums = [n for n in nums if n and 100 <= n <= 20000]
426 + if nums:
427 + pv = min(nums)
428 + if pv is None:
429 + rent_el = cont.select_one(
430 + "span[data-selenium-id$='Rent']") \
431 + or cont.select_one(".fieldset .font-weight-bold, "
432 + ".fieldset span.font-weight-bold")
433 + if rent_el:
434 + pv = _low_price(rent_el.get_text(" ", strip=True))
435 + if pv is None:
436 + pv = _low_price(" ".join(
437 + _PRICE_RE.findall(cont.get_text(" ", strip=True))))
438 + if pv:
439 + plan["price"] = pv
440 +
441 + if plan.get("name") or plan.get("price"):
442 + plans.append(plan)
443 + except Exception:
444 + continue
445 + return plans
446 +
447 +
448 +# =============================================================================
449 +# Génération des sous-classes concrètes à partir du registre
450 +# data/rentcafe_clients.json — une classe par client « ok », déposée dans les
451 +# globals du module pour que connectors/__init__.py la découvre. Un registre
452 +# absent/corrompu ne doit JAMAIS casser l'import du paquet (prod QC).
453 +# =============================================================================
454 +def _load_clients() -> list[dict]:
455 + try:
456 + with open(_REGISTRY_PATH, encoding="utf-8") as f:
457 + return json.load(f).get("clients") or []
458 + except (OSError, ValueError):
459 + return []
460 +
461 +
462 +for _c in _load_clients():
463 + if (_c.get("status") or "") != "ok" or not _c.get("id"):
464 + continue
465 + _cls_name = "RC" + "".join(
466 + w.capitalize() for w in re.split(r"[^a-z0-9]+", _c["id"]) if w) \
467 + + "Connector"
468 + globals()[_cls_name] = type(_cls_name, (RentCafeClientConnector,), {
469 + "source_id": f"rc_{_c['id']}",
470 + "client": _c,
471 + "disabled": not _ONTARIO,
472 + "__doc__": f"Client RentCafe « {_c.get('name') or _c['id']} » "
473 + f"({_c.get('regions') or 'Ontario'}) — généré depuis "
474 + "data/rentcafe_clients.json.",
475 + })
476 +del _c
added louka/connectors/skyline.py +339 −0
@@ -0,0 +1,339 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec + Ontario)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/skyline.py : connecteur Skyline Living (skylineliving.ca)
5 +# Gestionnaire clé des villes secondaires ontariennes (~22 000 unités,
6 +# ~38 villes : Guelph HQ, Est ontarien, Niagara, Sud-Ouest, Nord, Centre).
7 +# Le site est une SPA Rentsync « nouvelle génération » : ses bundles
8 +# (cdn.rentsync.com/site/skyline) appellent la passerelle JSON publique
9 +# `https://website-gateway-cdn.rentsync.com/v1/skyline/…` (aucun jeton,
10 +# le UA de base passe). Endpoints utilisés :
11 +# - /properties?limit=1000 — tous les immeubles actifs (~193) : adresse,
12 +# lat/lng, cityId, description, caractéristiques, animaux, contact ;
13 +# - /cities?where=id~in:…&relations=province:p — nom de ville + province
14 +# (filtre ON strict : Skyline a aussi quelques immeubles hors Ontario) ;
15 +# - /units?where=status:enabled,available~in:1 — types d'unités avec
16 +# vacance (loyer, cc/sdb, pi², date), paginés (meta.totalPages) ;
17 +# - /photos?relations=buildingsHasPhotos:bhp&where=bhp.buildingId~in:… —
18 +# galeries par lots d'immeubles (images S3 lws_lift, taille « full »).
19 +# Une annonce par type d'unité disponible (comme capreit). NB : dans les
20 +# tours neuves en location (ex. Sky Towers à Barrie), le flux publie UNE
21 +# rangée PAR APPARTEMENT (champ `number`) du même plan — on regroupe donc
22 +# par (immeuble, typeName, cc, sdb) : loyer plancher « à partir de »,
23 +# date la plus proche, identifiant stable immeuble+plan. Tout vient de
24 +# l'API par lots (~15 requêtes au total) : aucune page détail à visiter,
25 +# donc pas besoin du cache BD self.detail().
26 +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : sans la variable, le
27 +# connecteur est `disabled` (exclu du registre), prod QC inchangée.
28 +# NB : le parc Thunder Bay a été vendu (2025-2026) — absent du flux, normal.
29 +# -----------------------------------------------------------------------------
30 +from __future__ import annotations
31 +
32 +import html as htmllib
33 +import os
34 +import re
35 +
36 +from bs4 import BeautifulSoup
37 +
38 +from ..schema import Listing, normalize_unit_type, strip_accents
39 +
40 +from .base import BaseConnector
41 +
42 +BASE = "https://www.skylineliving.ca"
43 +GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/skyline"
44 +IMG_BASE = "https://s3.amazonaws.com/lws_lift/skyline/images/gallery/full"
45 +
46 +# Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, connecteur inactif
47 +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"
48 +
49 +# secteur : le champ `neighbourhood` du flux contient souvent le comté
50 +# (« Grey County », « Niagara Region ») — pas un quartier, on l'écarte
51 +_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)
52 +_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")
53 +
54 +
55 +class SkylineConnector(BaseConnector):
56 + source_id = "skyline"
57 + request_delay = 0.8
58 + disabled = not _ONTARIO # gate Ontario (voir en-tête)
59 + max_properties = 250 # garde-fou immeubles (193 au 2026-08)
60 + max_listings = 1500 # garde-fou global (≈480 groupes au 2026-08)
61 + max_images = 15
62 + page_limit = 750 # taille de page maximale de la passerelle
63 + photo_chunk = 40 # immeubles par requête /photos
64 +
65 + # -- accès passerelle -------------------------------------------------------
66 + def _get_json(self, path: str, params: dict) -> dict:
67 + resp = self.get(GATEWAY + path, params=params, headers={
68 + "Accept": "application/json",
69 + "Origin": BASE,
70 + "Referer": BASE + "/",
71 + "rs-lang": "en",
72 + })
73 + return resp.json()
74 +
75 + def _paged(self, path: str, params: dict, max_pages: int = 10) -> list[dict]:
76 + """Concatène toutes les pages d'un endpoint (meta.totalPages)."""
77 + out: list[dict] = []
78 + page = 1
79 + while page <= max_pages:
80 + data = self._get_json(path, {**params, "page": page})
81 + out.extend(data.get("data") or [])
82 + meta = data.get("meta") or {}
83 + if page >= int(meta.get("totalPages") or 1):
84 + break
85 + page += 1
86 + return out
87 +
88 + # -- villes : cityId -> (nom, code province) --------------------------------
89 + def _cities(self, city_ids: set[int]) -> dict[int, tuple[str, str]]:
90 + ids = "|".join(str(i) for i in sorted(city_ids))
91 + rows = self._paged("/cities", {
92 + "where": f"id~in:{ids}",
93 + "relations": "province:p",
94 + "limit": self.page_limit,
95 + })
96 + out: dict[int, tuple[str, str]] = {}
97 + for c in rows:
98 + prov = c.get("province") or {}
99 + out[int(c["id"])] = ((c.get("cityName") or "").strip(),
100 + (prov.get("provinceCode") or "").strip().upper())
101 + return out
102 +
103 + # -- galeries photo par lots d'immeubles ------------------------------------
104 + def _photos(self, building_ids: list[int]) -> dict[int, list[str]]:
105 + out: dict[int, list[str]] = {}
106 + for i in range(0, len(building_ids), self.photo_chunk):
107 + chunk = building_ids[i:i + self.photo_chunk]
108 + ids = "|".join(str(b) for b in chunk)
109 + try:
110 + rows = self._paged("/photos", {
111 + "relations": "buildingsHasPhotos:bhp",
112 + "where": f"bhp.buildingId~in:{ids}",
113 + "orderBy": "bhp.orderBy~asc",
114 + "limit": self.page_limit,
115 + })
116 + except Exception:
117 + continue # galerie manquante : annonces sans photo plutôt que rien
118 + for ph in rows:
119 + bid = ph.get("buildingId")
120 + img = (ph.get("image") or "").strip()
121 + if not bid or not img:
122 + continue
123 + urls = out.setdefault(int(bid), [])
124 + if len(urls) < self.max_images:
125 + u = f"{IMG_BASE}/{img}"
126 + if u not in urls:
127 + urls.append(u)
128 + return out
129 +
130 + # -- collecte ---------------------------------------------------------------
131 + def fetch(self) -> list[Listing]:
132 + props = self._paged("/properties", {"limit": 1000})[: self.max_properties]
133 +
134 + # villes -> filtre Ontario strict (le flux a quelques immeubles hors ON)
135 + cities = self._cities({int(p["cityId"]) for p in props if p.get("cityId")})
136 + on_props: dict[int, dict] = {}
137 + for p in props:
138 + name, prov = cities.get(int(p.get("cityId") or 0), ("", ""))
139 + if prov != "ON" or not name:
140 + continue
141 + p["_city"] = name
142 + on_props[int(p["id"])] = p
143 +
144 + # types d'unités avec vacance : dans les tours en location, le flux
145 + # publie une rangée PAR APPARTEMENT du même plan — regrouper par
146 + # (immeuble, plan, cc, sdb) pour une annonce par type d'unité
147 + units = self._paged("/units", {
148 + "where": "status:enabled,available~in:1",
149 + "limit": self.page_limit,
150 + })
151 + by_building: dict[int, dict[tuple, list[dict]]] = {}
152 + for u in units:
153 + bid = int(u.get("buildingId") or 0)
154 + if bid not in on_props:
155 + continue
156 + key = ((u.get("typeName") or "").strip().lower(),
157 + u.get("bedMin", u.get("bed")),
158 + u.get("bathMin", u.get("bath")))
159 + by_building.setdefault(bid, {}).setdefault(key, []).append(u)
160 +
161 + # galeries des seuls immeubles qui produisent des annonces
162 + photos = self._photos(sorted(by_building))
163 +
164 + listings: list[Listing] = []
165 + for bid, groups in by_building.items():
166 + if len(listings) >= self.max_listings:
167 + break
168 + p = on_props[bid]
169 + try:
170 + base = self._building_ctx(p, photos.get(bid) or [])
171 + except Exception:
172 + continue
173 + for rows in groups.values():
174 + if len(listings) >= self.max_listings:
175 + break
176 + try:
177 + listings.append(self._listing(bid, rows, base))
178 + except Exception:
179 + continue
180 + return listings
181 +
182 + # -- contexte immeuble (partagé entre ses annonces) --------------------------
183 + def _building_ctx(self, p: dict, images: list[str]) -> dict:
184 + city = p["_city"]
185 + street = " ".join(x for x in (
186 + (p.get("streetNumber") or "").strip(),
187 + (p.get("streetName") or "").strip()) if x)
188 + postal = (p.get("postal") or "").strip()
189 + address = ", ".join(x for x in (street, city) if x)
190 + if address:
191 + address += f", ON {postal}".rstrip()
192 +
193 + # secteur : quartier seulement (écarter comtés/« regions » du flux)
194 + sector = (p.get("neighbourhood") or "").strip()
195 + if _COUNTY_RE.search(sector):
196 + sector = ""
197 +
198 + try:
199 + lat = float(p["latitude"]) if p.get("latitude") else None
200 + lng = float(p["longitude"]) if p.get("longitude") else None
201 + except (TypeError, ValueError):
202 + lat = lng = None
203 +
204 + # caractéristiques de l'immeuble : HTML doublement échappé dans le flux
205 + amenities: list[str] = []
206 + feats = htmllib.unescape(p.get("buildingFeatures") or "")
207 + for li in BeautifulSoup(feats, "html.parser").find_all("li"):
208 + t = li.get_text(" ", strip=True)
209 + if t and t not in amenities:
210 + amenities.append(t)
211 +
212 + # description : aperçu de l'immeuble (+ détails des suites si présents)
213 + desc_html = (p.get("buildingOverview") or "") + " " + \
214 + (p.get("suiteDetails") or "")
215 + desc = BeautifulSoup(htmllib.unescape(desc_html),
216 + "html.parser").get_text(" ", strip=True)
217 +
218 + # animaux : drapeaux structurés du flux
219 + pets = None
220 + if p.get("petsNotAllowed") == 1:
221 + pets = "non"
222 + elif p.get("petFriendly") == 1:
223 + pets = "oui"
224 +
225 + details: dict = {}
226 + contact: dict = {}
227 + if (p.get("phone") or "").strip():
228 + contact["phone"] = p["phone"].strip()
229 + email = (p.get("email") or "").split(",")[0].strip()
230 + if email:
231 + contact["email"] = email
232 + if contact:
233 + details["contact"] = contact
234 + try:
235 + if int(p.get("yearBuilt") or 0) > 1800:
236 + details["Année de construction"] = int(p["yearBuilt"])
237 + if int(p.get("floorCount") or 0) > 0:
238 + details["Étages"] = int(p["floorCount"])
239 + except (TypeError, ValueError):
240 + pass
241 +
242 + perma = (p.get("fullPermaLink") or "").strip().strip("/")
243 + url = f"{BASE}/en/{perma}" if perma else BASE
244 +
245 + return {"city": city, "address": address, "sector": sector,
246 + "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,
247 + "pets": pets, "details": details, "url": url,
248 + "images": images[: self.max_images],
249 + "name": (p.get("buildingName") or "").strip()}
250 +
251 + # -- une annonce par type d'unité disponible (rangées regroupées) ------------
252 + def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:
253 + u = rows[0]
254 + type_name = (u.get("typeName") or "").strip()
255 + unit_type = normalize_unit_type(type_name)
256 + bed = u.get("bedMin", u.get("bed"))
257 + bath = u.get("bathMin", u.get("bath"))
258 + if bed is not None and (not unit_type or unit_type == type_name):
259 + # plans nommés (« Pinnacle », « Tempo »…) : dériver des chambres
260 + unit_type = ("Studio" if int(bed) == 0
261 + else normalize_unit_type(f"{int(bed)} chambres"))
262 +
263 + # loyer : plancher du groupe (masqué sur le site si hideRateWebsites=1)
264 + rates: list[float] = []
265 + for r in rows:
266 + if r.get("hideRateWebsites"):
267 + continue
268 + try:
269 + lo = float(r.get("rateMin") or r.get("rate") or 0)
270 + hi = float(r.get("rateMax") or lo)
271 + except (TypeError, ValueError):
272 + continue
273 + if lo > 0:
274 + rates += [lo, max(hi, lo)]
275 + price = min(rates) if rates else None
276 + price_label = ""
277 + if price is not None:
278 + price_label = (f"À partir de {price:.0f} $" if max(rates) > price
279 + else f"{price:.0f} $ /mois")
280 +
281 + # superficie plausible seulement (le flux publie parfois 0)
282 + area = None
283 + for r in rows:
284 + try:
285 + v = float(r.get("sqFtMin") or r.get("sqFt") or 0)
286 + except (TypeError, ValueError):
287 + continue
288 + if 80 <= v <= 20000 and (area is None or v < area):
289 + area = v
290 +
291 + # disponibilité : date structurée la plus proche du groupe
292 + dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]
293 + for r in rows
294 + if _ISO_DATE_RE.match(str(r.get("availabilityDate")
295 + or "")))
296 + avail_date = dates[0] if dates else None
297 + availability = f"Disponible le {avail_date}" if avail_date \
298 + else "Disponible"
299 + if len(rows) > 1:
300 + availability += f" — {len(rows)} unités"
301 +
302 + details = dict(b["details"])
303 + if (u.get("leaseTerm") or "").strip():
304 + details["Bail"] = u["leaseTerm"].strip()
305 +
306 + # identifiant stable : immeuble + plan (les ids d'appartements roulent)
307 + slug = re.sub(r"[^a-z0-9]+", "-",
308 + strip_accents(type_name.lower())).strip("-")
309 + ext = f"{bid}-{slug or 'u'}"
310 + if bed is not None or bath is not None:
311 + ext += f"-{bed}-{bath}"
312 +
313 + return Listing(
314 + source=self.source_id,
315 + external_id=ext,
316 + url=b["url"],
317 + title=f"{b['name']} — {type_name}" if type_name else b["name"],
318 + address=b["address"],
319 + sector=b["sector"],
320 + city=b["city"],
321 + province="ON",
322 + unit_type=unit_type,
323 + bedrooms=float(bed) if bed is not None else None,
324 + bathrooms=float(bath) if bath else None,
325 + price=price,
326 + price_label=price_label,
327 + availability=availability,
328 + availability_date=avail_date,
329 + area_sqft=area,
330 + pets=b["pets"],
331 + furnished=True if all(r.get("furnished") == 1
332 + for r in rows) else None,
333 + description=b["desc"][:600],
334 + amenities=b["amenities"][:25],
335 + details=details,
336 + images=b["images"],
337 + lat=b["lat"],
338 + lng=b["lng"],
339 + )
modified louka/db.py +8 −4
@@ -273,6 +273,7 @@ _MIGRATIONS = {
273 273 "img_audit": "TEXT", # JSON : traçabilité du contrôle images
274 274 "coord_key": "TEXT", # clé immeuble « lat,lng » 4 déc. (jointure kascores)
275 275 "building_key": "TEXT", # clé immeuble par adresse (buildings.bkey)
276 + "province": "TEXT DEFAULT 'QC'", # « QC » | « ON » (expansion Ontario 2026-08)
276 277 },
277 278 "sync_log": {
278 279 "stats": "TEXT",
@@ -444,7 +445,9 @@ def sync_source(con: sqlite3.Connection, source: str,
444 445 params = dict(
445 446 uid=lst.uid, source=lst.source, external_id=lst.external_id,
446 447 url=lst.url, title=lst.title, address=lst.address,
447 − sector=lst.sector, city=lst.city, unit_type=lst.unit_type,
448 + sector=lst.sector, city=lst.city,
449 + province=(getattr(lst, "province", None) or "QC"),
450 + unit_type=lst.unit_type,
448 451 bedrooms=lst.bedrooms, bathrooms=lst.bathrooms,
449 452 price=lst.price, price_label=lst.price_label,
450 453 availability=lst.availability,
@@ -465,15 +468,15 @@ def sync_source(con: sqlite3.Connection, source: str,
465 468 if row is None:
466 469 con.execute(
467 470 """INSERT INTO listings (uid, source, external_id, url, title,
468 − address, sector, city, unit_type, bedrooms, bathrooms,
469 − price, price_label,
471 + address, sector, city, province, unit_type, bedrooms,
472 + bathrooms, price, price_label,
470 473 availability, availability_date, area_sqft, pets, furnished,
471 474 description, digest, amenities, details, images, lat, lng,
472 475 content_hash, first_seen, last_seen, updated_at,
473 476 miss_count, active, completeness, published,
474 477 quality_reasons)
475 478 VALUES (:uid,:source,:external_id,:url,:title,:address,
476 − :sector,:city,:unit_type,:bedrooms,:bathrooms,
479 + :sector,:city,:province,:unit_type,:bedrooms,:bathrooms,
477 480 :price,:price_label,:availability,
478 481 :availability_date,:area_sqft,:pets,:furnished,
479 482 :description,:digest,:amenities,:details,:images,:lat,:lng,
@@ -488,6 +491,7 @@ def sync_source(con: sqlite3.Connection, source: str,
488 491 con.execute(
489 492 """UPDATE listings SET url=:url, title=:title,
490 493 address=:address, sector=:sector, city=:city,
494 + province=:province,
491 495 unit_type=:unit_type, bedrooms=:bedrooms,
492 496 bathrooms=:bathrooms, price=:price,
493 497 price_label=:price_label, availability=:availability,
modified louka/geocode.py +82 −23
@@ -36,12 +36,17 @@ _BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin
36 36 # garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord
37 37 # et la Gaspésie (expansion provinciale) — même boîte que schema.finalize()
38 38 _BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00)
39 +# Ontario (expansion 2026-08) : Windsor/île Pelée au sud, Kenora à l'ouest —
40 +# même boîte que schema.finalize()
41 +_BBOX_ONTARIO = (41.6, 56.9, -95.3, -74.3)
39 42
40 43 _VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures",
41 44 "l'ancienne-lorette", "ancienne-lorette"}
42 45
43 46
44 −def _bbox_for(city: str) -> tuple[float, float, float, float]:
47 +def _bbox_for(city: str, province: str = "QC") -> tuple[float, float, float, float]:
48 + if (province or "QC").upper() == "ON":
49 + return _BBOX_ONTARIO
45 50 key = strip_accents((city or "").strip().lower())
46 51 return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC
47 52
@@ -58,6 +63,16 @@ def norm_key(address: str) -> str:
58 63 return re.sub(r"\s+", " ", s).strip()
59 64
60 65
66 +def cache_key(address: str, province: str = "QC") -> str:
67 + """Clé geocode_cache province-aware : préfixe « on| » pour l'Ontario
68 + (évite toute collision d'adresses homonymes entre provinces) ; les clés
69 + québécoises restent identiques — le cache existant est préservé."""
70 + k = norm_key(address)
71 + if k and (province or "QC").upper() == "ON":
72 + return "on|" + k
73 + return k
74 +
75 +
61 76 class Geocoder:
62 77 def __init__(self, con) -> None:
63 78 self.con = con
@@ -88,12 +103,18 @@ class Geocoder:
88 103 s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s)
89 104 return s
90 105
91 − def _build_query(self, address: str, city: str) -> str:
106 + def _build_query(self, address: str, city: str,
107 + province: str = "QC") -> str:
92 108 q = self._clean(address)
93 109 key = strip_accents(q.lower())
94 110 if city and strip_accents(city.lower()) not in key:
95 111 q += f", {city}"
96 − if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():
112 + if (province or "QC").upper() == "ON":
113 + # « ON » en majuscules seulement : « on » minuscule est un mot français
114 + if "ontario" not in strip_accents(q.lower()) \
115 + and not re.search(r"\bON\b", q):
116 + q += ", Ontario"
117 + elif "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():
97 118 q += ", Québec"
98 119 return q + ", Canada"
99 120
@@ -149,7 +170,8 @@ class Geocoder:
149 170 except (KeyError, ValueError):
150 171 return None
151 172
152 − def _attempts(self, address: str, city: str) -> list[dict]:
173 + def _attempts(self, address: str, city: str,
174 + province: str = "QC") -> list[dict]:
153 175 """Stratégies de requête, de la plus précise à la moins précise.
154 176
155 177 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province)
@@ -157,27 +179,36 @@ class Geocoder:
157 179 3) structurée rue seule -> centroïde de rue (repli acceptable pour la
158 180 carte quand le numéro civique est absent d'OpenStreetMap)
159 181 """
182 + prov = (province or "QC").upper()
160 183 premier = self._clean(address).split(",")[0].strip()
161 184 ville = (city or "").strip()
162 − if not ville:
185 + if not ville and prov != "ON":
186 + # heuristique historique QC seulement — en Ontario, sans ville,
187 + # on s'en remet à la recherche libre (pas de ville par défaut)
163 188 k = strip_accents(address.lower())
164 189 ville = "Lévis" if "levis" in k else "Québec"
165 − commun = {"city": ville, "state": "Québec", "country": "Canada"}
190 + commun = {"state": "Ontario" if prov == "ON" else "Québec",
191 + "country": "Canada"}
192 + if ville:
193 + commun["city"] = ville
166 194
167 195 tries: list[dict] = []
168 196 m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier)
169 197 if m:
170 198 tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun})
171 − tries.append({"q": self._build_query(address, city)})
199 + tries.append({"q": self._build_query(address, city, prov)})
172 200 if m:
173 201 tries.append({"street": m.group(2), **commun})
174 202 return tries
175 203
176 − def resolve(self, address: str, city: str) -> tuple[float, float] | None:
204 + def resolve(self, address: str, city: str,
205 + province: str = "QC") -> tuple[float, float] | None:
177 206 """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable."""
178 − key = norm_key(address)
179 − if not key or len(key) < 6:
207 + prov = (province or "QC").upper()
208 + nk = norm_key(address)
209 + if not nk or len(nk) < 6:
180 210 return None
211 + key = ("on|" + nk) if prov == "ON" else nk # = cache_key(address, prov)
181 212 row = self.con.execute(
182 213 "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?",
183 214 (key,)).fetchone()
@@ -187,15 +218,17 @@ class Geocoder:
187 218 if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:
188 219 return None # échec récent : ne pas marteler l'API
189 220
190 − bbox = _bbox_for(city)
221 + bbox = _bbox_for(city, prov)
191 222 coords = None
192 223 provider = "nominatim"
193 − for params in self._attempts(address, city):
224 + for params in self._attempts(address, city, prov):
194 225 c = self._query_nominatim(params)
195 226 if c is not None and _in_bbox(*c, bbox):
196 227 coords = c
197 228 break
198 − if coords is None:
229 + if coords is None and prov != "ON":
230 + # repli Adresses Québec (MERN) : dataset québécois SEULEMENT —
231 + # jamais interrogé pour une adresse ontarienne
199 232 c = self._query_adresses_quebec(address, city)
200 233 if c is not None and _in_bbox(*c, bbox):
201 234 coords = c
@@ -222,14 +255,14 @@ def run(limit: int | None = None) -> dict:
222 255 con = db.connect()
223 256 geo = Geocoder(con)
224 257 rows = con.execute(
225 − """SELECT uid, address, city FROM listings
258 + """SELECT uid, address, city, province FROM listings
226 259 WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0
227 260 ORDER BY address""").fetchall()
228 261
229 − # regrouper par immeuble (adresse normalisée)
262 + # regrouper par immeuble (adresse normalisée, préfixée par province)
230 263 groupes: dict[str, list] = {}
231 264 for r in rows:
232 − groupes.setdefault(norm_key(r["address"]), []).append(r)
265 + groupes.setdefault(cache_key(r["address"], r["province"]), []).append(r)
233 266
234 267 done = failed = requests_made = 0
235 268 for key, members in groupes.items():
@@ -241,7 +274,8 @@ def run(limit: int | None = None) -> dict:
241 274 if limit is not None and requests_made >= limit:
242 275 continue
243 276 requests_made += 1
244 − coords = geo.resolve(members[0]["address"], members[0]["city"])
277 + coords = geo.resolve(members[0]["address"], members[0]["city"],
278 + members[0]["province"])
245 279 if coords:
246 280 for r in members:
247 281 con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
@@ -281,16 +315,18 @@ def run_batch(limit: int | None = None) -> dict:
281 315 con = db.connect()
282 316 geo = Geocoder(con) # réutilise _clean / bbox
283 317 rows = con.execute(
284 − """SELECT uid, address, city FROM listings
318 + """SELECT uid, address, city, province FROM listings
285 319 WHERE active=1 AND lat IS NULL AND address IS NOT NULL
286 320 AND address<>'' AND geocode_failed=0 ORDER BY address""").fetchall()
287 321 uniq: dict[str, dict] = {}
288 322 for r in rows:
289 − k = norm_key(r["address"])
290 − if not k or len(k) < 6:
323 + prov = (r["province"] or "QC").upper()
324 + nk = norm_key(r["address"])
325 + if not nk or len(nk) < 6:
291 326 continue
327 + k = ("on|" + nk) if prov == "ON" else nk # = cache_key()
292 328 u = uniq.setdefault(k, {"address": r["address"], "city": r["city"],
293 − "members": []})
329 + "province": prov, "members": []})
294 330 u["members"].append(r["uid"])
295 331 pending = []
296 332 for k, u in uniq.items():
@@ -308,9 +344,31 @@ def run_batch(limit: int | None = None) -> dict:
308 344 if limit is not None:
309 345 pending = pending[:limit]
310 346
347 + # Ontario : le lot Adresses Québec (MERN) ne couvre QUE le Québec —
348 + # les adresses ON passent par Nominatim unitaire (resolve, throttlé),
349 + # qui écrit lui-même geocode_cache (clé « on|… ») et saute le repli AQ.
350 + pending_on = [(k, u) for k, u in pending if u["province"] == "ON"]
351 + pending = [(k, u) for k, u in pending if u["province"] != "ON"]
352 + done = failed = 0
353 + for _k, u in pending_on:
354 + coords = geo.resolve(u["address"], u["city"], "ON")
355 + if coords:
356 + for uid in u["members"]:
357 + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
358 + (coords[0], coords[1], uid))
359 + done += len(u["members"])
360 + else:
361 + for uid in u["members"]:
362 + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",
363 + (uid,))
364 + failed += len(u["members"])
365 + con.commit()
366 + if pending_on:
367 + print(f"[lou-ka] geocode-batch ON {len(pending_on)} adresse(s) "
368 + f"(résolues {done}, échecs {failed})")
369 +
311 370 session = requests.Session()
312 371 session.headers["User-Agent"] = USER_AGENT
313 − done = failed = 0
314 372 for i in range(0, len(pending), BATCH_SIZE):
315 373 chunk = pending[i:i + BATCH_SIZE]
316 374 records = {"records": [
@@ -371,6 +429,7 @@ def run_batch(limit: int | None = None) -> dict:
371 429 f"(résolues {done}, échecs {failed})")
372 430 con.close()
373 431 stats = {"geocoded": done, "failed": failed,
374 − "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE}
432 + "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE,
433 + "ontario": len(pending_on)}
375 434 print(f"[lou-ka] geocode-batch {stats}")
376 435 return stats
modified louka/quality.py +4 −2
@@ -36,11 +36,13 @@ W_URL = 3 # lien vers l'annonce originale (contact / réservation)
36 36 # Ces annonces passent le score de complétude mais ne sont pas des logements :
37 37 # on les met en quarantaine (published=0) avec une raison « hors sujet ».
38 38
39 −# vocabulaire logement (titre/description)
39 +# vocabulaire logement (titre/description) — inclut les termes anglais du
40 +# marché ontarien (expansion 2026-08) : un logement ON légitime ne doit
41 +# jamais tomber en quarantaine « hors sujet » faute de vocabulaire
40 42 _RE_RES = re.compile(
41 43 r"chambre|room|studio|appart|apartment|condo|loft|maison|house|"
42 44 r"logement|logis\b|r[ée]sidences?\b|chalet|penthouse|duplex|triplex|"
43 − r"bachelor|colocation|"
45 + r"bachelor|colocation|townhouses?|bungalows?|"
44 46 r"sous-sol|basement|½|1/2|\b\d[.,]5\b|\bdemie?s?\b|\bbed(?:room)?s?\b|"
45 47 r"\bunit[ée]?s?\b|\bpi[eè]ces\b", re.I)
46 48
modified louka/schema.py +12 −4
@@ -53,6 +53,7 @@ class Listing:
53 53 address: str = "" # adresse civique
54 54 sector: str = "" # quartier/arrondissement (ex. Beauport)
55 55 city: str = "" # Québec, Lévis, ...
56 + province: str = "QC" # "QC" | "ON" (expansion Ontario 2026-08)
56 57 unit_type: str = "" # 1½ … 5½, 6½+, Studio, Loft, Chambre…
57 58 bedrooms: float | None = None # chambres fermées (convention QC : 4½ = 2)
58 59 bathrooms: float | None = None # salles de bain (1.5 = salle d'eau en plus)
@@ -103,11 +104,18 @@ class Listing:
103 104 if self.area_sqft is not None:
104 105 break
105 106
106 − # coordonnées fournies par la source : rejeter tout point hors de la
107 − # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra
108 − # le relais sur l'adresse plutôt que d'afficher un point au Kazakhstan
107 + # coordonnées fournies par la source : rejeter tout point hors du
108 + # territoire couvert (lat/lng inversés, 0/0, coquilles) — le géocodeur
109 + # prendra le relais sur l'adresse plutôt que d'afficher un point au
110 + # Kazakhstan. QC : 44.5–63°N / -80–-56°E ; ON (expansion 2026-08) :
111 + # 41.6°N (Windsor/Pelée) à 56.9°N, -95.2°E (Kenora) à -74.3°E.
109 112 if self.lat is not None and self.lng is not None:
110 − if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):
113 + prov = (self.province or "QC").upper()
114 + if prov == "ON":
115 + ok = 41.6 <= self.lat <= 56.9 and -95.3 <= self.lng <= -74.3
116 + else:
117 + ok = 44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0
118 + if not ok:
111 119 self.lat = self.lng = None
112 120
113 121 derived = extract_details(self.amenities, self.description, self.title)
114 122