SPB Git

spb/lou-ka

Public

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

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 (Aug 27, 2026) parent 91718a1 Browse files

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
Large diff (310 lines) is not loaded by default to keep the page fast.Load diff
modified louka/connectors/interrent.py +66 −9
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added louka/connectors/killam.py +343 −0
Large diff (343 lines) is not loaded by default to keep the page fast.Load diff
added louka/connectors/liftsystem.py +307 −0
Large diff (307 lines) is not loaded by default to keep the page fast.Load diff
modified louka/connectors/metcap.py +61 −11
Large diff (145 lines) is not loaded by default to keep the page fast.Load diff
modified louka/connectors/minto.py +84 −8
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
modified louka/connectors/morguard.py +43 −6
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
modified louka/connectors/realstar.py +117 −6
Large diff (200 lines) is not loaded by default to keep the page fast.Load diff
added louka/connectors/rentcafe.py +476 −0
Large diff (476 lines) is not loaded by default to keep the page fast.Load diff
added louka/connectors/skyline.py +339 −0
Large diff (339 lines) is not loaded by default to keep the page fast.Load diff
modified louka/db.py +8 −4
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
modified louka/geocode.py +82 −23
Large diff (213 lines) is not loaded by default to keep the page fast.Load diff
modified louka/quality.py +4 −2
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
modified louka/schema.py +12 −4
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme