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>
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