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
@@ -0,0 +1,310 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/homestead.py : connecteur Homestead Land Holdings (homestead.ca) | |
| 5 | +# Un des 5 plus gros gestionnaires de l'Ontario (~27 000 unités, siège à | |
| 6 | +# Kingston) : Toronto/GTA, Ottawa, Hamilton, London, Kitchener-Waterloo, | |
| 7 | +# Kingston, Guelph, Sarnia… Le site est une SPA Rentsync « nouvelle | |
| 8 | +# génération » (bundle cdn.rentsync.com/site/homestead_rebuild) qui parle à | |
| 9 | +# la passerelle JSON PUBLIQUE (aucune auth, aucun anti-bot) : | |
| 10 | +# https://website-gateway.rentsync.com/v1/homestead_rebuild/ | |
| 11 | +# properties?limit=500 → 172 immeubles (adresse, | |
| 12 | +# GPS, description, animaux, permaLink, cityId, modified) | |
| 13 | +# cities/property-summary?limit=100 → cityId → nom + province | |
| 14 | +# units?where=buildingId~in:a|b|…,status~in:enabled | |
| 15 | +# → types d'unités avec bed/bath/pi²/prix/dispo/date (séparateur | |
| 16 | +# multi-valeurs : « | » ; paginer via meta.totalPages) | |
| 17 | +# properties/{id}/photos + /utilities → galerie + services inclus | |
| 18 | +# Photos : https://s3.amazonaws.com/lws_lift/homestead/images/gallery/full/… | |
| 19 | +# (clé S3 « homestead », PAS « homestead_rebuild » qui sert au contenu CMS). | |
| 20 | +# On émet UNE annonce par type d'unité DISPONIBLE (available=1), regroupées | |
| 21 | +# par (immeuble, type, cc, sdb) — certains immeubles listent chaque logement. | |
| 22 | +# Fiche (photos+services) via le cache BD self.detail(), clé = modified. | |
| 23 | +# ----------------------------------------------------------------------------- | |
| 24 | +# Expansion Ontario — activer via LOUKA_ONTARIO=1 | |
| 25 | +from __future__ import annotations | |
| 26 | + | |
| 27 | +import hashlib | |
| 28 | +import os | |
| 29 | +import re | |
| 30 | + | |
| 31 | +from bs4 import BeautifulSoup | |
| 32 | + | |
| 33 | +from ..schema import Listing, normalize_unit_type | |
| 34 | +from .base import BaseConnector | |
| 35 | + | |
| 36 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 37 | + | |
| 38 | +BASE = "https://www.homestead.ca" | |
| 39 | +GATEWAY = "https://website-gateway.rentsync.com/v1/homestead_rebuild" | |
| 40 | +# galerie S3 : tailles 512/768/1152/full — « full » validé live | |
| 41 | +IMG_BASE = "https://s3.amazonaws.com/lws_lift/homestead/images/gallery/full" | |
| 42 | + | |
| 43 | +_ISO_DATE = re.compile(r"^20\d{2}-\d{2}-\d{2}") | |
| 44 | + | |
| 45 | + | |
| 46 | +def _slug(s: str) -> str: | |
| 47 | + return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-") | |
| 48 | + | |
| 49 | + | |
| 50 | +def _txt(html: str) -> str: | |
| 51 | + """HTML de la passerelle → texte plat.""" | |
| 52 | + if not html: | |
| 53 | + return "" | |
| 54 | + return BeautifulSoup(html, "html.parser").get_text(" ", strip=True) | |
| 55 | + | |
| 56 | + | |
| 57 | +class HomesteadConnector(BaseConnector): | |
| 58 | + source_id = "homestead" | |
| 59 | + request_delay = 0.8 | |
| 60 | + disabled = not _ONTARIO # Expansion Ontario — activer via LOUKA_ONTARIO=1 | |
| 61 | + max_properties = 300 # garde-fou (172 immeubles au 2026-08) | |
| 62 | + max_images = 15 | |
| 63 | + chunk_size = 25 # immeubles par requête « units » | |
| 64 | + | |
| 65 | + # -- passerelle JSON -------------------------------------------------------- | |
| 66 | + def _api(self, path: str, **params) -> dict: | |
| 67 | + resp = self.get(f"{GATEWAY}/{path}", params=params, | |
| 68 | + headers={"Accept": "application/json", | |
| 69 | + "Origin": BASE, "Referer": BASE + "/"}) | |
| 70 | + return resp.json() | |
| 71 | + | |
| 72 | + def _api_all(self, path: str, **params) -> list[dict]: | |
| 73 | + """Toutes les pages d'une collection (meta.totalPages).""" | |
| 74 | + params.setdefault("limit", 500) | |
| 75 | + out: list[dict] = [] | |
| 76 | + page = 1 | |
| 77 | + while True: | |
| 78 | + d = self._api(path, page=page, **params) | |
| 79 | + out.extend(d.get("data") or []) | |
| 80 | + meta = d.get("meta") or {} | |
| 81 | + total = meta.get("totalPages") or 1 | |
| 82 | + if page >= total: | |
| 83 | + return out | |
| 84 | + page += 1 | |
| 85 | + | |
| 86 | + # -- villes : cityId → (nom, code province) --------------------------------- | |
| 87 | + def _cities(self) -> dict[int, tuple[str, str]]: | |
| 88 | + cities: dict[int, tuple[str, str]] = {} | |
| 89 | + try: | |
| 90 | + for c in self._api_all("cities/property-summary", limit=100): | |
| 91 | + cid = c.get("cityId") | |
| 92 | + if cid is not None: | |
| 93 | + cities[int(cid)] = ((c.get("cityName") or "").strip(), | |
| 94 | + (c.get("provinceCode") or "").strip().upper()) | |
| 95 | + except Exception: | |
| 96 | + pass # repli : ville dérivée du permaLink dans _listing | |
| 97 | + return cities | |
| 98 | + | |
| 99 | + def fetch(self) -> list[Listing]: | |
| 100 | + cities = self._cities() | |
| 101 | + props = self._api_all("properties")[: self.max_properties] | |
| 102 | + by_id = {int(p["id"]): p for p in props if p.get("id") is not None} | |
| 103 | + | |
| 104 | + # types d'unités actifs, par lots d'immeubles (séparateur « | ») | |
| 105 | + units: list[dict] = [] | |
| 106 | + ids = list(by_id) | |
| 107 | + for i in range(0, len(ids), self.chunk_size): | |
| 108 | + chunk = "|".join(str(x) for x in ids[i:i + self.chunk_size]) | |
| 109 | + try: | |
| 110 | + units.extend(self._api_all( | |
| 111 | + "units", where=f"buildingId~in:{chunk},status~in:enabled")) | |
| 112 | + except Exception: | |
| 113 | + continue | |
| 114 | + | |
| 115 | + # regrouper les unités DISPONIBLES par (immeuble, type, cc, sdb) — | |
| 116 | + # certains immeubles publient une ligne par logement individuel | |
| 117 | + groups: dict[tuple, list[dict]] = {} | |
| 118 | + for u in units: | |
| 119 | + if not u.get("available") or u.get("hideSuiteTypeWebsite"): | |
| 120 | + continue | |
| 121 | + bid = int(u.get("buildingId") or 0) | |
| 122 | + if bid not in by_id: | |
| 123 | + continue | |
| 124 | + key = (bid, _slug(u.get("typeName") or ""), | |
| 125 | + u.get("bed"), u.get("bath")) | |
| 126 | + groups.setdefault(key, []).append(u) | |
| 127 | + | |
| 128 | + listings: list[Listing] = [] | |
| 129 | + for key, grp in groups.items(): | |
| 130 | + try: | |
| 131 | + listings.append(self._listing(by_id[key[0]], grp, cities)) | |
| 132 | + except Exception: | |
| 133 | + continue | |
| 134 | + return listings | |
| 135 | + | |
| 136 | + # -- une annonce par type d'unité disponible dans un immeuble --------------- | |
| 137 | + def _listing(self, p: dict, grp: list[dict], | |
| 138 | + cities: dict[int, tuple[str, str]]) -> Listing: | |
| 139 | + pid = int(p["id"]) | |
| 140 | + u0 = grp[0] | |
| 141 | + type_name = (u0.get("typeName") or "").strip() | |
| 142 | + name = (p.get("buildingName") or "").strip() | |
| 143 | + perma = (p.get("permaLink") or "").strip() | |
| 144 | + url = f"{BASE}/residential/{perma}" if perma else BASE | |
| 145 | + | |
| 146 | + # ville : mapping cityId → nom officiel ; repli = dernier segment du slug | |
| 147 | + city, prov = cities.get(int(p.get("cityId") or 0), ("", "ON")) | |
| 148 | + if not city and perma: | |
| 149 | + city = perma.rsplit("-", 1)[-1].replace("-", " ").title() | |
| 150 | + | |
| 151 | + # adresse complète : rue + ville + ON + code postal | |
| 152 | + street = " ".join(x for x in ((p.get("streetNumber") or "").strip(), | |
| 153 | + (p.get("streetName") or "").strip()) if x) | |
| 154 | + postal = (p.get("postal") or "").strip() | |
| 155 | + full_addr = ", ".join(x for x in (street, city) if x) | |
| 156 | + if full_addr: | |
| 157 | + full_addr += f", ON {postal}".rstrip() | |
| 158 | + | |
| 159 | + # coordonnées GPS structurées de la passerelle | |
| 160 | + try: | |
| 161 | + lat = float(p["latitude"]) if p.get("latitude") else None | |
| 162 | + lng = float(p["longitude"]) if p.get("longitude") else None | |
| 163 | + except (TypeError, ValueError): | |
| 164 | + lat = lng = None | |
| 165 | + | |
| 166 | + # prix : plus bas tarif affichable du groupe (0 = prix masqué) | |
| 167 | + rates = [] | |
| 168 | + for u in grp: | |
| 169 | + if u.get("hideRateWebsites"): | |
| 170 | + continue | |
| 171 | + try: | |
| 172 | + r = float(u.get("rate") or 0) | |
| 173 | + except (TypeError, ValueError): | |
| 174 | + r = 0.0 | |
| 175 | + if r > 0: | |
| 176 | + rates.append(r) | |
| 177 | + price = min(rates) if rates else None | |
| 178 | + price_label = "" | |
| 179 | + if price is not None: | |
| 180 | + price_label = (f"À partir de {price:.0f} $" | |
| 181 | + if len(grp) > 1 or (rates and max(rates) != price) | |
| 182 | + else f"{price:.0f} $ /mois") | |
| 183 | + | |
| 184 | + # disponibilité : plus proche date du groupe (None = maintenant) | |
| 185 | + dates = sorted(str(u.get("availabilityDate") or "")[:10] | |
| 186 | + for u in grp if _ISO_DATE.match( | |
| 187 | + str(u.get("availabilityDate") or ""))) | |
| 188 | + avail_date = dates[0] if dates else None | |
| 189 | + availability = (f"Disponible le {avail_date}" if avail_date | |
| 190 | + else "Disponible maintenant") | |
| 191 | + | |
| 192 | + # superficie : plus petite valeur plausible du groupe | |
| 193 | + area = None | |
| 194 | + for u in grp: | |
| 195 | + for k in ("sqFt", "sqFtMin"): | |
| 196 | + try: | |
| 197 | + v = float(u.get(k) or 0) | |
| 198 | + except (TypeError, ValueError): | |
| 199 | + continue | |
| 200 | + if 80 <= v <= 20000 and (area is None or v < area): | |
| 201 | + area = v | |
| 202 | + | |
| 203 | + # chambres / salles de bain : champs structurés de l'unité | |
| 204 | + try: | |
| 205 | + bedrooms = float(u0["bed"]) if u0.get("bed") is not None else None | |
| 206 | + bathrooms = float(u0["bath"]) if u0.get("bath") is not None else None | |
| 207 | + except (TypeError, ValueError): | |
| 208 | + bedrooms = bathrooms = None | |
| 209 | + unit_type = ("Studio" if bedrooms == 0 | |
| 210 | + else normalize_unit_type(type_name)) | |
| 211 | + | |
| 212 | + # animaux : indicateurs structurés de l'immeuble | |
| 213 | + if p.get("petsNotAllowed"): | |
| 214 | + pets = "non" | |
| 215 | + elif p.get("petFriendly"): | |
| 216 | + pets = "oui" | |
| 217 | + else: | |
| 218 | + pets = None | |
| 219 | + | |
| 220 | + # description : aperçu de l'immeuble + détails de suites (HTML → texte) | |
| 221 | + desc = " ".join(x for x in ( | |
| 222 | + _txt(p.get("buildingOverview") or ""), | |
| 223 | + _txt(p.get("suiteDetails") or ""), | |
| 224 | + ) if x)[:800] | |
| 225 | + | |
| 226 | + # commodités : caractéristiques de l'immeuble + services inclus (fiche) | |
| 227 | + amenities: list[str] = [] | |
| 228 | + for t in re.split(r"[\n;•]|</li>|<li>", | |
| 229 | + p.get("buildingFeatures") or ""): | |
| 230 | + t = _txt(t) | |
| 231 | + if t and t not in amenities: | |
| 232 | + amenities.append(t) | |
| 233 | + | |
| 234 | + # champs structurés | |
| 235 | + details: dict = {} | |
| 236 | + contact: dict = {} | |
| 237 | + if (p.get("phone") or "").strip(): | |
| 238 | + contact["phone"] = p["phone"].strip() | |
| 239 | + if (p.get("email") or "").strip(): | |
| 240 | + contact["email"] = p["email"].strip() | |
| 241 | + if contact: | |
| 242 | + details["contact"] = contact | |
| 243 | + if (p.get("neighbourhood") or "").strip(): | |
| 244 | + details["Quartier"] = p["neighbourhood"].strip() | |
| 245 | + | |
| 246 | + # fiche (galerie photo + services inclus) via le cache BD : revisitée | |
| 247 | + # seulement quand l'immeuble change (horodatage « modified ») | |
| 248 | + det_key = hashlib.sha1(str(p.get("modified") or "").encode()).hexdigest() | |
| 249 | + d = self.detail(f"b{pid}", det_key, lambda: self._fetch_detail(pid)) | |
| 250 | + for t in d.get("utilities") or []: | |
| 251 | + t = f"{t} incluse" if t in ("Eau", "Électricité") else t | |
| 252 | + if t and t not in amenities: | |
| 253 | + amenities.append(t) | |
| 254 | + images = list(d.get("images") or []) | |
| 255 | + | |
| 256 | + return Listing( | |
| 257 | + source=self.source_id, | |
| 258 | + external_id=f"{pid}-{_slug(type_name) or 'unite'}-" | |
| 259 | + f"{u0.get('bed')}cc-{u0.get('bath')}sdb", | |
| 260 | + url=url, | |
| 261 | + title=f"{name} — {type_name}" if type_name else name, | |
| 262 | + address=full_addr, | |
| 263 | + sector=(p.get("neighbourhood") or "").strip(), | |
| 264 | + city=city, | |
| 265 | + province=prov or "ON", | |
| 266 | + unit_type=unit_type, | |
| 267 | + bedrooms=bedrooms, | |
| 268 | + bathrooms=bathrooms, | |
| 269 | + price=price, | |
| 270 | + price_label=price_label, | |
| 271 | + availability=availability, | |
| 272 | + availability_date=avail_date, | |
| 273 | + area_sqft=area, | |
| 274 | + pets=pets, | |
| 275 | + description=desc, | |
| 276 | + amenities=amenities[:25], | |
| 277 | + details=details, | |
| 278 | + images=images[: self.max_images], | |
| 279 | + lat=lat, | |
| 280 | + lng=lng, | |
| 281 | + ) | |
| 282 | + | |
| 283 | + # -- fiche immeuble : galerie photo + services inclus (2 appels, cachés) ---- | |
| 284 | + def _fetch_detail(self, pid: int) -> dict: | |
| 285 | + out: dict = {"images": [], "utilities": []} | |
| 286 | + # traductions FR des services inclus les plus fréquents | |
| 287 | + fr = {"Water": "Eau", "Heat": "Chauffage", "Hydro": "Électricité", | |
| 288 | + "Electricity": "Électricité", "Internet": "Internet", | |
| 289 | + "Cable": "Câble"} | |
| 290 | + try: | |
| 291 | + photos = self._api_all(f"properties/{pid}/photos", limit=100) | |
| 292 | + except Exception: | |
| 293 | + photos = [] | |
| 294 | + photos = [ph for ph in photos | |
| 295 | + if ph.get("active") and (ph.get("image") or "").strip()] | |
| 296 | + photos.sort(key=lambda ph: (0 if ph.get("mainGallery") else 1, | |
| 297 | + ph.get("orderBy") or 0)) | |
| 298 | + for ph in photos: | |
| 299 | + u = f"{IMG_BASE}/{ph['image'].strip()}" | |
| 300 | + if u not in out["images"]: | |
| 301 | + out["images"].append(u) | |
| 302 | + out["images"] = out["images"][: self.max_images] | |
| 303 | + try: | |
| 304 | + for ut in self._api_all(f"properties/{pid}/utilities", limit=50): | |
| 305 | + t = (ut.get("name") or "").strip() | |
| 306 | + if t: | |
| 307 | + out["utilities"].append(fr.get(t, t)) | |
| 308 | + except Exception: | |
| 309 | + pass | |
| 310 | + return out | |
modified
louka/connectors/interrent.py
+66 −9
@@ -7,13 +7,19 @@ | ||
| 7 | 7 | # (adresse, quartier, photos, commodités Amenities/Utilities, animaux |
| 8 | 8 | # PetFriendly*, stationnement, contact, description via référence $NN) |
| 9 | 9 | # avec leurs suites disponibles (type, chambres, sdb, pi², loyer, date). |
| 10 | −# Une seule requête suffit. | |
| 10 | +# Une seule requête suffit : le flux RSC embarque TOUTES les communautés | |
| 11 | +# du REIT (QC, ON, BC), peu importe la ville demandée dans l'URL. | |
| 11 | 12 | # REIT pancanadien : filtre strict sur les villes du Grand Montréal. |
| 13 | +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1, | |
| 14 | +# les communautés ON des marchés cibles (Toronto/GTA, Ottawa, Hamilton, | |
| 15 | +# Kitchener-Waterloo) sont aussi conservées (province="ON", adresse | |
| 16 | +# suffixée « ON »). Sans la variable, comportement strictement identique. | |
| 12 | 17 | # ----------------------------------------------------------------------------- |
| 13 | 18 | from __future__ import annotations |
| 14 | 19 | |
| 15 | 20 | import html as htmllib |
| 16 | 21 | import json |
| 22 | +import os | |
| 17 | 23 | import re |
| 18 | 24 | |
| 19 | 25 | from ..schema import Listing, strip_accents |
@@ -22,6 +28,9 @@ from .base import BaseConnector | ||
| 22 | 28 | BASE = "https://www.irent.com" |
| 23 | 29 | CITY_URL = f"{BASE}/communities/city/montreal" |
| 24 | 30 | |
| 31 | +# Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, QC seulement | |
| 32 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 33 | + | |
| 25 | 34 | CHUNK_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)') |
| 26 | 35 | OBJ_START_RE = re.compile(r'\{"Id":\d+,"ImportId"') |
| 27 | 36 | |
@@ -39,6 +48,40 @@ _GM_CITIES = { | ||
| 39 | 48 | "dollard-des-ormeaux": "Dollard-des-Ormeaux", |
| 40 | 49 | } |
| 41 | 50 | |
| 51 | +# Villes admissibles en Ontario (expansion 2026-08, sous LOUKA_ONTARIO) : | |
| 52 | +# marchés cibles Toronto/GTA, Ottawa, Hamilton, Kitchener-Waterloo. | |
| 53 | +# clé sans accents/minuscules -> (ville normalisée, secteur imposé ou None) ; | |
| 54 | +# les anciens boroughs de Toronto/Ottawa sont repliés sur la ville fusionnée. | |
| 55 | +_ON_CITIES = { | |
| 56 | + "toronto": ("Toronto", None), | |
| 57 | + "etobicoke": ("Toronto", "Etobicoke"), | |
| 58 | + "north york": ("Toronto", "North York"), | |
| 59 | + "york": ("Toronto", "York"), | |
| 60 | + "east york": ("Toronto", "East York"), | |
| 61 | + "scarborough": ("Toronto", "Scarborough"), | |
| 62 | + "mississauga": ("Mississauga", None), | |
| 63 | + "brampton": ("Brampton", None), | |
| 64 | + "markham": ("Markham", None), | |
| 65 | + "vaughan": ("Vaughan", None), | |
| 66 | + "richmond hill": ("Richmond Hill", None), | |
| 67 | + "ajax": ("Ajax", None), | |
| 68 | + "pickering": ("Pickering", None), | |
| 69 | + "whitby": ("Whitby", None), | |
| 70 | + "oshawa": ("Oshawa", None), | |
| 71 | + "oakville": ("Oakville", None), | |
| 72 | + "burlington": ("Burlington", None), | |
| 73 | + "milton": ("Milton", None), | |
| 74 | + "hamilton": ("Hamilton", None), | |
| 75 | + "ottawa": ("Ottawa", None), | |
| 76 | + "nepean": ("Ottawa", "Nepean"), | |
| 77 | + "kanata": ("Ottawa", "Kanata"), | |
| 78 | + "gloucester": ("Ottawa", "Gloucester"), | |
| 79 | + "orleans": ("Ottawa", "Orléans"), | |
| 80 | + "kitchener": ("Kitchener", None), | |
| 81 | + "waterloo": ("Waterloo", None), | |
| 82 | + "cambridge": ("Cambridge", None), | |
| 83 | +} | |
| 84 | + | |
| 42 | 85 | _BED_TYPES = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"} |
| 43 | 86 | |
| 44 | 87 | |
@@ -174,23 +217,37 @@ class InterrentConnector(BaseConnector): | ||
| 174 | 217 | refs: dict[str, str] | None = None) -> list[Listing]: |
| 175 | 218 | refs = refs or {} |
| 176 | 219 | loc = c.get("Location") or {} |
| 177 | − if (loc.get("ProvinceCode") or "").upper() != "QC": | |
| 178 | − return [] | |
| 220 | + prov = (loc.get("ProvinceCode") or "").upper() | |
| 179 | 221 | raw_city = _clean(loc.get("City") or "") |
| 180 | 222 | key = strip_accents(raw_city.lower()).strip() |
| 181 | − city = _GM_CITIES.get(key) | |
| 182 | − if not city: | |
| 183 | − return [] # hors Grand Montréal | |
| 223 | + forced_sector = None | |
| 224 | + if prov == "QC": | |
| 225 | + city = _GM_CITIES.get(key) | |
| 226 | + if not city: | |
| 227 | + return [] # hors Grand Montréal | |
| 228 | + elif prov == "ON" and _ONTARIO: | |
| 229 | + # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO) | |
| 230 | + city, forced_sector = _ON_CITIES.get(key, (None, None)) | |
| 231 | + if not city: | |
| 232 | + return [] # hors marchés cibles ON | |
| 233 | + else: | |
| 234 | + return [] | |
| 184 | 235 | sector = _clean(loc.get("Neighbourhood") or c.get("TagLine") or "") |
| 185 | 236 | if strip_accents(sector.lower()) == strip_accents(city.lower()): |
| 186 | 237 | sector = "" if city != "Montréal" else sector |
| 187 | − if key in ("cote-saint-luc", "verdun", "lasalle") and not sector: | |
| 238 | + if prov == "QC" and key in ("cote-saint-luc", "verdun", "lasalle") \ | |
| 239 | + and not sector: | |
| 188 | 240 | sector = raw_city if city == "Montréal" else "" |
| 241 | + if forced_sector and not sector: | |
| 242 | + sector = forced_sector # ex. Etobicoke -> Toronto / Etobicoke | |
| 189 | 243 | |
| 190 | 244 | name = _clean(c.get("Name") or "") |
| 191 | 245 | url = c.get("Url") or f"{BASE}/communities/{c.get('PermaLink', '')}" |
| 192 | 246 | address = _clean(loc.get("Address") or "") |
| 193 | 247 | postal = _clean(loc.get("PostalCode") or "") |
| 248 | + # adresse complète : suffixe « ON » pour l'Ontario (QC inchangé) | |
| 249 | + last = f"ON {postal}".strip() if prov == "ON" else postal | |
| 250 | + full_address = ", ".join(x for x in [address, city, last] if x) | |
| 194 | 251 | |
| 195 | 252 | # Photos de la communauté |
| 196 | 253 | images: list[str] = [] |
@@ -339,10 +396,10 @@ class InterrentConnector(BaseConnector): | ||
| 339 | 396 | external_id=str(s.get("Id")), |
| 340 | 397 | url=url, |
| 341 | 398 | title=label, |
| 342 | − address=", ".join(x for x in [address, city, postal] | |
| 343 | − if x), | |
| 399 | + address=full_address, | |
| 344 | 400 | sector=sector, |
| 345 | 401 | city=city, |
| 402 | + province=prov, | |
| 346 | 403 | unit_type=unit_type, |
| 347 | 404 | price=price, |
| 348 | 405 | price_label=f"{int(rate)} $/mois" if price else "", |
added
louka/connectors/killam.py
+343 −0
@@ -0,0 +1,343 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/killam.py : connecteur Killam Apartment REIT (killamreit.com) | |
| 5 | +# REIT national (~17 000 unités, Maritimes + Ontario + Alberta/C.-B.) — on ne | |
| 6 | +# garde ici QUE l'Ontario : Toronto, Ottawa (Kanata), London, Kitchener- | |
| 7 | +# Waterloo, Cambridge, Mississauga (~29 propriétés au moment de l'écriture). | |
| 8 | +# Le site est un « corporate site » Yardi RentCafe derrière Cloudflare | |
| 9 | +# (challenge JS : curl nu = 403 « Just a moment… »). Aucune API interne à | |
| 10 | +# rejouer : la page de recherche /apartments embarque TOUT le portefeuille | |
| 11 | +# (204 propriétés) dans un input caché `#available_prop` — un JSON doublement | |
| 12 | +# encodé (chaîne JSON contenant du JSON aux guillemets "). On le décode | |
| 13 | +# avec deux json.loads, sans exécuter de JavaScript : Scrapfly ASP suffit | |
| 14 | +# (asp=true, PAS de render_js), via la chaîne de secours de BaseConnector.get | |
| 15 | +# (Oxylabs résidentiel → Scrapfly → Bright Data). | |
| 16 | +# Le flux liste donne nom, adresse civique, ville/province/code postal, | |
| 17 | +# lat/lng, fourchettes loyer/chambres/sdb/pi², politique animaux, téléphone | |
| 18 | +# et l'URL de la fiche. La fiche propriété (rendu serveur, même anti-bot) | |
| 19 | +# fournit description, galerie photo (resource.rentcafe.com) et les cartes | |
| 20 | +# « plans d'étage » (nom, chambres, sdb, pi², « Starting at $X /Month ») — | |
| 21 | +# visitée via le cache BD self.detail() (clé = hash de la ligne du flux). | |
| 22 | +# On émet UNE annonce PAR PLAN D'ÉTAGE (granularité type d'unité), avec un | |
| 23 | +# repli « une annonce par propriété » si les cartes sont introuvables. | |
| 24 | +# ----------------------------------------------------------------------------- | |
| 25 | +from __future__ import annotations | |
| 26 | + | |
| 27 | +import hashlib | |
| 28 | +import json | |
| 29 | +import os | |
| 30 | +import re | |
| 31 | + | |
| 32 | +from bs4 import BeautifulSoup | |
| 33 | + | |
| 34 | +from ..schema import Listing, normalize_unit_type | |
| 35 | +from .base import BaseConnector | |
| 36 | + | |
| 37 | +BASE = "https://www.killamreit.com" | |
| 38 | +SEARCH_PAGE = f"{BASE}/apartments" | |
| 39 | + | |
| 40 | +# Gate expansion Ontario : le connecteur reste hors registre tant que la | |
| 41 | +# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée (zéro impact prod). | |
| 42 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 43 | + | |
| 44 | +# blob JSON du portefeuille dans la page de recherche (input caché) | |
| 45 | +_PROP_BLOB_RE = re.compile( | |
| 46 | + r"id=\"available_prop\"\s+value='(.*?)'", re.S) | |
| 47 | + | |
| 48 | +# cartes plans d'étage de la fiche : « 1 Bed », « 1.5 Bath », « 415 Sq. Ft. » | |
| 49 | +_BED_RE = re.compile(r"(\d+)\s*Bed") | |
| 50 | +_BATH_RE = re.compile(r"([\d.]+)\s*Bath") | |
| 51 | +_SQFT_RE = re.compile(r"([\d,]+)\s*Sq\.?\s*Ft", re.I) | |
| 52 | +_PRICE_RE = re.compile(r"\$\s*([\d,]+)") | |
| 53 | +_FPID_RE = re.compile(r"#fp-modal-(\d+)") | |
| 54 | +# disclaimer promo servi DANS le paragraphe descriptif — retiré, pas interprété | |
| 55 | +_DISCLAIMER_RE = re.compile( | |
| 56 | + r"Limited-time offer[^.]*\.\s*(Subject to [^.]*\.\s*)?", re.I) | |
| 57 | +_BOILERPLATE_RE = re.compile( | |
| 58 | + r"marketing text|cookie|consent|unsubscribe|privacy policy", re.I) | |
| 59 | + | |
| 60 | +# IDs de commodités RentCafe (checkboxes du filtre corpsearch) -> libellé FR | |
| 61 | +_AMENITY_FR = { | |
| 62 | + "1": "Air climatisé", "2": "Lave-vaisselle", "3": "Espace de rangement", | |
| 63 | + "7": "Planchers de bois franc", "8": "Grands placards", | |
| 64 | + "9": "Balcon/terrasse", "10": "Laveuse-sécheuse", | |
| 65 | + "11": "Entrées laveuse-sécheuse", "12": "Accès fauteuil roulant", | |
| 66 | + "13": "Aire BBQ/pique-nique", "14": "Centre d'affaires", | |
| 67 | + "15": "Salle de réception", "16": "Accès contrôlé", | |
| 68 | + "17": "Stationnement couvert", "18": "Garage", | |
| 69 | + "19": "Internet haute vitesse", "20": "Spa", "21": "Buanderie", | |
| 70 | + "22": "Salle multimédia", "23": "Bail court terme", "24": "Piscine", | |
| 71 | + "25": "Salle d'entraînement", "26": "Terrain de basketball", | |
| 72 | + "27": "Terrain de tennis", "28": "Service de garde", "29": "Aire de jeux", | |
| 73 | + "30": "Supports à vélos", "31": "Électroménagers écoénergétiques", | |
| 74 | + "32": "Thermostat électronique", "33": "Bâtiment écologique", | |
| 75 | + "34": "Transport en commun à proximité", "35": "Recyclage", | |
| 76 | +} | |
| 77 | + | |
| 78 | +# villes du flux à normaliser (le flux écrit parfois « Kanata Ottawa ») | |
| 79 | +_CITY_FIX = { | |
| 80 | + "kanata ottawa": ("Ottawa", "Kanata"), | |
| 81 | + "kanata-ottawa": ("Ottawa", "Kanata"), | |
| 82 | +} | |
| 83 | + | |
| 84 | + | |
| 85 | +def _money(txt: str) -> float | None: | |
| 86 | + m = _PRICE_RE.search(txt or "") | |
| 87 | + return float(m.group(1).replace(",", "")) if m else None | |
| 88 | + | |
| 89 | + | |
| 90 | +class KillamConnector(BaseConnector): | |
| 91 | + source_id = "killam" | |
| 92 | + request_delay = 1.2 # Cloudflare/Scrapfly : rester très poli | |
| 93 | + disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1) | |
| 94 | + max_properties = 40 # garde-fou (29 propriétés ON aujourd'hui) | |
| 95 | + max_images = 20 | |
| 96 | + | |
| 97 | + # -- portefeuille : blob embarqué de la page de recherche ------------------- | |
| 98 | + def _properties(self) -> list[dict]: | |
| 99 | + page = self.get(SEARCH_PAGE).text | |
| 100 | + m = _PROP_BLOB_RE.search(page) | |
| 101 | + if not m: | |
| 102 | + raise RuntimeError("Killam : blob #available_prop introuvable " | |
| 103 | + "(gabarit RentCafe modifié ?)") | |
| 104 | + # doublement encodé : value='"[{"companyid":0,…}]"' | |
| 105 | + return json.loads(json.loads(m.group(1))) | |
| 106 | + | |
| 107 | + def fetch(self) -> list[Listing]: | |
| 108 | + props = self._properties() | |
| 109 | + listings: list[Listing] = [] | |
| 110 | + count = 0 | |
| 111 | + for p in props: | |
| 112 | + if (p.get("propertyState") or "").strip().upper() != "ON": | |
| 113 | + continue # REIT national : Ontario seulement ici | |
| 114 | + if count >= self.max_properties: | |
| 115 | + break | |
| 116 | + count += 1 | |
| 117 | + try: | |
| 118 | + listings.extend(self._listings(p)) | |
| 119 | + except Exception: | |
| 120 | + continue | |
| 121 | + return listings | |
| 122 | + | |
| 123 | + # -- annonces d'une propriété (une par plan d'étage, repli propriété) ------- | |
| 124 | + def _listings(self, p: dict) -> list[Listing]: | |
| 125 | + pid = str(p.get("propertyid")) | |
| 126 | + url = ((p.get("PropertySiteUrl") or p.get("LinkUrl") or "") | |
| 127 | + .strip().rstrip("?")) or SEARCH_PAGE | |
| 128 | + name = (p.get("propertyName") or "").strip() | |
| 129 | + | |
| 130 | + # adresse complète : rue + ville + « ON » + code postal | |
| 131 | + city_raw = (p.get("propertyCity") or "").strip() | |
| 132 | + city, sector = _CITY_FIX.get(city_raw.lower(), (city_raw, "")) | |
| 133 | + street = (p.get("propertyAddress") or "").strip() | |
| 134 | + postal = (p.get("propertyZipCode") or "").strip() | |
| 135 | + full_addr = ", ".join(x for x in (street, city) if x) + ", ON" | |
| 136 | + if postal: | |
| 137 | + full_addr += f" {postal}" | |
| 138 | + | |
| 139 | + # coordonnées GPS du flux (finalize() valide la bbox Ontario) | |
| 140 | + try: | |
| 141 | + lat = float(p.get("propertyLat")) or None | |
| 142 | + lng = float(p.get("propertyLng")) or None | |
| 143 | + except (TypeError, ValueError): | |
| 144 | + lat = lng = None | |
| 145 | + | |
| 146 | + # politique animaux : {"bNoPetsAllowed":false,"bCats":true,"bDogs":true} | |
| 147 | + pets = None | |
| 148 | + try: | |
| 149 | + pol = json.loads(p.get("bPetPolicy") or "{}") | |
| 150 | + if pol.get("bNoPetsAllowed") is True: | |
| 151 | + pets = "non" | |
| 152 | + elif pol.get("bCats") and pol.get("bDogs"): | |
| 153 | + pets = "oui" | |
| 154 | + elif pol.get("bCats") or pol.get("bDogs"): | |
| 155 | + pets = "conditions" | |
| 156 | + except (ValueError, TypeError): | |
| 157 | + pass | |
| 158 | + | |
| 159 | + # commodités : IDs RentCafe -> libellés français | |
| 160 | + amenities = [_AMENITY_FR[a] for a in (p.get("sAmenity") or []) | |
| 161 | + if a in _AMENITY_FR] | |
| 162 | + | |
| 163 | + # disponibilité : le flux n'expose qu'un booléen d'occupation + une | |
| 164 | + # date minimale (« 1/1/0001 » = aucune affichée) — rien d'inventé | |
| 165 | + occupied = p.get("IsFullyOccupied") is True | |
| 166 | + availability = ("Aucune unité disponible" if occupied | |
| 167 | + else "Unités disponibles") | |
| 168 | + avail_date = None | |
| 169 | + mad = str(p.get("dtMinUnitAvailable") or "").strip() | |
| 170 | + md = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(20\d{2})", mad) | |
| 171 | + if md: | |
| 172 | + avail_date = f"{md.group(3)}-{int(md.group(1)):02d}-{int(md.group(2)):02d}" | |
| 173 | + | |
| 174 | + details: dict = {} | |
| 175 | + phone = (p.get("phone") or "").strip() | |
| 176 | + if phone: | |
| 177 | + details["contact"] = {"phone": phone} | |
| 178 | + | |
| 179 | + # fiche propriété (description, galerie, plans d'étage) — via le cache | |
| 180 | + # BD : revisitée seulement quand la ligne du flux change | |
| 181 | + feed_key = hashlib.sha1("|".join(str(x) for x in ( | |
| 182 | + p.get("propertyMinRent"), p.get("propertyMaxRent"), | |
| 183 | + p.get("propertyMinBed"), p.get("propertyMaxBed"), | |
| 184 | + p.get("propertyMinArea"), p.get("propertyMaxArea"), | |
| 185 | + p.get("IsFullyOccupied"), mad, p.get("propertyThumb"), | |
| 186 | + )).encode("utf-8")).hexdigest() | |
| 187 | + d = self.detail(pid, feed_key, lambda: self._fetch_detail(url)) | |
| 188 | + | |
| 189 | + desc = d.get("description") or "" | |
| 190 | + images = list(d.get("images") or []) | |
| 191 | + thumb = (p.get("propertyThumb") or "").strip() | |
| 192 | + if thumb and thumb not in images: | |
| 193 | + images.insert(0, thumb) | |
| 194 | + images = images[: self.max_images] | |
| 195 | + | |
| 196 | + common = dict( | |
| 197 | + source=self.source_id, url=url, address=full_addr, sector=sector, | |
| 198 | + city=city, province="ON", availability=availability, | |
| 199 | + availability_date=avail_date, pets=pets, description=desc, | |
| 200 | + amenities=amenities[:25], images=images, lat=lat, lng=lng, | |
| 201 | + ) | |
| 202 | + | |
| 203 | + # une annonce par plan d'étage (prix/chambres/sdb/pi² précis) | |
| 204 | + out: list[Listing] = [] | |
| 205 | + for fp in d.get("floorplans") or []: | |
| 206 | + price = fp.get("price") | |
| 207 | + beds = fp.get("beds") | |
| 208 | + out.append(Listing( | |
| 209 | + external_id=f"{pid}-{fp['fpid']}", | |
| 210 | + title=f"{name} — {fp['name']}" if fp.get("name") else name, | |
| 211 | + unit_type=("Studio" if beds == 0 else normalize_unit_type( | |
| 212 | + f"{int(beds)} chambres") if beds is not None else ""), | |
| 213 | + bedrooms=beds, | |
| 214 | + bathrooms=fp.get("baths"), | |
| 215 | + price=price, | |
| 216 | + price_label=(f"À partir de {price:.0f} $ /mois" | |
| 217 | + if price is not None else ""), | |
| 218 | + area_sqft=fp.get("sqft"), | |
| 219 | + details=dict(details), | |
| 220 | + **common, | |
| 221 | + )) | |
| 222 | + if out: | |
| 223 | + return out | |
| 224 | + | |
| 225 | + # repli : une annonce par propriété (fourchettes du flux liste) | |
| 226 | + price = None | |
| 227 | + try: | |
| 228 | + price = float(p.get("propertyMinRent") or 0) or None | |
| 229 | + except (TypeError, ValueError): | |
| 230 | + pass | |
| 231 | + price_label = "" | |
| 232 | + if price is not None: | |
| 233 | + try: | |
| 234 | + pmax = float(p.get("propertyMaxRent") or 0) | |
| 235 | + except (TypeError, ValueError): | |
| 236 | + pmax = 0.0 | |
| 237 | + price_label = (f"À partir de {price:.0f} $" | |
| 238 | + if pmax and pmax != price else f"{price:.0f} $ /mois") | |
| 239 | + beds = baths = None | |
| 240 | + try: | |
| 241 | + bmin, bmax = float(p.get("propertyMinBed") or -1), \ | |
| 242 | + float(p.get("propertyMaxBed") or -1) | |
| 243 | + if bmin >= 0 and bmin == bmax: | |
| 244 | + beds = bmin | |
| 245 | + if float(p.get("propertyMinBath") or 0) > 0: | |
| 246 | + baths = float(p["propertyMinBath"]) | |
| 247 | + except (TypeError, ValueError): | |
| 248 | + pass | |
| 249 | + area = None | |
| 250 | + try: | |
| 251 | + v = float(p.get("propertyMinArea") or 0) | |
| 252 | + if 80 <= v <= 20000: | |
| 253 | + area = v | |
| 254 | + except (TypeError, ValueError): | |
| 255 | + pass | |
| 256 | + return [Listing( | |
| 257 | + external_id=pid, | |
| 258 | + title=name, | |
| 259 | + unit_type=("Studio" if beds == 0 else normalize_unit_type( | |
| 260 | + f"{int(beds)} chambres") if beds is not None else ""), | |
| 261 | + bedrooms=beds, | |
| 262 | + bathrooms=baths, | |
| 263 | + price=price, | |
| 264 | + price_label=price_label, | |
| 265 | + area_sqft=area, | |
| 266 | + details=details, | |
| 267 | + **common, | |
| 268 | + )] | |
| 269 | + | |
| 270 | + # -- fiche propriété : description + galerie + plans d'étage ---------------- | |
| 271 | + def _fetch_detail(self, url: str) -> dict: | |
| 272 | + out: dict = {"description": "", "images": [], "floorplans": []} | |
| 273 | + try: | |
| 274 | + page = self.get(url).text | |
| 275 | + except Exception: | |
| 276 | + return out | |
| 277 | + soup = BeautifulSoup(page, "html.parser") | |
| 278 | + | |
| 279 | + # plans d'étage : cartes serveur « Nom / N Bed / N Bath / N Sq. Ft. / | |
| 280 | + # Starting at $X /Month » (modal #fp-modal-<id> = identifiant stable) | |
| 281 | + fp_imgs: set[str] = set() | |
| 282 | + for card in soup.select(".card"): | |
| 283 | + txt = card.get_text(" ", strip=True) | |
| 284 | + if "Bed" not in txt and "Studio" not in txt: | |
| 285 | + continue | |
| 286 | + ref = card.find(attrs={"data-target": _FPID_RE}) | |
| 287 | + mid = _FPID_RE.search(ref["data-target"]) if ref else None | |
| 288 | + if not mid: | |
| 289 | + continue | |
| 290 | + h = card.find(["h2", "h3", "h4"]) | |
| 291 | + mb = _BED_RE.search(txt) | |
| 292 | + beds = (float(mb.group(1)) if mb | |
| 293 | + else 0.0 if "Studio" in txt else None) | |
| 294 | + mba = _BATH_RE.search(txt) | |
| 295 | + msq = _SQFT_RE.search(txt) | |
| 296 | + sqft = float(msq.group(1).replace(",", "")) if msq else None | |
| 297 | + self._append_fp(out, { | |
| 298 | + "fpid": mid.group(1), | |
| 299 | + "name": h.get_text(" ", strip=True) if h else "", | |
| 300 | + "beds": beds, | |
| 301 | + "baths": float(mba.group(1)) if mba else None, | |
| 302 | + "sqft": sqft if sqft and 80 <= sqft <= 20000 else None, | |
| 303 | + "price": _money(txt), | |
| 304 | + }) | |
| 305 | + for img in card.find_all("img"): | |
| 306 | + src = img.get("src") or img.get("data-src") or "" | |
| 307 | + if src: | |
| 308 | + fp_imgs.add(src) | |
| 309 | + | |
| 310 | + # galerie : visuels resource.rentcafe.com HORS logos et plans d'étage | |
| 311 | + images: list[str] = [] | |
| 312 | + for img in soup.find_all("img"): | |
| 313 | + src = (img.get("src") or img.get("data-src") or "").strip() | |
| 314 | + if ("resource.rentcafe.com" not in src or src in fp_imgs | |
| 315 | + or img.find_parent(class_=re.compile(r"ysi-logo"))): | |
| 316 | + continue | |
| 317 | + if not img.find_parent(class_=re.compile(r"ysi-picture")): | |
| 318 | + continue | |
| 319 | + # variante généreuse de la transformation Cloudinary | |
| 320 | + src = re.sub(r"c_l(?:fill|imit),w_\d+(?:,h_\d+)?", | |
| 321 | + "c_limit,w_1200", src) | |
| 322 | + if src not in images: | |
| 323 | + images.append(src) | |
| 324 | + out["images"] = images[: self.max_images] | |
| 325 | + | |
| 326 | + # description : paragraphes de présentation (sans le boilerplate | |
| 327 | + # consentement/cookies ni le disclaimer promo) | |
| 328 | + paras = [] | |
| 329 | + for ptag in soup.find_all("p"): | |
| 330 | + t = ptag.get_text(" ", strip=True) | |
| 331 | + if len(t) < 150 or _BOILERPLATE_RE.search(t): | |
| 332 | + continue | |
| 333 | + t = _DISCLAIMER_RE.sub("", t).strip() | |
| 334 | + if t and t not in paras: | |
| 335 | + paras.append(t) | |
| 336 | + out["description"] = " ".join(paras)[:900] | |
| 337 | + return out | |
| 338 | + | |
| 339 | + @staticmethod | |
| 340 | + def _append_fp(out: dict, fp: dict) -> None: | |
| 341 | + """Ajoute un plan d'étage en dédoublonnant sur son identifiant.""" | |
| 342 | + if fp["fpid"] not in {f["fpid"] for f in out["floorplans"]}: | |
| 343 | + out["floorplans"].append(fp) | |
added
louka/connectors/liftsystem.py
+307 −0
@@ -0,0 +1,307 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/liftsystem.py : connecteur GÉNÉRIQUE Rentsync/LiftSystem | |
| 5 | +# Des dizaines de gestionnaires (surtout en Ontario) publient leur parc via | |
| 6 | +# la même plateforme Rentsync « classique », dont le flux JSON officiel est | |
| 7 | +# `https://api.theliftsystem.com/v2/search?client_id=<N>&auth_token=<token>`. | |
| 8 | +# Le token est universel (embarqué dans le /scripts/main.js de chaque site) ; | |
| 9 | +# seul le client_id change. Plutôt que N modules copiés-collés, ce module lit | |
| 10 | +# le registre data/liftsystem_clients.json ({id, name, site, client_id…}, | |
| 11 | +# découverts et validés en live le 2026-08-26) et GÉNÈRE une sous-classe de | |
| 12 | +# BaseConnector par client (source_id = "lift_<id>", ex. "lift_greenwin"), | |
| 13 | +# déposée dans les globals du module pour que le registre auto-découvrant | |
| 14 | +# (connectors/__init__.py) les enregistre toutes. Mapping identique au | |
| 15 | +# connecteur canonique centurion.py (même plateforme, client_id 21) : adresse | |
| 16 | +# complète, geocode, prix min « À partir de », galerie assets.rentsync.com | |
| 17 | +# via le cache BD self.detail() (revisitée quand la ligne du flux change). | |
| 18 | +# Différence : les flux sont pancanadiens — on ne garde que ON et QC, et le | |
| 19 | +# champ Listing.province est renseigné selon la propriété. | |
| 20 | +# | |
| 21 | +# Expansion Ontario — activer via LOUKA_ONTARIO=1 | |
| 22 | +# (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les | |
| 23 | +# classes générées sont `disabled` et donc exclues du registre / de la prod. | |
| 24 | +# ----------------------------------------------------------------------------- | |
| 25 | +from __future__ import annotations | |
| 26 | + | |
| 27 | +import hashlib | |
| 28 | +import json | |
| 29 | +import os | |
| 30 | +import re | |
| 31 | +from pathlib import Path | |
| 32 | + | |
| 33 | +from bs4 import BeautifulSoup | |
| 34 | + | |
| 35 | +from ..schema import Listing, infer_city, normalize_unit_type | |
| 36 | +from .base import BaseConnector | |
| 37 | + | |
| 38 | +# Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=1 | |
| 39 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 40 | + | |
| 41 | +REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \ | |
| 42 | + "liftsystem_clients.json" | |
| 43 | +LIFT_API = "https://api.theliftsystem.com/v2/search" | |
| 44 | + | |
| 45 | +# Jeton universel Rentsync/LiftSystem — le même pour tous les sites clients | |
| 46 | +# (vérifié dans le main.js de cpliving, parkproperty, williamsandmcdaniel, | |
| 47 | +# yorkproperty… et validé sur les 13 client_id du registre le 2026-08-26). | |
| 48 | +# Une entrée du registre peut le surcharger via sa clé "auth_token". | |
| 49 | +DEFAULT_AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" | |
| 50 | + | |
| 51 | +# Provinces couvertes par Lou-Ka — plusieurs clients sont pancanadiens | |
| 52 | +# (Shelter Canadian : MB/AB/SK…) : tout le reste est ignoré au fetch. | |
| 53 | +_PROVINCES = {"ON", "QC"} | |
| 54 | + | |
| 55 | +# Types de propriété NON résidentiels du flux (property_type, chaîne libre) — | |
| 56 | +# plusieurs clients mélangent bureaux/commerces (tarifs au pi², ex. « 18 $ ») | |
| 57 | +# et chantiers sans unités : hors sujet pour Lou-Ka (logements au mois). | |
| 58 | +_NON_RESIDENTIAL = { | |
| 59 | + "office", "retail", "warehouse", "industrial", "commercial", "land", | |
| 60 | + "construction", "motel", "hotel", "parking", "storage", | |
| 61 | +} | |
| 62 | + | |
| 63 | +# galerie de la fiche propriété (img + backgrounds CSS), comme centurion.py | |
| 64 | +_IMG_RE = re.compile( | |
| 65 | + r"https://assets\.rentsync\.com/[^\"'\\)\s]+\.(?:jpg|jpeg|png|webp)", re.I) | |
| 66 | +_SKIP_IMG = re.compile(r"logo|icon|favicon|badge|/thumb", re.I) | |
| 67 | + | |
| 68 | + | |
| 69 | +def _load_registry() -> list[dict]: | |
| 70 | + """Entrées validées du registre (client_id présent, status != a_verifier).""" | |
| 71 | + try: | |
| 72 | + data = json.loads(REGISTRY_PATH.read_text("utf-8")) | |
| 73 | + except (OSError, ValueError): | |
| 74 | + return [] | |
| 75 | + return [c for c in data.get("clients") or [] | |
| 76 | + if c.get("client_id") and c.get("status") == "valide"] | |
| 77 | + | |
| 78 | + | |
| 79 | +class LiftSystemConnector(BaseConnector): | |
| 80 | + """Base commune des connecteurs LiftSystem générés — non enregistrée | |
| 81 | + elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de | |
| 82 | + registre dans l'attribut de classe `client`.""" | |
| 83 | + | |
| 84 | + source_id = "" # les sous-classes générées le définissent | |
| 85 | + client: dict = {} # entrée du registre (name, site, client_id…) | |
| 86 | + request_delay = 0.7 # politesse — même rythme que centurion.py | |
| 87 | + max_properties = 400 # garde-fou (plus gros client : ~204 propriétés) | |
| 88 | + max_images = 20 | |
| 89 | + | |
| 90 | + def fetch(self) -> list[Listing]: | |
| 91 | + props = self.get(LIFT_API, params={ | |
| 92 | + "client_id": str(self.client["client_id"]), | |
| 93 | + "auth_token": self.client.get("auth_token") or DEFAULT_AUTH_TOKEN, | |
| 94 | + "show_all_properties": "true", | |
| 95 | + "show_custom_fields": "true", | |
| 96 | + "show_amenities": "true", | |
| 97 | + "show_promotions": "true", | |
| 98 | + "limit": "1000", | |
| 99 | + }, headers={"Accept": "application/json", | |
| 100 | + "Referer": (self.client.get("site") or "") + "/"}).json() | |
| 101 | + | |
| 102 | + listings: list[Listing] = [] | |
| 103 | + for p in props: | |
| 104 | + if len(listings) >= self.max_properties: # garde-fou APRÈS filtres | |
| 105 | + break | |
| 106 | + try: | |
| 107 | + addr = p.get("address") or {} | |
| 108 | + if (addr.get("province_code") or "").upper() not in _PROVINCES: | |
| 109 | + continue # flux pancanadiens : ON et QC seulement | |
| 110 | + ptype = str(p.get("property_type") or "").strip().lower() | |
| 111 | + if ptype in _NON_RESIDENTIAL: | |
| 112 | + continue # bureaux/commerces/chantiers : hors sujet | |
| 113 | + listings.append(self._listing(p)) | |
| 114 | + except Exception: | |
| 115 | + continue | |
| 116 | + return listings | |
| 117 | + | |
| 118 | + # -- une annonce par propriété (mapping aligné sur centurion.py) ----------- | |
| 119 | + def _listing(self, p: dict) -> Listing: | |
| 120 | + pid = str(p.get("id")) | |
| 121 | + addr = p.get("address") or {} | |
| 122 | + url = p.get("permalink") or self.client.get("listing_url") or \ | |
| 123 | + self.client.get("site") or "" | |
| 124 | + # certains clients publient des permaliens cassés (ex. Shelter : | |
| 125 | + # « /images/<slug> » → 404, la vraie fiche est « /residential-rental/ ») | |
| 126 | + # — correctif déclaré au registre : "permalink_sub": [avant, après] | |
| 127 | + sub = self.client.get("permalink_sub") or [] | |
| 128 | + if len(sub) == 2 and sub[0] in url: | |
| 129 | + url = url.replace(sub[0], sub[1], 1) | |
| 130 | + name = (p.get("name") or "").strip() | |
| 131 | + prov = (addr.get("province_code") or "").upper() | |
| 132 | + | |
| 133 | + # adresse complète : rue + ville + « , ON/QC <postal> » selon le flux | |
| 134 | + city = re.sub(r"\s+(?:ON|QC)$", "", | |
| 135 | + (addr.get("city") or "").strip(), flags=re.I) | |
| 136 | + street = (addr.get("address") or "").strip() | |
| 137 | + postal = (addr.get("postal_code") or "").strip() | |
| 138 | + full_addr = ", ".join(x for x in (street, city) if x) | |
| 139 | + if full_addr: | |
| 140 | + full_addr += f", {prov} {postal}".rstrip() | |
| 141 | + sector = (addr.get("neighbourhood") or "").strip() | |
| 142 | + | |
| 143 | + # coordonnées GPS structurées du flux | |
| 144 | + geo = p.get("geocode") or {} | |
| 145 | + try: | |
| 146 | + lat = float(geo["latitude"]) if geo.get("latitude") else None | |
| 147 | + lng = float(geo["longitude"]) if geo.get("longitude") else None | |
| 148 | + except (TypeError, ValueError): | |
| 149 | + lat = lng = None | |
| 150 | + | |
| 151 | + # sommaire des unités disponibles (rempli seulement s'il y a vacance) | |
| 152 | + stats = ((p.get("statistics") or {}).get("suites") or {}) | |
| 153 | + rates = stats.get("rates") or {} | |
| 154 | + beds = stats.get("bedrooms") or {} | |
| 155 | + baths = stats.get("bathrooms") or {} | |
| 156 | + sqft = stats.get("square_feet") or {} | |
| 157 | + price = float(rates["min"]) if rates.get("min") else None | |
| 158 | + price_label = "" | |
| 159 | + if price is not None: | |
| 160 | + price_label = (f"À partir de {price:.0f} $" | |
| 161 | + if rates.get("max") and rates["max"] != rates["min"] | |
| 162 | + else f"{price:.0f} $ /mois") | |
| 163 | + # type d'unité : seulement si la gamme est sans ambiguïté | |
| 164 | + unit_type = "" | |
| 165 | + if beds.get("min") is not None and beds.get("min") == beds.get("max"): | |
| 166 | + n = int(beds["min"]) | |
| 167 | + unit_type = "Studio" if n == 0 else normalize_unit_type( | |
| 168 | + f"{n} chambres") | |
| 169 | + # superficie : le flux publie parfois « 0.0 » — ignorer | |
| 170 | + area = None | |
| 171 | + try: | |
| 172 | + v = float(sqft.get("min") or 0) | |
| 173 | + if 80 <= v <= 20000: | |
| 174 | + area = v | |
| 175 | + except (TypeError, ValueError): | |
| 176 | + pass | |
| 177 | + | |
| 178 | + # disponibilité : libellé du flux (« No Vacancy », « X Vacancies »…) | |
| 179 | + availability = (p.get("availability_status_label") or "").strip() | |
| 180 | + avail_date = None | |
| 181 | + mad = str(p.get("min_availability_date") or "").strip() | |
| 182 | + if re.fullmatch(r"20\d{2}-\d{2}-\d{2}", mad[:10]): | |
| 183 | + avail_date = mad[:10] | |
| 184 | + | |
| 185 | + # description : aperçu HTML du flux (rendu texte) | |
| 186 | + details_src = p.get("details") or {} | |
| 187 | + desc = BeautifulSoup(details_src.get("overview") or "", | |
| 188 | + "html.parser").get_text(" ", strip=True) | |
| 189 | + promo = p.get("promotion") or {} | |
| 190 | + promo_txt = (promo.get("title") or promo.get("name") or "").strip() \ | |
| 191 | + if isinstance(promo, dict) else "" | |
| 192 | + if promo_txt: | |
| 193 | + desc = f"Promotion : {promo_txt}. {desc}".strip() | |
| 194 | + | |
| 195 | + # commodités : liste du flux + champ personnalisé Rentsync (CSV) | |
| 196 | + amenities: list[str] = [] | |
| 197 | + for a in p.get("amenities") or []: | |
| 198 | + t = (a.get("name") if isinstance(a, dict) else str(a) or "").strip() | |
| 199 | + if t and t not in amenities: | |
| 200 | + amenities.append(t) | |
| 201 | + cf = p.get("custom_fields") or {} | |
| 202 | + for t in (cf.get("amenities") or "").split(","): | |
| 203 | + t = t.strip() | |
| 204 | + if t and t not in amenities: | |
| 205 | + amenities.append(t) | |
| 206 | + | |
| 207 | + # champs structurés du flux | |
| 208 | + details: dict = {} | |
| 209 | + contact = p.get("contact") or {} | |
| 210 | + if contact.get("phone"): | |
| 211 | + details["contact"] = {"phone": contact["phone"]} | |
| 212 | + if contact.get("email"): | |
| 213 | + details.setdefault("contact", {})["email"] = contact["email"] | |
| 214 | + # pet_friendly=false ne distingue pas « interdit » de « non renseigné » | |
| 215 | + pets = "oui" if p.get("pet_friendly") is True else None | |
| 216 | + | |
| 217 | + # galerie photo de la fiche propriété — via le cache BD : revisitée | |
| 218 | + # seulement quand la ligne du flux change | |
| 219 | + feed_key = hashlib.sha1("|".join(str(x) for x in ( | |
| 220 | + p.get("availability_count"), p.get("availability_status"), | |
| 221 | + rates.get("min"), rates.get("max"), mad, p.get("photo"), | |
| 222 | + )).encode("utf-8")).hexdigest() | |
| 223 | + d = self.detail(pid, feed_key, lambda: self._fetch_gallery(url)) | |
| 224 | + images = list(d.get("images") or []) | |
| 225 | + photo = (p.get("photo_path") or "").strip() | |
| 226 | + if photo and photo not in images: | |
| 227 | + images.insert(0, photo) | |
| 228 | + | |
| 229 | + # infer_city (secteur -> Lévis…) n'a de sens que côté Québec | |
| 230 | + final_city = infer_city(sector, default=city) if prov == "QC" else city | |
| 231 | + | |
| 232 | + return Listing( | |
| 233 | + source=self.source_id, | |
| 234 | + external_id=pid, | |
| 235 | + url=url, | |
| 236 | + title=name, | |
| 237 | + address=full_addr, | |
| 238 | + sector=sector, | |
| 239 | + city=final_city, | |
| 240 | + province=prov, | |
| 241 | + unit_type=unit_type, | |
| 242 | + price=price, | |
| 243 | + price_label=price_label, | |
| 244 | + availability=availability, | |
| 245 | + availability_date=avail_date, | |
| 246 | + area_sqft=area, | |
| 247 | + pets=pets, | |
| 248 | + description=desc[:600] + ( | |
| 249 | + f" Salles de bain : {baths['min']:g}+." | |
| 250 | + if baths.get("min") else ""), | |
| 251 | + amenities=amenities[:25], | |
| 252 | + details=details, | |
| 253 | + images=images[: self.max_images], | |
| 254 | + lat=lat, | |
| 255 | + lng=lng, | |
| 256 | + ) | |
| 257 | + | |
| 258 | + def _fetch_gallery(self, url: str) -> dict: | |
| 259 | + """Scrape la galerie photo (assets.rentsync.com) de la fiche propriété.""" | |
| 260 | + out: dict = {"images": []} | |
| 261 | + if not url: | |
| 262 | + return out | |
| 263 | + try: | |
| 264 | + page = self.get(url).text | |
| 265 | + except Exception: | |
| 266 | + return out | |
| 267 | + images: list[str] = [] | |
| 268 | + for u in _IMG_RE.findall(page): | |
| 269 | + if _SKIP_IMG.search(u): | |
| 270 | + continue | |
| 271 | + # variante pleine résolution de la galerie (…/gallery/full/…) | |
| 272 | + u = re.sub(r"/gallery/\d{3,4}/", "/gallery/full/", u) | |
| 273 | + if u not in images: | |
| 274 | + images.append(u) | |
| 275 | + out["images"] = images[: self.max_images] | |
| 276 | + return out | |
| 277 | + | |
| 278 | + | |
| 279 | +# ----------------------------------------------------------------------------- | |
| 280 | +# Génération : une sous-classe par client du registre, déposée dans les globals | |
| 281 | +# du module — connectors/__init__.py (scan de vars(module)) les découvre alors | |
| 282 | +# comme n'importe quel connecteur écrit à la main. | |
| 283 | +# ----------------------------------------------------------------------------- | |
| 284 | +def _make_connector(entry: dict) -> type[LiftSystemConnector]: | |
| 285 | + cls = type( | |
| 286 | + f"Lift{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector", | |
| 287 | + (LiftSystemConnector,), | |
| 288 | + { | |
| 289 | + "source_id": f"lift_{entry['id']}", | |
| 290 | + "client": entry, | |
| 291 | + # Expansion Ontario — activer via LOUKA_ONTARIO=1 | |
| 292 | + # (voir gestion-immobiliere-ontario.md) | |
| 293 | + "disabled": not _ONTARIO, | |
| 294 | + "__doc__": f"Connecteur LiftSystem généré — {entry.get('name')} " | |
| 295 | + f"(client_id {entry.get('client_id')}).", | |
| 296 | + }, | |
| 297 | + ) | |
| 298 | + return cls | |
| 299 | + | |
| 300 | + | |
| 301 | +def _register_all() -> None: | |
| 302 | + for entry in _load_registry(): | |
| 303 | + cls = _make_connector(entry) | |
| 304 | + globals()[cls.__name__] = cls | |
| 305 | + | |
| 306 | + | |
| 307 | +_register_all() | |
modified
louka/connectors/metcap.py
+61 −11
@@ -12,10 +12,16 @@ | ||
| 12 | 12 | # photos d'unité ; les fiches /property/... la galerie photo de l'immeuble. |
| 13 | 13 | # Fiches visitées via self.detail(...) (cache BD). Gestionnaire pancanadien : |
| 14 | 14 | # seules les villes du Grand Montréal sont couvertes. |
| 15 | +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1, | |
| 16 | +# la page /province/ontario (province=113) est aussi balayée pour les villes | |
| 17 | +# Toronto/GTA + Barrie (province="ON", adresse suffixée « ON », compteur | |
| 18 | +# d'unités séparé — le parc QC n'est jamais rogné). Sans la variable, | |
| 19 | +# comportement strictement identique (QC seulement). | |
| 15 | 20 | # ----------------------------------------------------------------------------- |
| 16 | 21 | from __future__ import annotations |
| 17 | 22 | |
| 18 | 23 | import hashlib |
| 24 | +import os | |
| 19 | 25 | import re |
| 20 | 26 | import urllib.parse |
| 21 | 27 | |
@@ -24,8 +30,12 @@ from bs4 import BeautifulSoup | ||
| 24 | 30 | from ..schema import Listing, parse_price, strip_accents |
| 25 | 31 | from .base import BaseConnector |
| 26 | 32 | |
| 33 | +# Expansion Ontario 2026-08 — gate d'environnement (défaut : QC seulement) | |
| 34 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 35 | + | |
| 27 | 36 | BASE = "https://www.metcap.com" |
| 28 | 37 | QC_PROVINCE_URL = f"{BASE}/province/quebec?lang=en" |
| 38 | +ON_PROVINCE_URL = f"{BASE}/province/ontario?lang=en" | |
| 29 | 39 | |
| 30 | 40 | # Villes admissibles (Grand Montréal), clés sans accents/minuscules |
| 31 | 41 | _GM_CITIES = { |
@@ -43,6 +53,22 @@ _GM_CITIES = { | ||
| 43 | 53 | "dorval": ("Dorval", ""), |
| 44 | 54 | } |
| 45 | 55 | |
| 56 | +# Villes admissibles Ontario (Toronto/GTA + Barrie) — expansion 2026-08, | |
| 57 | +# consommées seulement sous LOUKA_ONTARIO=1. Clés sans accents/minuscules. | |
| 58 | +_ON_CITIES = { | |
| 59 | + "toronto": ("Toronto", ""), | |
| 60 | + "north york": ("Toronto", "North York"), | |
| 61 | + "scarborough": ("Toronto", "Scarborough"), | |
| 62 | + "etobicoke": ("Toronto", "Etobicoke"), | |
| 63 | + "east york": ("Toronto", "East York"), | |
| 64 | + "thornhill": ("Thornhill", ""), | |
| 65 | + "mississauga": ("Mississauga", ""), | |
| 66 | + "brampton": ("Brampton", ""), | |
| 67 | + "burlington": ("Burlington", ""), | |
| 68 | + "oshawa": ("Oshawa", ""), | |
| 69 | + "barrie": ("Barrie", ""), | |
| 70 | +} | |
| 71 | + | |
| 46 | 72 | _TYPE_MAP = [ |
| 47 | 73 | (re.compile(r"bachelor|studio", re.I), "Studio"), |
| 48 | 74 | (re.compile(r"1\s*bed", re.I), "3½"), |
@@ -80,29 +106,47 @@ class MetcapConnector(BaseConnector): | ||
| 80 | 106 | |
| 81 | 107 | def fetch(self) -> list[Listing]: |
| 82 | 108 | self._real_details = 0 |
| 83 | − html = self.get(QC_PROVINCE_URL).text | |
| 84 | − # Liens de villes QC : /province-search-results?...province=115&city=X | |
| 109 | + listings: list[Listing] = [] | |
| 110 | + # Québec (comportement historique, inchangé) | |
| 111 | + self._scan_province(QC_PROVINCE_URL, "115", _GM_CITIES, "QC", | |
| 112 | + listings) | |
| 113 | + # Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO (compteur d'unités | |
| 114 | + # séparé : le parc QC garde tout son budget max_units) | |
| 115 | + if _ONTARIO: | |
| 116 | + try: | |
| 117 | + self._scan_province(ON_PROVINCE_URL, "113", _ON_CITIES, "ON", | |
| 118 | + listings) | |
| 119 | + except Exception: | |
| 120 | + pass # l'échec ON ne doit jamais faire tomber la synchro QC | |
| 121 | + return listings | |
| 122 | + | |
| 123 | + def _scan_province(self, province_url: str, province_id: str, | |
| 124 | + cities_map: dict, province: str, | |
| 125 | + listings: list[Listing]) -> None: | |
| 126 | + """Balaye une page province (liens de villes -> pages résultats).""" | |
| 127 | + html = self.get(province_url).text | |
| 128 | + # Liens de villes : /province-search-results?...province=<id>&city=X | |
| 85 | 129 | cities = [] |
| 86 | 130 | for href in re.findall(r'href="(/province-search-results\?[^"]+)"', |
| 87 | 131 | html): |
| 88 | 132 | q = urllib.parse.parse_qs(urllib.parse.urlparse( |
| 89 | 133 | href.replace("&", "&")).query) |
| 90 | − if (q.get("province") or [""])[0] != "115": | |
| 134 | + if (q.get("province") or [""])[0] != province_id: | |
| 91 | 135 | continue |
| 92 | 136 | city = (q.get("city") or [""])[0] |
| 93 | 137 | if city and city not in cities: |
| 94 | 138 | cities.append(city) |
| 95 | 139 | |
| 96 | − listings: list[Listing] = [] | |
| 97 | 140 | count = 0 |
| 98 | 141 | for city_name in cities: |
| 99 | 142 | key = strip_accents(city_name.lower()).replace(".", "").strip() |
| 100 | − if key not in _GM_CITIES: | |
| 101 | − continue # hors Grand Montréal (garde REIT pancanadien) | |
| 102 | − city, sector = _GM_CITIES[key] | |
| 143 | + if key not in cities_map: | |
| 144 | + continue # hors région couverte (garde REIT pancanadien) | |
| 145 | + city, sector = cities_map[key] | |
| 103 | 146 | try: |
| 104 | 147 | page = self.get( |
| 105 | − f"{BASE}/province-search-results?lang=en&province=115" | |
| 148 | + f"{BASE}/province-search-results?lang=en" | |
| 149 | + f"&province={province_id}" | |
| 106 | 150 | f"&city={urllib.parse.quote(city_name)}").text |
| 107 | 151 | except Exception: |
| 108 | 152 | continue |
@@ -133,12 +177,12 @@ class MetcapConnector(BaseConnector): | ||
| 133 | 177 | text = a.get_text(" ", strip=True) |
| 134 | 178 | lst = self._unit_listing( |
| 135 | 179 | a.get("href", ""), text, address, prop_name, |
| 136 | − prop_path, city, sector, lat, lng) | |
| 180 | + prop_path, city, sector, lat, lng, | |
| 181 | + province=province) | |
| 137 | 182 | if lst: |
| 138 | 183 | listings.append(lst) |
| 139 | 184 | except Exception: |
| 140 | 185 | continue |
| 141 | − return listings | |
| 142 | 186 | |
| 143 | 187 | # -- pages détail (via cache BD self.detail) ------------------------------- |
| 144 | 188 | def _gallery(self, prop_path: str) -> list[str]: |
@@ -239,7 +283,8 @@ class MetcapConnector(BaseConnector): | ||
| 239 | 283 | # -- construction d'une annonce -------------------------------------------- |
| 240 | 284 | def _unit_listing(self, href: str, card_text: str, address: str, |
| 241 | 285 | prop_name: str, prop_path: str, city: str, sector: str, |
| 242 | − lat: float | None, lng: float | None) -> Listing | None: | |
| 286 | + lat: float | None, lng: float | None, | |
| 287 | + province: str = "QC") -> Listing | None: | |
| 243 | 288 | path = href.split("?")[0] |
| 244 | 289 | slug = path.rstrip("/").split("/")[-1] |
| 245 | 290 | if not slug: |
@@ -324,12 +369,17 @@ class MetcapConnector(BaseConnector): | ||
| 324 | 369 | title_type = re.sub(r"\s*from \$[\d,.].*$", "", card_text).strip() |
| 325 | 370 | title = (f"{prop_name} — {title_type}" if prop_name |
| 326 | 371 | else f"{address} — {title_type}") |
| 372 | + # Ontario : adresse suffixée « ON » (standard expansion 2026-08) | |
| 373 | + if province == "ON" and address \ | |
| 374 | + and not re.search(r",\s*ON\b", address): | |
| 375 | + address = f"{address}, ON" | |
| 327 | 376 | return Listing( |
| 328 | 377 | source=self.source_id, |
| 329 | 378 | external_id=slug, |
| 330 | 379 | url=url, |
| 331 | 380 | title=title, |
| 332 | 381 | address=address, |
| 382 | + province=province, | |
| 333 | 383 | sector=sector, |
| 334 | 384 | city=city, |
| 335 | 385 | unit_type=unit_type, |
modified
louka/connectors/minto.py
+84 −8
@@ -11,9 +11,17 @@ | ||
| 11 | 11 | # complète, le quartier (« Neighbourhood: ») et le téléphone/courriel ; |
| 12 | 12 | # les sections Features listent les commodités (items à coche). |
| 13 | 13 | # Une annonce par type de suite disponible. |
| 14 | +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO=1 : Minto est d'abord | |
| 15 | +# ottavien ; on ajoute les pages « projects » d'Ottawa | |
| 16 | +# (/ottawa/apartment-rentals/…) et de la GTA | |
| 17 | +# (/gta/<Secteur>-apartment-rentals/… : Toronto, Etobicoke, North York, | |
| 18 | +# Mississauga, Oakville). Même structure de fiches (cartes | |
| 19 | +# .projects-apartamets-unit-card). Sans la variable d'environnement, | |
| 20 | +# comportement strictement identique (prod QC). | |
| 14 | 21 | # ----------------------------------------------------------------------------- |
| 15 | 22 | from __future__ import annotations |
| 16 | 23 | |
| 24 | +import os | |
| 17 | 25 | import re |
| 18 | 26 | |
| 19 | 27 | from bs4 import BeautifulSoup |
@@ -42,11 +50,37 @@ _KNOWN_SECTORS = [ | ||
| 42 | 50 | _CITY_SECTORS = {"westmount": "Westmount", "mont-royal": "Mont-Royal", |
| 43 | 51 | "cote-saint-luc": "Côte-Saint-Luc"} |
| 44 | 52 | |
| 53 | +# --- Expansion Ontario (gaté LOUKA_ONTARIO=1 ; prod QC intacte sans) --------- | |
| 54 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 55 | + | |
| 56 | +LIST_URLS_ON = [ | |
| 57 | + f"{BASE}/ottawa/apartment-rentals/projects.html", | |
| 58 | + f"{BASE}/gta/apartment-rentals/projects.html", | |
| 59 | +] | |
| 60 | +# /<région>/<sous-chemin>/<slug>/main.html — le sous-chemin GTA porte le | |
| 61 | +# secteur (ex. North-York-apartment-rentals), celui d'Ottawa est simplement | |
| 62 | +# apartment-rentals | |
| 63 | +PROJECT_ON_RE = re.compile( | |
| 64 | + r'https?://www\.mintoapartments\.com/(ottawa|gta)/' | |
| 65 | + r'([A-Za-z0-9\-]+)/([A-Za-z0-9\-]+)/main\.html') | |
| 66 | + | |
| 67 | +# secteur d'URL GTA (minuscule, sans -apartment-rentals) -> (ville, secteur) | |
| 68 | +_ON_AREAS = { | |
| 69 | + "ottawa": ("Ottawa", ""), | |
| 70 | + "toronto": ("Toronto", ""), | |
| 71 | + "downtown-toronto": ("Toronto", "Downtown"), | |
| 72 | + "etobicoke": ("Toronto", "Etobicoke"), | |
| 73 | + "north-york": ("Toronto", "North York"), | |
| 74 | + "mississauga": ("Mississauga", ""), | |
| 75 | + "oakville": ("Oakville", ""), | |
| 76 | +} | |
| 77 | + | |
| 45 | 78 | |
| 46 | 79 | class MintoConnector(BaseConnector): |
| 47 | 80 | source_id = "minto" |
| 48 | 81 | request_delay = 0.6 |
| 49 | − max_projects = 12 # garde-fou | |
| 82 | + max_projects = 12 # garde-fou (QC) | |
| 83 | + max_projects_on = 40 # garde-fou distinct pour l'Ontario (gaté) | |
| 50 | 84 | max_images = 25 |
| 51 | 85 | |
| 52 | 86 | def fetch(self) -> list[Listing]: |
@@ -59,10 +93,41 @@ class MintoConnector(BaseConnector): | ||
| 59 | 93 | listings.extend(self._project_listings(slug)) |
| 60 | 94 | except Exception: |
| 61 | 95 | continue |
| 96 | + | |
| 97 | + # Expansion Ontario (gaté LOUKA_ONTARIO=1) : Ottawa + GTA, budget de | |
| 98 | + # projets distinct pour ne rien enlever à la couverture QC | |
| 99 | + if _ONTARIO: | |
| 100 | + n_on = 0 | |
| 101 | + for list_url in LIST_URLS_ON: | |
| 102 | + try: | |
| 103 | + on_html = self.get(list_url).text | |
| 104 | + except Exception: | |
| 105 | + continue | |
| 106 | + for region, mid, slug in dict.fromkeys( | |
| 107 | + PROJECT_ON_RE.findall(on_html)): | |
| 108 | + # exclure les meublés courte durée (furnished-apartments) | |
| 109 | + if not mid.endswith("apartment-rentals"): | |
| 110 | + continue | |
| 111 | + if n_on >= self.max_projects_on: | |
| 112 | + break | |
| 113 | + n_on += 1 | |
| 114 | + area = (re.sub(r"-?apartment-rentals$", "", mid) | |
| 115 | + .strip("-").lower() or region) | |
| 116 | + city, sector = _ON_AREAS.get( | |
| 117 | + area, (area.replace("-", " ").title(), "")) | |
| 118 | + try: | |
| 119 | + listings.extend(self._project_listings( | |
| 120 | + slug, region=region, subpath=mid, | |
| 121 | + on_city=city, on_sector=sector, province="ON")) | |
| 122 | + except Exception: | |
| 123 | + continue | |
| 62 | 124 | return listings |
| 63 | 125 | |
| 64 | − def _project_listings(self, slug: str) -> list[Listing]: | |
| 65 | − url = f"{BASE}/montreal/apartment-rentals/{slug}/main.html" | |
| 126 | + def _project_listings(self, slug: str, region: str = "montreal", | |
| 127 | + subpath: str = "apartment-rentals", | |
| 128 | + on_city: str = "", on_sector: str = "", | |
| 129 | + province: str = "QC") -> list[Listing]: | |
| 130 | + url = f"{BASE}/{region}/{subpath}/{slug}/main.html" | |
| 66 | 131 | html = self.get(url).text |
| 67 | 132 | soup = BeautifulSoup(html, "html.parser") |
| 68 | 133 | |
@@ -79,12 +144,18 @@ class MintoConnector(BaseConnector): | ||
| 79 | 144 | nm = re.search(r"Neighbourhood:\s*([^<\n]{2,50})", html) |
| 80 | 145 | if nm: |
| 81 | 146 | sector = nm.group(1).strip() |
| 147 | + if province == "ON": | |
| 148 | + # ville/secteur dérivés de l'URL de la page « projects » (GTA) ; | |
| 149 | + # le « Neighbourhood: » de la fiche a priorité s'il est publié | |
| 150 | + city = on_city | |
| 151 | + sector = sector or on_sector | |
| 82 | 152 | else: |
| 83 | − for s in _KNOWN_SECTORS: | |
| 84 | − if s.lower() in title_text.lower(): | |
| 85 | − sector = "Centre-ville" if s == "Downtown" else s | |
| 86 | − break | |
| 87 | − city = _CITY_SECTORS.get(strip_accents(sector.lower()), "Montréal") | |
| 153 | + if not sector: | |
| 154 | + for s in _KNOWN_SECTORS: | |
| 155 | + if s.lower() in title_text.lower(): | |
| 156 | + sector = "Centre-ville" if s == "Downtown" else s | |
| 157 | + break | |
| 158 | + city = _CITY_SECTORS.get(strip_accents(sector.lower()), "Montréal") | |
| 88 | 159 | if sector == city: |
| 89 | 160 | sector = "" |
| 90 | 161 | |
@@ -101,6 +172,10 @@ class MintoConnector(BaseConnector): | ||
| 101 | 172 | r'[^<>"{}]{3,60}', html, re.I) |
| 102 | 173 | if am: |
| 103 | 174 | address = re.sub(r"\s+", " ", am.group(0)).strip().rstrip(",") |
| 175 | + if province == "ON" and address: | |
| 176 | + address = re.sub(r",\s*,", ",", address) # « Drive,, Etobicoke » | |
| 177 | + if not re.search(r"\bON\b", address): | |
| 178 | + address = f"{address}, ON" | |
| 104 | 179 | |
| 105 | 180 | # Contact location (téléphone / courriel) -> details.contact |
| 106 | 181 | details: dict = {} |
@@ -195,6 +270,7 @@ class MintoConnector(BaseConnector): | ||
| 195 | 270 | address=address, |
| 196 | 271 | sector=sector, |
| 197 | 272 | city=city, |
| 273 | + province=province, | |
| 198 | 274 | unit_type=normalize_unit_type(suite_name), |
| 199 | 275 | price=price, |
| 200 | 276 | price_label=f"{price_label} /mois", |
modified
louka/connectors/morguard.py
+43 −6
@@ -9,10 +9,17 @@ | ||
| 9 | 9 | # (code postal H/J/G) : 15 immeubles — centre-ville de Montréal (Maisonneuve, |
| 10 | 10 | # Crescent, Mackay, des Pins…), Dollard-des-Ormeaux, Côte-Saint-Luc et |
| 11 | 11 | # Pointe-Claire. Une annonce par unité disponible (available == 1). |
| 12 | +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1, | |
| 13 | +# les immeubles ON des marchés cibles (Toronto/GTA, Ottawa, Hamilton, KW) | |
| 14 | +# sont aussi conservés via leur cityId Rentsync (le gateway n'expose pas de | |
| 15 | +# nom de ville ni de province — mapping cityId->ville validé par FSA postal ; | |
| 16 | +# London/Windsor hors marchés cibles exclus). province="ON", adresse « ON ». | |
| 17 | +# Sans la variable, comportement strictement identique (QC seulement). | |
| 12 | 18 | # ----------------------------------------------------------------------------- |
| 13 | 19 | from __future__ import annotations |
| 14 | 20 | |
| 15 | 21 | import html as htmllib |
| 22 | +import os | |
| 16 | 23 | import re |
| 17 | 24 | |
| 18 | 25 | from ..schema import Listing |
@@ -22,6 +29,9 @@ SITE = "https://www.morguardliving.ca" | ||
| 22 | 29 | API = "https://website-gateway.rentsync.com/v1/morguard_invest" |
| 23 | 30 | IMG_BASE = "https://s3.amazonaws.com/lws_lift/morguard_invest/images/gallery/1152" |
| 24 | 31 | |
| 32 | +# Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, QC seulement | |
| 33 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 34 | + | |
| 25 | 35 | # cityId Rentsync -> ville (le gateway n'expose pas les noms de villes) |
| 26 | 36 | _CITIES = { |
| 27 | 37 | 1863: "Montréal", |
@@ -30,6 +40,23 @@ _CITIES = { | ||
| 30 | 40 | 2213: "Pointe-Claire", |
| 31 | 41 | } |
| 32 | 42 | |
| 43 | +# cityId Rentsync -> ville Ontario (expansion 2026-08, sous LOUKA_ONTARIO). | |
| 44 | +# Marchés cibles : Toronto/GTA, Ottawa, Hamilton, Kitchener-Waterloo. | |
| 45 | +# Mapping validé par FSA des codes postaux du gateway (2026-08-26) ; | |
| 46 | +# 1607 (London, N6A) et 3370 (Windsor, N8Y) volontairement exclus. | |
| 47 | +_CITIES_ON = { | |
| 48 | + 3133: "Toronto", # M6K (Parkdale…) | |
| 49 | + 2015: "Toronto", # M3A (North York) | |
| 50 | + 1837: "Mississauga", # L4X | |
| 51 | + 329: "Brampton", # L6W | |
| 52 | + 2042: "Oakville", # L6K | |
| 53 | + 387: "Burlington", # L7T | |
| 54 | + 1174: "Hamilton", # L8S | |
| 55 | + 2084: "Ottawa", # K1V… | |
| 56 | + 1425: "Kitchener", # N2C | |
| 57 | + 3284: "Waterloo", # N2L | |
| 58 | +} | |
| 59 | + | |
| 33 | 60 | # Nombre de chambres -> type d'unité (convention QC : n½ = n-2 chambres) |
| 34 | 61 | _BED_TYPE = {0: "Studio", 1: "3½", 2: "4½", 3: "5½", 4: "6½"} |
| 35 | 62 | |
@@ -46,16 +73,20 @@ def _clean(txt: str) -> str: | ||
| 46 | 73 | class MorguardConnector(BaseConnector): |
| 47 | 74 | source_id = "morguard" |
| 48 | 75 | request_delay = 0.6 |
| 49 | − max_units = 300 # garde-fou (84 unités QC dispo à l'écriture) | |
| 76 | + # garde-fou (84 unités QC dispo à l'écriture ; plafond relevé avec l'ON) | |
| 77 | + max_units = 900 if _ONTARIO else 300 | |
| 50 | 78 | |
| 51 | 79 | def fetch(self) -> list[Listing]: |
| 52 | 80 | props = (self.get(f"{API}/properties", params={"limit": "300"}) |
| 53 | 81 | .json().get("data") or []) |
| 54 | − # Québec seulement : codes postaux H (Mtl), J (couronnes), G (est) | |
| 82 | + # Québec : codes postaux H (Mtl), J (couronnes), G (est) ; | |
| 83 | + # Ontario (sous LOUKA_ONTARIO) : cityId des marchés cibles. | |
| 55 | 84 | buildings: dict[int, dict] = {} |
| 56 | 85 | for p in props: |
| 57 | 86 | postal = (p.get("postal") or "").strip().upper() |
| 58 | − if postal[:1] not in ("H", "J", "G"): | |
| 87 | + is_qc = postal[:1] in ("H", "J", "G") | |
| 88 | + is_on = _ONTARIO and int(p.get("cityId") or 0) in _CITIES_ON | |
| 89 | + if not (is_qc or is_on): | |
| 59 | 90 | continue |
| 60 | 91 | if (p.get("status") or "") != "enabled": |
| 61 | 92 | continue |
@@ -66,7 +97,8 @@ class MorguardConnector(BaseConnector): | ||
| 66 | 97 | ids = "|".join(str(i) for i in sorted(buildings)) |
| 67 | 98 | units = (self.get(f"{API}/units", params={ |
| 68 | 99 | "where": f"buildingId~in:{ids},status~in:enabled", |
| 69 | − "limit": "500", | |
| 100 | + # ON inclus : ~6x plus d'immeubles, plafond relevé en conséquence | |
| 101 | + "limit": "1500" if _ONTARIO else "500", | |
| 70 | 102 | }).json().get("data") or []) |
| 71 | 103 | |
| 72 | 104 | listings: list[Listing] = [] |
@@ -114,9 +146,13 @@ class MorguardConnector(BaseConnector): | ||
| 114 | 146 | name = _clean(b.get("buildingName") or "") |
| 115 | 147 | street = _clean(f"{b.get('streetNumber') or ''} " |
| 116 | 148 | f"{b.get('streetName') or ''}") |
| 117 | − city = _CITIES.get(int(b.get("cityId") or 0), "Montréal") | |
| 149 | + cid = int(b.get("cityId") or 0) | |
| 150 | + if _ONTARIO and cid in _CITIES_ON: | |
| 151 | + prov, city = "ON", _CITIES_ON[cid] | |
| 152 | + else: | |
| 153 | + prov, city = "QC", _CITIES.get(cid, "Montréal") | |
| 118 | 154 | postal = (b.get("postal") or "").strip().upper() |
| 119 | − address = f"{street}, {city}, QC {postal}".strip(" ,") | |
| 155 | + address = f"{street}, {city}, {prov} {postal}".strip(" ,") | |
| 120 | 156 | sector = _clean(b.get("neighbourhood") or "") |
| 121 | 157 | |
| 122 | 158 | try: |
@@ -187,6 +223,7 @@ class MorguardConnector(BaseConnector): | ||
| 187 | 223 | address=address, |
| 188 | 224 | sector=sector, |
| 189 | 225 | city=city, |
| 226 | + province=prov, | |
| 190 | 227 | unit_type=unit_type, |
| 191 | 228 | bedrooms=float(bed) if bed is not None else None, |
| 192 | 229 | bathrooms=bath, |
modified
louka/connectors/realstar.py
+117 −6
@@ -13,6 +13,12 @@ | ||
| 13 | 13 | # Une annonce par propriété (uid stables). Les pages détail passent par |
| 14 | 14 | # self.detail(...) (cache BD) : Firecrawl n'est rappelé que si la carte |
| 15 | 15 | # liste a changé. |
| 16 | +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : quand LOUKA_ONTARIO=1, | |
| 17 | +# un rendu supplémentaire de ?province=Ontario alimente les cartes | |
| 18 | +# /apartments/on/ des 7 régions couvertes (Toronto/GTA, Ottawa, Hamilton, | |
| 19 | +# Kitchener-Waterloo, London, Cobourg), avec plafonds propriétés/rendus | |
| 20 | +# SÉPARÉS (le budget QC n'est jamais entamé). Sans la variable, | |
| 21 | +# comportement strictement identique (QC seulement). | |
| 16 | 22 | # ----------------------------------------------------------------------------- |
| 17 | 23 | from __future__ import annotations |
| 18 | 24 | |
@@ -25,7 +31,11 @@ from bs4 import BeautifulSoup | ||
| 25 | 31 | from ..schema import Listing, parse_price |
| 26 | 32 | from .base import FIRECRAWL_API, BaseConnector |
| 27 | 33 | |
| 34 | +# Expansion Ontario 2026-08 — gate d'environnement (défaut : QC seulement) | |
| 35 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 36 | + | |
| 28 | 37 | SEARCH_URL = "https://www.realstar.ca/searchlisting?province=Quebec" |
| 38 | +ON_SEARCH_URL = "https://www.realstar.ca/searchlisting?province=Ontario" | |
| 29 | 39 | |
| 30 | 40 | # Ville du chemin /apartments/qc/<ville>/<slug> -> (ville affichée, secteur) |
| 31 | 41 | _GM_CITIES = { |
@@ -43,6 +53,32 @@ _GM_CITIES = { | ||
| 43 | 53 | "sherbrooke": ("Sherbrooke", ""), |
| 44 | 54 | } |
| 45 | 55 | |
| 56 | +# Ville du chemin /apartments/on/<ville>/<slug> -> (ville affichée, secteur). | |
| 57 | +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : 7 régions (Toronto/GTA, | |
| 58 | +# Ottawa, Hamilton, Kitchener-Waterloo, London, Cobourg). | |
| 59 | +_ON_CITIES = { | |
| 60 | + "toronto": ("Toronto", ""), | |
| 61 | + "north-york": ("Toronto", "North York"), | |
| 62 | + "etobicoke": ("Toronto", "Etobicoke"), | |
| 63 | + "scarborough": ("Toronto", "Scarborough"), | |
| 64 | + "mississauga": ("Mississauga", ""), | |
| 65 | + "brampton": ("Brampton", ""), | |
| 66 | + "oakville": ("Oakville", ""), | |
| 67 | + "milton": ("Milton", ""), | |
| 68 | + "burlington": ("Burlington", ""), | |
| 69 | + "aurora": ("Aurora", ""), | |
| 70 | + "newmarket": ("Newmarket", ""), | |
| 71 | + "whitby": ("Whitby", ""), | |
| 72 | + "oshawa": ("Oshawa", ""), | |
| 73 | + "ottawa": ("Ottawa", ""), | |
| 74 | + "hamilton": ("Hamilton", ""), | |
| 75 | + "kitchener": ("Kitchener", ""), | |
| 76 | + "waterloo": ("Waterloo", ""), | |
| 77 | + "cambridge": ("Cambridge", ""), | |
| 78 | + "london": ("London", ""), | |
| 79 | + "cobourg": ("Cobourg", ""), | |
| 80 | +} | |
| 81 | + | |
| 46 | 82 | _BED_TYPES = {"0": "Studio", "1": "3½", "2": "4½", "3": "5½", "4": "6½"} |
| 47 | 83 | _SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder", re.I) |
| 48 | 84 | _PRICE_RE = re.compile(r"\$[\d,]+(?:\.\d{2})?") |
@@ -58,6 +94,10 @@ class RealstarConnector(BaseConnector): | ||
| 58 | 94 | max_properties = 12 # garde-fou (2 appels Firecrawl par propriété) |
| 59 | 95 | max_images = 25 |
| 60 | 96 | max_renders = 20 # plafond d'appels Firecrawl par sync (hors cache) |
| 97 | + # Expansion Ontario 2026-08 (gaté LOUKA_ONTARIO) : plafonds séparés pour | |
| 98 | + # ne jamais entamer le budget QC | |
| 99 | + max_properties_on = 12 | |
| 100 | + max_renders_on = 24 | |
| 61 | 101 | |
| 62 | 102 | # -- Firecrawl avec attente de rendu (SPA + Cloudflare) ------------------- |
| 63 | 103 | def _rendered(self, url: str, wait_ms: int = 9000) -> str: |
@@ -109,11 +149,60 @@ class RealstarConnector(BaseConnector): | ||
| 109 | 149 | self._property_listing(card, url, city_slug, slug)) |
| 110 | 150 | except Exception: |
| 111 | 151 | continue |
| 152 | + | |
| 153 | + # Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO (rendu et budgets | |
| 154 | + # séparés : l'échec ON ne doit jamais faire tomber la synchro QC) | |
| 155 | + if _ONTARIO: | |
| 156 | + try: | |
| 157 | + listings.extend(self._fetch_ontario(seen)) | |
| 158 | + except Exception: | |
| 159 | + pass | |
| 160 | + return listings | |
| 161 | + | |
| 162 | + def _fetch_ontario(self, seen: set[str]) -> list[Listing]: | |
| 163 | + """Cartes /apartments/on/ de la recherche Ontario (mêmes gabarits | |
| 164 | + RentCafe que le QC), plafonds propriétés/rendus dédiés.""" | |
| 165 | + # budget de rendus dédié à l'Ontario (le QC a déjà consommé le sien) | |
| 166 | + self.max_renders = self._renders + self.max_renders_on | |
| 167 | + html = self._rendered(ON_SEARCH_URL, 12000) | |
| 168 | + soup = BeautifulSoup(html, "html.parser") | |
| 169 | + cards = soup.select("li.property-box") | |
| 170 | + if not cards: # rendu incomplet : une seconde chance | |
| 171 | + html = self._rendered(ON_SEARCH_URL, 15000) | |
| 172 | + soup = BeautifulSoup(html, "html.parser") | |
| 173 | + cards = soup.select("li.property-box") | |
| 174 | + | |
| 175 | + listings: list[Listing] = [] | |
| 176 | + count = 0 | |
| 177 | + for card in cards: | |
| 178 | + try: | |
| 179 | + a = card.select_one("a[href*='/apartments/on/']") | |
| 180 | + if not a: | |
| 181 | + continue # autre province | |
| 182 | + url = (a.get("href") or "").split("?")[0] | |
| 183 | + url = url.replace("http://", "https://") | |
| 184 | + m = re.search(r"/apartments/on/([a-z0-9\-.]+)/([a-z0-9\-]+)", | |
| 185 | + url) | |
| 186 | + if not m or url in seen: | |
| 187 | + continue | |
| 188 | + seen.add(url) | |
| 189 | + city_slug, slug = m.group(1), m.group(2) | |
| 190 | + if city_slug not in _ON_CITIES: | |
| 191 | + continue # ville ON hors des 7 régions couvertes | |
| 192 | + if count >= self.max_properties_on: | |
| 193 | + break | |
| 194 | + count += 1 | |
| 195 | + listings.append( | |
| 196 | + self._property_listing(card, url, city_slug, slug, | |
| 197 | + province="ON")) | |
| 198 | + except Exception: | |
| 199 | + continue | |
| 112 | 200 | return listings |
| 113 | 201 | |
| 114 | 202 | def _property_listing(self, card, url: str, city_slug: str, |
| 115 | − slug: str) -> Listing: | |
| 116 | − city, sector = _GM_CITIES[city_slug] | |
| 203 | + slug: str, province: str = "QC") -> Listing: | |
| 204 | + city, sector = (_ON_CITIES if province == "ON" | |
| 205 | + else _GM_CITIES)[city_slug] | |
| 117 | 206 | name = "" |
| 118 | 207 | fav = card.select_one("[data-property]") |
| 119 | 208 | if fav: |
@@ -127,6 +216,10 @@ class RealstarConnector(BaseConnector): | ||
| 127 | 216 | |
| 128 | 217 | addr_el = card.select_one(".card-prop-address") |
| 129 | 218 | address = addr_el.get_text(" ", strip=True) if addr_el else "" |
| 219 | + # Ontario : adresse suffixée « ON » (standard expansion 2026-08) | |
| 220 | + if province == "ON" and address \ | |
| 221 | + and not re.search(r",?\s+ON\b", address): | |
| 222 | + address = f"{address}, ON" | |
| 130 | 223 | |
| 131 | 224 | meta = card.select_one(".card-bed-bath-rent") |
| 132 | 225 | beds = baths = sqft = "" |
@@ -182,8 +275,11 @@ class RealstarConnector(BaseConnector): | ||
| 182 | 275 | key = hashlib.sha1( |
| 183 | 276 | f"{name}|{address}|{beds}|{baths}|{sqft}|{price_label}" |
| 184 | 277 | .encode("utf-8")).hexdigest() |
| 278 | + # uid : slug inchangé au QC ; préfixe « on- » en Ontario (aucun risque | |
| 279 | + # de collision inter-provinces, cache détail séparé) | |
| 280 | + ext = slug if province != "ON" else f"on-{slug}" | |
| 185 | 281 | try: |
| 186 | − payload = self.detail(slug, key, lambda: self._fetch_detail(url)) | |
| 282 | + payload = self.detail(ext, key, lambda: self._fetch_detail(url)) | |
| 187 | 283 | except _BudgetReached: |
| 188 | 284 | payload = {} |
| 189 | 285 | except Exception: |
@@ -239,12 +335,13 @@ class RealstarConnector(BaseConnector): | ||
| 239 | 335 | desc_parts.append("Disponibles : " + " ; ".join(plan_bits)) |
| 240 | 336 | return Listing( |
| 241 | 337 | source=self.source_id, |
| 242 | − external_id=slug, | |
| 338 | + external_id=ext, | |
| 243 | 339 | url=url, |
| 244 | 340 | title=name, |
| 245 | 341 | address=address, |
| 246 | 342 | sector=sector, |
| 247 | 343 | city=city, |
| 344 | + province=province, | |
| 248 | 345 | unit_type=unit_type, |
| 249 | 346 | price=price, |
| 250 | 347 | price_label=price_label, |
@@ -284,6 +381,9 @@ class RealstarConnector(BaseConnector): | ||
| 284 | 381 | text = psoup.get_text("\n", strip=True) |
| 285 | 382 | hm = re.search(r"Points forts de la propri[ée]t[ée]\n(.*?)\n" |
| 286 | 383 | r"(?:Photos|Emplacement|Votre)", text, re.S) |
| 384 | + if not hm: # gabarit anglais (fiches Ontario) | |
| 385 | + hm = re.search(r"Property Highlights\n(.*?)\n" | |
| 386 | + r"(?:Photos|Location|Your)", text, re.S) | |
| 287 | 387 | if hm: |
| 288 | 388 | amenities = [] |
| 289 | 389 | for t in hm.group(1).split("\n"): |
@@ -322,7 +422,8 @@ class RealstarConnector(BaseConnector): | ||
| 322 | 422 | sqft = None |
| 323 | 423 | sq_el = cont.select_one("span[data-selenium-id$='SqFt']") |
| 324 | 424 | if sq_el: |
| 325 | − sm = re.search(r"([\d,]{2,})\s*Pi", | |
| 425 | + # « Pi. Ca. » (gabarit FR) ou « Sq. Ft. » (gabarit EN/ON) | |
| 426 | + sm = re.search(r"([\d,]{2,})\s*(?:Pi|Sq)", | |
| 326 | 427 | sq_el.get_text(" ", strip=True), re.I) |
| 327 | 428 | if sm: |
| 328 | 429 | v = float(sm.group(1).replace(",", "")) |
@@ -339,8 +440,18 @@ class RealstarConnector(BaseConnector): | ||
| 339 | 440 | if um: |
| 340 | 441 | n = int(um.group(1)) |
| 341 | 442 | unit_type = "6½+" if n >= 6 else f"{n}½" |
| 342 | − elif re.match(r"(?i)^\s*studio", name): | |
| 443 | + elif re.match(r"(?i)^\s*(?:studio|bachelor)", name): | |
| 343 | 444 | unit_type = "Studio" |
| 445 | + else: | |
| 446 | + # gabarits anglais (plans Ontario) : « 2 Bed 1 Bath A », | |
| 447 | + # « One Bedroom », etc. -> convention QC (n + 2)½ | |
| 448 | + wm = re.match(r"(?i)^\s*(\d|one|two|three|four)\s*bed", | |
| 449 | + name) | |
| 450 | + if wm: | |
| 451 | + n = {"one": 1, "two": 2, "three": 3, "four": 4}.get( | |
| 452 | + wm.group(1).lower()) or int(wm.group(1)) | |
| 453 | + # aligné sur _BED_TYPES : 4 chambres -> 6½ | |
| 454 | + unit_type = "6½+" if n >= 5 else f"{n + 2}½" | |
| 344 | 455 | plans.append({"name": name, "available": avail, |
| 345 | 456 | "sqft": sqft, "price": price, |
| 346 | 457 | "unit_type": unit_type}) |
added
louka/connectors/rentcafe.py
+476 −0
@@ -0,0 +1,476 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/rentcafe.py : connecteur GÉNÉRIQUE Yardi RentCafe/SecureCafe | |
| 5 | +# (multi-clients Ontario — levier n° 2 de l'expansion, voir | |
| 6 | +# gestion-immobiliere-ontario.md §10) | |
| 7 | +# | |
| 8 | +# Une sous-classe est générée dynamiquement par client du registre | |
| 9 | +# data/rentcafe_clients.json (statut « ok ») : source_id = rc_<id> | |
| 10 | +# (ex. rc_effort, rc_osgoode, rc_gwlra, rc_oshanter). Le registre | |
| 11 | +# auto-découvrant connectors/__init__.py les ramasse dans vars(module). | |
| 12 | +# | |
| 13 | +# Pattern « searchlisting » (validé sur les 4 clients actifs) : | |
| 14 | +# 1) <site>/searchlisting.aspx via Scrapfly (Cloudflare 403 en direct, | |
| 15 | +# contenu rendu côté serveur -> render_js inutile) : | |
| 16 | +# - cartes li.property-box-hidden : nom, lien fiche, adresse complète | |
| 17 | +# (« …, Kingston, ON K7P 1M8 »), lits/sdb/pi², fourchette de prix, | |
| 18 | +# téléphone, vignette resource.rentcafe.com ; | |
| 19 | +# - champ caché available_prop_map (JSON doublement encodé) : | |
| 20 | +# propertyid -> lat/lng + fourchette de prix des épingles de carte. | |
| 21 | +# Seules les cartes dont l'adresse est en Ontario sont conservées | |
| 22 | +# (Osgoode/GWLRA listent aussi AB/BC ; le QC reste aux connecteurs QC). | |
| 23 | +# 2) fiche propriété (+ /floorplans au besoin) via self.detail() (cache BD, | |
| 24 | +# budget Scrapfly par synchronisation) : galerie, description, plans | |
| 25 | +# structurés fp-container (2 gabarits : spans data-selenium-id ou cartes | |
| 26 | +# h2.card-title + nu-bed/nu-bathroom/nu-area + data-floorplan-*). | |
| 27 | +# Une annonce par propriété (uid stable = propertyid RentCafe) ; comme chez | |
| 28 | +# Osgoode, AUCUN décompte d'unités disponibles n'est publié -> availability | |
| 29 | +# reste vide (rien d'inventé). | |
| 30 | +# | |
| 31 | +# Pattern « securecafe » (<client>.securecafe.com/residentservices/ | |
| 32 | +# apartmentsforrent/…) : vérifié fermé derrière login chez Old Oak, | |
| 33 | +# Paramount et Tricar — entrées « echec » du registre, aucune classe | |
| 34 | +# générée (voir les notes du registre avant de réessayer). | |
| 35 | +# | |
| 36 | +# ⚠ Gate Ontario : les classes ne sont actives que si LOUKA_ONTARIO=1 — | |
| 37 | +# sans cette variable, disabled=True et le registre des connecteurs les | |
| 38 | +# ignore (zéro impact sur la prod Québec). | |
| 39 | +# ----------------------------------------------------------------------------- | |
| 40 | +from __future__ import annotations | |
| 41 | + | |
| 42 | +import hashlib | |
| 43 | +import html as htmllib | |
| 44 | +import json | |
| 45 | +import os | |
| 46 | +import re | |
| 47 | + | |
| 48 | +from bs4 import BeautifulSoup | |
| 49 | + | |
| 50 | +from ..schema import Listing, parse_price | |
| 51 | +from .base import BaseConnector | |
| 52 | + | |
| 53 | +# Gate expansion Ontario (voir en-tête) | |
| 54 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 55 | + | |
| 56 | +_REGISTRY_PATH = os.path.join(os.path.dirname(__file__), "..", "..", | |
| 57 | + "data", "rentcafe_clients.json") | |
| 58 | + | |
| 59 | +# « $1,449.00 - $1,899.00 » / « $1,499.00 » (format RentCafe, virgule = milliers) | |
| 60 | +_PRICE_RE = re.compile(r"\$[\d,]+(?:\.\d{2})?(?:\s*(?:-|to|à)+\s*" | |
| 61 | + r"\$[\d,]+(?:\.\d{2})?)?") | |
| 62 | +_NUM_RE = re.compile(r"[\d,]+(?:\.\d+)?") | |
| 63 | +_SKIP_IMG = re.compile(r"logo|icon|favicon|placeholder|\.svg", re.I) | |
| 64 | +_PHONE_RE = re.compile(r"\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})") | |
| 65 | + | |
| 66 | +# nombre de chambres -> type d'unité (normalize_unit_type fera la conversion | |
| 67 | +# canonique n½ à l'ingestion ; on passe le texte source, rien d'inventé) | |
| 68 | +_BED_TYPES = {0: "Studio", 1: "1 Bed", 2: "2 Beds", 3: "3 Beds", 4: "4 Beds"} | |
| 69 | + | |
| 70 | + | |
| 71 | +def _to_float(txt: str) -> float | None: | |
| 72 | + m = _NUM_RE.search(txt or "") | |
| 73 | + if not m: | |
| 74 | + return None | |
| 75 | + try: | |
| 76 | + return float(m.group(0).replace(",", "")) | |
| 77 | + except ValueError: | |
| 78 | + return None | |
| 79 | + | |
| 80 | + | |
| 81 | +def _low_price(txt: str) -> float | None: | |
| 82 | + """Borne basse d'une fourchette « $1,449.00 - $1,899.00 » (100-20000 $).""" | |
| 83 | + vals = [_to_float(v) for v in re.findall(r"\$[\d,]+(?:\.\d{2})?", txt or "")] | |
| 84 | + vals = [v for v in vals if v is not None and 100 <= v <= 20000] | |
| 85 | + return min(vals) if vals else None | |
| 86 | + | |
| 87 | + | |
| 88 | +class _DetailBudget(Exception): | |
| 89 | + """Budget de nouvelles fiches Scrapfly épuisé pour cette synchronisation.""" | |
| 90 | + | |
| 91 | + | |
| 92 | +class RentCafeClientConnector(BaseConnector): | |
| 93 | + """Classe de base des clients RentCafe — ne PAS l'enregistrer telle quelle | |
| 94 | + (source_id vide) : les sous-classes concrètes sont générées plus bas à | |
| 95 | + partir du registre data/rentcafe_clients.json.""" | |
| 96 | + | |
| 97 | + source_id = "" # vide -> ignorée par connectors/__init__.py | |
| 98 | + disabled = not _ONTARIO # gate expansion Ontario | |
| 99 | + request_delay = 1.5 # Scrapfly coûte : politesse renforcée | |
| 100 | + client: dict = {} # entrée du registre (site, search_url…) | |
| 101 | + max_properties = 160 # garde-fou (Effort Trust : 153 cartes ON) | |
| 102 | + max_details = 8 # nouvelles fiches Scrapfly max par sync | |
| 103 | + max_images = 20 | |
| 104 | + | |
| 105 | + # -- Scrapfly (Cloudflare -> ASP ; contenu rendu serveur, pas de JS) ------- | |
| 106 | + def _page(self, url: str) -> str: | |
| 107 | + res = self.scrapfly(url, render_js=False, asp=True, country="ca") | |
| 108 | + if (res.get("status_code") or 0) != 200: | |
| 109 | + return "" | |
| 110 | + return res.get("content") or "" | |
| 111 | + | |
| 112 | + # -- fetch ------------------------------------------------------------------ | |
| 113 | + def fetch(self) -> list[Listing]: | |
| 114 | + if (self.client.get("pattern") or "searchlisting") != "searchlisting": | |
| 115 | + return [] # « securecafe » public : aucun client validé | |
| 116 | + html = self._page(self.client["search_url"]) | |
| 117 | + if not html: | |
| 118 | + raise RuntimeError( | |
| 119 | + f"searchlisting inaccessible via Scrapfly ({self.source_id})") | |
| 120 | + soup = BeautifulSoup(html, "html.parser") | |
| 121 | + pins = self._map_pins(html) | |
| 122 | + | |
| 123 | + self._detail_fetches = 0 | |
| 124 | + listings: list[Listing] = [] | |
| 125 | + seen: set[str] = set() | |
| 126 | + cards = soup.select("li.property-box-hidden") \ | |
| 127 | + or soup.select("li.property-box, .property-box") | |
| 128 | + for card in cards: | |
| 129 | + if len(listings) >= self.max_properties: | |
| 130 | + break | |
| 131 | + try: | |
| 132 | + lst = self._property_listing(card, pins) | |
| 133 | + if lst and lst.external_id not in seen: | |
| 134 | + seen.add(lst.external_id) | |
| 135 | + listings.append(lst) | |
| 136 | + except Exception: | |
| 137 | + continue | |
| 138 | + return listings | |
| 139 | + | |
| 140 | + # -- épingles de carte (champ caché available_prop_map) ---------------------- | |
| 141 | + @staticmethod | |
| 142 | + def _map_pins(html: str) -> dict[str, dict]: | |
| 143 | + """propertyid -> {lat, lng, price, beds} (JSON doublement encodé).""" | |
| 144 | + m = re.search(r"available_prop_map[^>]*value=(['\"])(.*?)\1", html, re.S) | |
| 145 | + if not m: | |
| 146 | + return {} | |
| 147 | + try: | |
| 148 | + data = json.loads(htmllib.unescape(m.group(2))) | |
| 149 | + if isinstance(data, str): | |
| 150 | + data = json.loads(data) | |
| 151 | + pins = data.get("ListingsPins") | |
| 152 | + if isinstance(pins, str): | |
| 153 | + pins = json.loads(pins) | |
| 154 | + except (ValueError, AttributeError): | |
| 155 | + return {} | |
| 156 | + out: dict[str, dict] = {} | |
| 157 | + for grp in (pins or {}).get("groups") or []: | |
| 158 | + for p in grp.get("points") or []: | |
| 159 | + pid = str(p.get("propertyid") or p.get("id") or "") | |
| 160 | + if not pid: | |
| 161 | + continue | |
| 162 | + hover = p.get("hover") or {} | |
| 163 | + out[pid] = {"lat": p.get("y"), "lng": p.get("x"), | |
| 164 | + "price": hover.get("Price") or "", | |
| 165 | + "beds": hover.get("Beds") or ""} | |
| 166 | + return out | |
| 167 | + | |
| 168 | + # -- carte propriété ---------------------------------------------------------- | |
| 169 | + def _property_listing(self, card, pins: dict[str, dict]) -> Listing | None: | |
| 170 | + a = card.select_one(".property-name a") or card.select_one("h3 a") | |
| 171 | + if not a or not a.get("href"): | |
| 172 | + return None | |
| 173 | + url = (a.get("href") or "").strip() | |
| 174 | + if url.startswith("/"): | |
| 175 | + url = self.client["site"].rstrip("/") + url | |
| 176 | + url = url.split("?")[0].rstrip("/") | |
| 177 | + | |
| 178 | + name = re.sub(r"\s*opens in a new tab\s*", "", | |
| 179 | + a.get_text(" ", strip=True)).strip() | |
| 180 | + | |
| 181 | + addr_el = card.select_one(".card-prop-address") | |
| 182 | + address = addr_el.get_text(" ", strip=True) if addr_el else "" | |
| 183 | + # Ontario seulement (Osgoode/GWLRA listent aussi AB/BC ; QC = connecteurs QC) | |
| 184 | + if not re.search(r",\s*ON(?:\s|,|$)", address): | |
| 185 | + return None | |
| 186 | + city = "" | |
| 187 | + parts = [p.strip() for p in address.split(",")] | |
| 188 | + for i, p in enumerate(parts): | |
| 189 | + if re.match(r"^ON(\s|$)", p) and i > 0: | |
| 190 | + city = parts[i - 1] | |
| 191 | + break | |
| 192 | + | |
| 193 | + # propertyid RentCafe stable (classe track-propertyurl-<id> ou épingle) | |
| 194 | + pid = "" | |
| 195 | + for el in card.select("[class*='track-propertyurl-']"): | |
| 196 | + for cl in el.get("class") or []: | |
| 197 | + if cl.startswith("track-propertyurl-"): | |
| 198 | + pid = cl.rsplit("-", 1)[-1] | |
| 199 | + break | |
| 200 | + slug = re.sub(r"[^a-z0-9]+", "-", | |
| 201 | + url.rstrip("/").rsplit("/", 1)[-1].lower()).strip("-") | |
| 202 | + external_id = pid or slug | |
| 203 | + if not external_id: | |
| 204 | + return None | |
| 205 | + | |
| 206 | + # lits / sdb / pi² de la carte (« 1.0Beds - 2.0Beds », « 799 - 1,018 Sq. Ft. ») | |
| 207 | + beds_txt = baths_txt = sqft_txt = "" | |
| 208 | + meta = card.select_one(".card-bed-bath-rent") | |
| 209 | + if meta: | |
| 210 | + for li in meta.select("li"): | |
| 211 | + it = li.get_text(" ", strip=True) | |
| 212 | + if "Bed" in it: | |
| 213 | + beds_txt = it | |
| 214 | + elif "Bath" in it: | |
| 215 | + baths_txt = it | |
| 216 | + elif "Sq" in it: | |
| 217 | + sqft_txt = re.sub(r"\s*to\s*-\s*", " - ", it) | |
| 218 | + unit_type = "" | |
| 219 | + bm = re.match(r"^(\d+)(?:\.\d+)?\s*Beds?", beds_txt or "") | |
| 220 | + if bm and "-" not in beds_txt.split("Bed")[0]: | |
| 221 | + unit_type = _BED_TYPES.get(int(bm.group(1)), "") | |
| 222 | + | |
| 223 | + # fourchette de prix : carte, sinon épingle de la carte interactive | |
| 224 | + pin = pins.get(external_id) or {} | |
| 225 | + pm = _PRICE_RE.search(card.get_text(" ", strip=True)) | |
| 226 | + price_label = pm.group(0) if pm else (pin.get("price") or "") | |
| 227 | + price_label = re.sub(r"\s*(?:to|à)\s*", " - ", price_label).strip() | |
| 228 | + price = _low_price(price_label) | |
| 229 | + if price is not None and "-" in price_label: | |
| 230 | + price_label = "À partir de " + price_label | |
| 231 | + if price is None: | |
| 232 | + price_label = "" # « Call for Details » : rien d'inventé | |
| 233 | + | |
| 234 | + phone = "" | |
| 235 | + tel = card.select_one("a[href^='tel:']") | |
| 236 | + if tel: | |
| 237 | + tm = _PHONE_RE.search(tel.get("href") or "") | |
| 238 | + if tm: | |
| 239 | + phone = f"{tm.group(1)}-{tm.group(2)}-{tm.group(3)}" | |
| 240 | + | |
| 241 | + images: list[str] = [] | |
| 242 | + img = card.select_one("img[src*='rentcafe']") or card.select_one("img") | |
| 243 | + if img and (img.get("src") or "").startswith("http") \ | |
| 244 | + and not _SKIP_IMG.search(img.get("src") or ""): | |
| 245 | + images.append(img["src"]) | |
| 246 | + | |
| 247 | + # fiche + plans via cache BD (clé = contenu de la carte liste) | |
| 248 | + key = hashlib.sha1( | |
| 249 | + f"{name}|{address}|{beds_txt}|{baths_txt}|{sqft_txt}|{price_label}" | |
| 250 | + .encode("utf-8")).hexdigest() | |
| 251 | + try: | |
| 252 | + payload = self.detail(external_id, key, | |
| 253 | + lambda: self._fetch_detail(url)) | |
| 254 | + except _DetailBudget: | |
| 255 | + payload = {} | |
| 256 | + except Exception: | |
| 257 | + payload = {} | |
| 258 | + | |
| 259 | + for im in payload.get("images") or []: | |
| 260 | + if im not in images: | |
| 261 | + images.append(im) | |
| 262 | + | |
| 263 | + # plans structurés : prix « à partir de » réel + résumé fidèle | |
| 264 | + plans = payload.get("floorplans") or [] | |
| 265 | + prices = [p["price"] for p in plans | |
| 266 | + if p.get("price") and 100 <= p["price"] <= 20000] | |
| 267 | + if prices: | |
| 268 | + price = min(prices) | |
| 269 | + price_label = (f"À partir de {price:,.0f} $/mois".replace(",", " ") | |
| 270 | + if len(plans) > 1 or "-" in price_label | |
| 271 | + else f"{price:,.0f} $/mois".replace(",", " ")) | |
| 272 | + sqfts = [p["sqft"] for p in plans if p.get("sqft")] | |
| 273 | + area_sqft = min(sqfts) if sqfts else None | |
| 274 | + if len(plans) == 1 and plans[0].get("unit_type"): | |
| 275 | + unit_type = plans[0]["unit_type"] | |
| 276 | + plan_bits = [] | |
| 277 | + for p in plans[:8]: | |
| 278 | + seg = p.get("name") or "" | |
| 279 | + if p.get("sqft"): | |
| 280 | + seg += f" ({p['sqft']:.0f} pi²)" | |
| 281 | + if p.get("price"): | |
| 282 | + seg += f" : {p['price']:,.0f} $/mois".replace(",", " ") | |
| 283 | + if seg: | |
| 284 | + plan_bits.append(seg) | |
| 285 | + | |
| 286 | + bathrooms = None | |
| 287 | + tb = re.match(r"^(\d+(?:\.\d+)?)\s*Bath", baths_txt or "") | |
| 288 | + if tb and "-" not in baths_txt.split("Bath")[0]: | |
| 289 | + bathrooms = float(tb.group(1)) | |
| 290 | + | |
| 291 | + details: dict = {} | |
| 292 | + if phone: | |
| 293 | + details["contact"] = {"phone": phone} | |
| 294 | + | |
| 295 | + desc_parts = ([payload["description"]] | |
| 296 | + if payload.get("description") else []) | |
| 297 | + desc_parts += [b for b in [beds_txt, baths_txt, sqft_txt] if b] | |
| 298 | + if plan_bits: | |
| 299 | + desc_parts.append("Plans : " + " ; ".join(plan_bits)) | |
| 300 | + | |
| 301 | + lat = pin.get("lat") | |
| 302 | + lng = pin.get("lng") | |
| 303 | + | |
| 304 | + return Listing( | |
| 305 | + source=self.source_id, | |
| 306 | + external_id=str(external_id), | |
| 307 | + url=url, | |
| 308 | + title=name or slug.replace("-", " ").title(), | |
| 309 | + address=address, | |
| 310 | + sector="", # le gabarit RentCafe ne publie pas le quartier | |
| 311 | + city=city, | |
| 312 | + province="ON", | |
| 313 | + unit_type=unit_type, | |
| 314 | + bathrooms=bathrooms, | |
| 315 | + price=price, | |
| 316 | + price_label=price_label, | |
| 317 | + availability="", # aucun décompte d'unités publié (cf. en-tête) | |
| 318 | + area_sqft=area_sqft, | |
| 319 | + description=" — ".join(desc_parts)[:900], | |
| 320 | + details=details, | |
| 321 | + images=images[: self.max_images], | |
| 322 | + lat=float(lat) if isinstance(lat, (int, float)) else None, | |
| 323 | + lng=float(lng) if isinstance(lng, (int, float)) else None, | |
| 324 | + ) | |
| 325 | + | |
| 326 | + # -- fiche propriété (galerie + description + plans) -------------------------- | |
| 327 | + def _fetch_detail(self, url: str) -> dict: | |
| 328 | + if self._detail_fetches >= self.max_details: | |
| 329 | + raise _DetailBudget() | |
| 330 | + self._detail_fetches += 1 | |
| 331 | + | |
| 332 | + payload: dict = {"description": "", "images": [], "floorplans": []} | |
| 333 | + html = self._page(url) | |
| 334 | + if html: | |
| 335 | + soup = BeautifulSoup(html, "html.parser") | |
| 336 | + for im in soup.select("img[src*='resource.rentcafe.com']"): | |
| 337 | + src = im.get("src") or "" | |
| 338 | + if src and not _SKIP_IMG.search(src) \ | |
| 339 | + and src not in payload["images"]: | |
| 340 | + payload["images"].append(src) | |
| 341 | + paras = [p.get_text(" ", strip=True) for p in soup.find_all("p")] | |
| 342 | + paras = [p for p in paras if len(p) > 80] | |
| 343 | + if paras: | |
| 344 | + payload["description"] = " ".join(paras[:2])[:600] | |
| 345 | + payload["floorplans"] = self._parse_floorplans(soup) | |
| 346 | + | |
| 347 | + # plans absents de la fiche (gabarit Osgoode/GWLRA) -> page /floorplans | |
| 348 | + if not payload["floorplans"] and not url.endswith("default.aspx") \ | |
| 349 | + and self._detail_fetches < self.max_details: | |
| 350 | + self._detail_fetches += 1 | |
| 351 | + fp_html = self._page(url + "/floorplans") | |
| 352 | + if fp_html: | |
| 353 | + payload["floorplans"] = self._parse_floorplans( | |
| 354 | + BeautifulSoup(fp_html, "html.parser")) | |
| 355 | + return payload | |
| 356 | + | |
| 357 | + # -- plans fp-container (2 gabarits RentCafe) ---------------------------------- | |
| 358 | + @staticmethod | |
| 359 | + def _parse_floorplans(soup) -> list[dict]: | |
| 360 | + """Cartes de plans : nom, chambres, sdb, pi², prix (borne basse d'une | |
| 361 | + fourchette). Gabarits : spans data-selenium-id (Osgoode) OU cartes | |
| 362 | + h2.card-title + icônes nu-bed/nu-bathroom/nu-area + attributs | |
| 363 | + data-floorplan-* (Effort, GWLRA). Dédoublonnés par id (carrousels).""" | |
| 364 | + plans: list[dict] = [] | |
| 365 | + seen: set[str] = set() | |
| 366 | + for cont in soup.select("div[id^='fp-container-']"): | |
| 367 | + fpid = (cont.get("id") or "").rsplit("-", 1)[-1] | |
| 368 | + if fpid in seen: | |
| 369 | + continue | |
| 370 | + seen.add(fpid) | |
| 371 | + try: | |
| 372 | + plan: dict = {} | |
| 373 | + # gabarit 1 : spans data-selenium-id | |
| 374 | + name_el = cont.select_one("span[data-selenium-id$='Name']") | |
| 375 | + # gabarit 2 : cartes (titre + icônes) | |
| 376 | + if name_el is None: | |
| 377 | + name_el = cont.select_one("h2.card-title, .card-title") | |
| 378 | + if name_el is not None: | |
| 379 | + plan["name"] = name_el.get_text(" ", strip=True) | |
| 380 | + | |
| 381 | + beds_el = cont.select_one("span[data-selenium-id$='Beds']") | |
| 382 | + beds_txt = (beds_el.get_text(" ", strip=True) if beds_el | |
| 383 | + else "") | |
| 384 | + if not beds_txt: | |
| 385 | + ic = cont.select_one(".nu-bed") | |
| 386 | + if ic and ic.parent: | |
| 387 | + beds_txt = ic.parent.get_text(" ", strip=True) | |
| 388 | + bm = re.search(r"(\d+)\s*Bed", beds_txt) | |
| 389 | + if bm: | |
| 390 | + plan["bedrooms"] = float(bm.group(1)) | |
| 391 | + plan["unit_type"] = _BED_TYPES.get(int(bm.group(1)), "") | |
| 392 | + elif re.search(r"studio", (plan.get("name") or "") + beds_txt, | |
| 393 | + re.I): | |
| 394 | + plan["bedrooms"] = 0.0 | |
| 395 | + plan["unit_type"] = "Studio" | |
| 396 | + | |
| 397 | + baths_el = cont.select_one("span[data-selenium-id$='Baths']") | |
| 398 | + baths_txt = (baths_el.get_text(" ", strip=True) if baths_el | |
| 399 | + else "") | |
| 400 | + if not baths_txt: | |
| 401 | + ic = cont.select_one(".nu-bathroom") | |
| 402 | + if ic and ic.parent: | |
| 403 | + baths_txt = ic.parent.get_text(" ", strip=True) | |
| 404 | + tm = re.search(r"(\d+(?:\.\d+)?)\s*Bath", baths_txt) | |
| 405 | + if tm: | |
| 406 | + plan["bathrooms"] = float(tm.group(1)) | |
| 407 | + | |
| 408 | + sq_el = cont.select_one("span[data-selenium-id$='SqFt']") | |
| 409 | + sq_txt = sq_el.get_text(" ", strip=True) if sq_el else "" | |
| 410 | + if not sq_txt: | |
| 411 | + ic = cont.select_one(".nu-area") | |
| 412 | + if ic and ic.parent: | |
| 413 | + sq_txt = ic.parent.get_text(" ", strip=True) | |
| 414 | + sv = _to_float(sq_txt) | |
| 415 | + if sv and 80 <= sv <= 20000: | |
| 416 | + plan["sqft"] = sv | |
| 417 | + | |
| 418 | + # prix : attribut structuré data-floorplan-price (« 2113 -2163 »), | |
| 419 | + # sinon encadré « Starting at $2,113.00 /Month », sinon span Rent | |
| 420 | + pv = None | |
| 421 | + btn = cont.select_one("[data-floorplan-price]") | |
| 422 | + if btn: | |
| 423 | + nums = [_to_float(x) for x in _NUM_RE.findall( | |
| 424 | + btn.get("data-floorplan-price") or "")] | |
| 425 | + nums = [n for n in nums if n and 100 <= n <= 20000] | |
| 426 | + if nums: | |
| 427 | + pv = min(nums) | |
| 428 | + if pv is None: | |
| 429 | + rent_el = cont.select_one( | |
| 430 | + "span[data-selenium-id$='Rent']") \ | |
| 431 | + or cont.select_one(".fieldset .font-weight-bold, " | |
| 432 | + ".fieldset span.font-weight-bold") | |
| 433 | + if rent_el: | |
| 434 | + pv = _low_price(rent_el.get_text(" ", strip=True)) | |
| 435 | + if pv is None: | |
| 436 | + pv = _low_price(" ".join( | |
| 437 | + _PRICE_RE.findall(cont.get_text(" ", strip=True)))) | |
| 438 | + if pv: | |
| 439 | + plan["price"] = pv | |
| 440 | + | |
| 441 | + if plan.get("name") or plan.get("price"): | |
| 442 | + plans.append(plan) | |
| 443 | + except Exception: | |
| 444 | + continue | |
| 445 | + return plans | |
| 446 | + | |
| 447 | + | |
| 448 | +# ============================================================================= | |
| 449 | +# Génération des sous-classes concrètes à partir du registre | |
| 450 | +# data/rentcafe_clients.json — une classe par client « ok », déposée dans les | |
| 451 | +# globals du module pour que connectors/__init__.py la découvre. Un registre | |
| 452 | +# absent/corrompu ne doit JAMAIS casser l'import du paquet (prod QC). | |
| 453 | +# ============================================================================= | |
| 454 | +def _load_clients() -> list[dict]: | |
| 455 | + try: | |
| 456 | + with open(_REGISTRY_PATH, encoding="utf-8") as f: | |
| 457 | + return json.load(f).get("clients") or [] | |
| 458 | + except (OSError, ValueError): | |
| 459 | + return [] | |
| 460 | + | |
| 461 | + | |
| 462 | +for _c in _load_clients(): | |
| 463 | + if (_c.get("status") or "") != "ok" or not _c.get("id"): | |
| 464 | + continue | |
| 465 | + _cls_name = "RC" + "".join( | |
| 466 | + w.capitalize() for w in re.split(r"[^a-z0-9]+", _c["id"]) if w) \ | |
| 467 | + + "Connector" | |
| 468 | + globals()[_cls_name] = type(_cls_name, (RentCafeClientConnector,), { | |
| 469 | + "source_id": f"rc_{_c['id']}", | |
| 470 | + "client": _c, | |
| 471 | + "disabled": not _ONTARIO, | |
| 472 | + "__doc__": f"Client RentCafe « {_c.get('name') or _c['id']} » " | |
| 473 | + f"({_c.get('regions') or 'Ontario'}) — généré depuis " | |
| 474 | + "data/rentcafe_clients.json.", | |
| 475 | + }) | |
| 476 | +del _c | |
added
louka/connectors/skyline.py
+339 −0
@@ -0,0 +1,339 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec + Ontario) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/skyline.py : connecteur Skyline Living (skylineliving.ca) | |
| 5 | +# Gestionnaire clé des villes secondaires ontariennes (~22 000 unités, | |
| 6 | +# ~38 villes : Guelph HQ, Est ontarien, Niagara, Sud-Ouest, Nord, Centre). | |
| 7 | +# Le site est une SPA Rentsync « nouvelle génération » : ses bundles | |
| 8 | +# (cdn.rentsync.com/site/skyline) appellent la passerelle JSON publique | |
| 9 | +# `https://website-gateway-cdn.rentsync.com/v1/skyline/…` (aucun jeton, | |
| 10 | +# le UA de base passe). Endpoints utilisés : | |
| 11 | +# - /properties?limit=1000 — tous les immeubles actifs (~193) : adresse, | |
| 12 | +# lat/lng, cityId, description, caractéristiques, animaux, contact ; | |
| 13 | +# - /cities?where=id~in:…&relations=province:p — nom de ville + province | |
| 14 | +# (filtre ON strict : Skyline a aussi quelques immeubles hors Ontario) ; | |
| 15 | +# - /units?where=status:enabled,available~in:1 — types d'unités avec | |
| 16 | +# vacance (loyer, cc/sdb, pi², date), paginés (meta.totalPages) ; | |
| 17 | +# - /photos?relations=buildingsHasPhotos:bhp&where=bhp.buildingId~in:… — | |
| 18 | +# galeries par lots d'immeubles (images S3 lws_lift, taille « full »). | |
| 19 | +# Une annonce par type d'unité disponible (comme capreit). NB : dans les | |
| 20 | +# tours neuves en location (ex. Sky Towers à Barrie), le flux publie UNE | |
| 21 | +# rangée PAR APPARTEMENT (champ `number`) du même plan — on regroupe donc | |
| 22 | +# par (immeuble, typeName, cc, sdb) : loyer plancher « à partir de », | |
| 23 | +# date la plus proche, identifiant stable immeuble+plan. Tout vient de | |
| 24 | +# l'API par lots (~15 requêtes au total) : aucune page détail à visiter, | |
| 25 | +# donc pas besoin du cache BD self.detail(). | |
| 26 | +# Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : sans la variable, le | |
| 27 | +# connecteur est `disabled` (exclu du registre), prod QC inchangée. | |
| 28 | +# NB : le parc Thunder Bay a été vendu (2025-2026) — absent du flux, normal. | |
| 29 | +# ----------------------------------------------------------------------------- | |
| 30 | +from __future__ import annotations | |
| 31 | + | |
| 32 | +import html as htmllib | |
| 33 | +import os | |
| 34 | +import re | |
| 35 | + | |
| 36 | +from bs4 import BeautifulSoup | |
| 37 | + | |
| 38 | +from ..schema import Listing, normalize_unit_type, strip_accents | |
| 39 | + | |
| 40 | +from .base import BaseConnector | |
| 41 | + | |
| 42 | +BASE = "https://www.skylineliving.ca" | |
| 43 | +GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/skyline" | |
| 44 | +IMG_BASE = "https://s3.amazonaws.com/lws_lift/skyline/images/gallery/full" | |
| 45 | + | |
| 46 | +# Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, connecteur inactif | |
| 47 | +_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" | |
| 48 | + | |
| 49 | +# secteur : le champ `neighbourhood` du flux contient souvent le comté | |
| 50 | +# (« Grey County », « Niagara Region ») — pas un quartier, on l'écarte | |
| 51 | +_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I) | |
| 52 | +_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}") | |
| 53 | + | |
| 54 | + | |
| 55 | +class SkylineConnector(BaseConnector): | |
| 56 | + source_id = "skyline" | |
| 57 | + request_delay = 0.8 | |
| 58 | + disabled = not _ONTARIO # gate Ontario (voir en-tête) | |
| 59 | + max_properties = 250 # garde-fou immeubles (193 au 2026-08) | |
| 60 | + max_listings = 1500 # garde-fou global (≈480 groupes au 2026-08) | |
| 61 | + max_images = 15 | |
| 62 | + page_limit = 750 # taille de page maximale de la passerelle | |
| 63 | + photo_chunk = 40 # immeubles par requête /photos | |
| 64 | + | |
| 65 | + # -- accès passerelle ------------------------------------------------------- | |
| 66 | + def _get_json(self, path: str, params: dict) -> dict: | |
| 67 | + resp = self.get(GATEWAY + path, params=params, headers={ | |
| 68 | + "Accept": "application/json", | |
| 69 | + "Origin": BASE, | |
| 70 | + "Referer": BASE + "/", | |
| 71 | + "rs-lang": "en", | |
| 72 | + }) | |
| 73 | + return resp.json() | |
| 74 | + | |
| 75 | + def _paged(self, path: str, params: dict, max_pages: int = 10) -> list[dict]: | |
| 76 | + """Concatène toutes les pages d'un endpoint (meta.totalPages).""" | |
| 77 | + out: list[dict] = [] | |
| 78 | + page = 1 | |
| 79 | + while page <= max_pages: | |
| 80 | + data = self._get_json(path, {**params, "page": page}) | |
| 81 | + out.extend(data.get("data") or []) | |
| 82 | + meta = data.get("meta") or {} | |
| 83 | + if page >= int(meta.get("totalPages") or 1): | |
| 84 | + break | |
| 85 | + page += 1 | |
| 86 | + return out | |
| 87 | + | |
| 88 | + # -- villes : cityId -> (nom, code province) -------------------------------- | |
| 89 | + def _cities(self, city_ids: set[int]) -> dict[int, tuple[str, str]]: | |
| 90 | + ids = "|".join(str(i) for i in sorted(city_ids)) | |
| 91 | + rows = self._paged("/cities", { | |
| 92 | + "where": f"id~in:{ids}", | |
| 93 | + "relations": "province:p", | |
| 94 | + "limit": self.page_limit, | |
| 95 | + }) | |
| 96 | + out: dict[int, tuple[str, str]] = {} | |
| 97 | + for c in rows: | |
| 98 | + prov = c.get("province") or {} | |
| 99 | + out[int(c["id"])] = ((c.get("cityName") or "").strip(), | |
| 100 | + (prov.get("provinceCode") or "").strip().upper()) | |
| 101 | + return out | |
| 102 | + | |
| 103 | + # -- galeries photo par lots d'immeubles ------------------------------------ | |
| 104 | + def _photos(self, building_ids: list[int]) -> dict[int, list[str]]: | |
| 105 | + out: dict[int, list[str]] = {} | |
| 106 | + for i in range(0, len(building_ids), self.photo_chunk): | |
| 107 | + chunk = building_ids[i:i + self.photo_chunk] | |
| 108 | + ids = "|".join(str(b) for b in chunk) | |
| 109 | + try: | |
| 110 | + rows = self._paged("/photos", { | |
| 111 | + "relations": "buildingsHasPhotos:bhp", | |
| 112 | + "where": f"bhp.buildingId~in:{ids}", | |
| 113 | + "orderBy": "bhp.orderBy~asc", | |
| 114 | + "limit": self.page_limit, | |
| 115 | + }) | |
| 116 | + except Exception: | |
| 117 | + continue # galerie manquante : annonces sans photo plutôt que rien | |
| 118 | + for ph in rows: | |
| 119 | + bid = ph.get("buildingId") | |
| 120 | + img = (ph.get("image") or "").strip() | |
| 121 | + if not bid or not img: | |
| 122 | + continue | |
| 123 | + urls = out.setdefault(int(bid), []) | |
| 124 | + if len(urls) < self.max_images: | |
| 125 | + u = f"{IMG_BASE}/{img}" | |
| 126 | + if u not in urls: | |
| 127 | + urls.append(u) | |
| 128 | + return out | |
| 129 | + | |
| 130 | + # -- collecte --------------------------------------------------------------- | |
| 131 | + def fetch(self) -> list[Listing]: | |
| 132 | + props = self._paged("/properties", {"limit": 1000})[: self.max_properties] | |
| 133 | + | |
| 134 | + # villes -> filtre Ontario strict (le flux a quelques immeubles hors ON) | |
| 135 | + cities = self._cities({int(p["cityId"]) for p in props if p.get("cityId")}) | |
| 136 | + on_props: dict[int, dict] = {} | |
| 137 | + for p in props: | |
| 138 | + name, prov = cities.get(int(p.get("cityId") or 0), ("", "")) | |
| 139 | + if prov != "ON" or not name: | |
| 140 | + continue | |
| 141 | + p["_city"] = name | |
| 142 | + on_props[int(p["id"])] = p | |
| 143 | + | |
| 144 | + # types d'unités avec vacance : dans les tours en location, le flux | |
| 145 | + # publie une rangée PAR APPARTEMENT du même plan — regrouper par | |
| 146 | + # (immeuble, plan, cc, sdb) pour une annonce par type d'unité | |
| 147 | + units = self._paged("/units", { | |
| 148 | + "where": "status:enabled,available~in:1", | |
| 149 | + "limit": self.page_limit, | |
| 150 | + }) | |
| 151 | + by_building: dict[int, dict[tuple, list[dict]]] = {} | |
| 152 | + for u in units: | |
| 153 | + bid = int(u.get("buildingId") or 0) | |
| 154 | + if bid not in on_props: | |
| 155 | + continue | |
| 156 | + key = ((u.get("typeName") or "").strip().lower(), | |
| 157 | + u.get("bedMin", u.get("bed")), | |
| 158 | + u.get("bathMin", u.get("bath"))) | |
| 159 | + by_building.setdefault(bid, {}).setdefault(key, []).append(u) | |
| 160 | + | |
| 161 | + # galeries des seuls immeubles qui produisent des annonces | |
| 162 | + photos = self._photos(sorted(by_building)) | |
| 163 | + | |
| 164 | + listings: list[Listing] = [] | |
| 165 | + for bid, groups in by_building.items(): | |
| 166 | + if len(listings) >= self.max_listings: | |
| 167 | + break | |
| 168 | + p = on_props[bid] | |
| 169 | + try: | |
| 170 | + base = self._building_ctx(p, photos.get(bid) or []) | |
| 171 | + except Exception: | |
| 172 | + continue | |
| 173 | + for rows in groups.values(): | |
| 174 | + if len(listings) >= self.max_listings: | |
| 175 | + break | |
| 176 | + try: | |
| 177 | + listings.append(self._listing(bid, rows, base)) | |
| 178 | + except Exception: | |
| 179 | + continue | |
| 180 | + return listings | |
| 181 | + | |
| 182 | + # -- contexte immeuble (partagé entre ses annonces) -------------------------- | |
| 183 | + def _building_ctx(self, p: dict, images: list[str]) -> dict: | |
| 184 | + city = p["_city"] | |
| 185 | + street = " ".join(x for x in ( | |
| 186 | + (p.get("streetNumber") or "").strip(), | |
| 187 | + (p.get("streetName") or "").strip()) if x) | |
| 188 | + postal = (p.get("postal") or "").strip() | |
| 189 | + address = ", ".join(x for x in (street, city) if x) | |
| 190 | + if address: | |
| 191 | + address += f", ON {postal}".rstrip() | |
| 192 | + | |
| 193 | + # secteur : quartier seulement (écarter comtés/« regions » du flux) | |
| 194 | + sector = (p.get("neighbourhood") or "").strip() | |
| 195 | + if _COUNTY_RE.search(sector): | |
| 196 | + sector = "" | |
| 197 | + | |
| 198 | + try: | |
| 199 | + lat = float(p["latitude"]) if p.get("latitude") else None | |
| 200 | + lng = float(p["longitude"]) if p.get("longitude") else None | |
| 201 | + except (TypeError, ValueError): | |
| 202 | + lat = lng = None | |
| 203 | + | |
| 204 | + # caractéristiques de l'immeuble : HTML doublement échappé dans le flux | |
| 205 | + amenities: list[str] = [] | |
| 206 | + feats = htmllib.unescape(p.get("buildingFeatures") or "") | |
| 207 | + for li in BeautifulSoup(feats, "html.parser").find_all("li"): | |
| 208 | + t = li.get_text(" ", strip=True) | |
| 209 | + if t and t not in amenities: | |
| 210 | + amenities.append(t) | |
| 211 | + | |
| 212 | + # description : aperçu de l'immeuble (+ détails des suites si présents) | |
| 213 | + desc_html = (p.get("buildingOverview") or "") + " " + \ | |
| 214 | + (p.get("suiteDetails") or "") | |
| 215 | + desc = BeautifulSoup(htmllib.unescape(desc_html), | |
| 216 | + "html.parser").get_text(" ", strip=True) | |
| 217 | + | |
| 218 | + # animaux : drapeaux structurés du flux | |
| 219 | + pets = None | |
| 220 | + if p.get("petsNotAllowed") == 1: | |
| 221 | + pets = "non" | |
| 222 | + elif p.get("petFriendly") == 1: | |
| 223 | + pets = "oui" | |
| 224 | + | |
| 225 | + details: dict = {} | |
| 226 | + contact: dict = {} | |
| 227 | + if (p.get("phone") or "").strip(): | |
| 228 | + contact["phone"] = p["phone"].strip() | |
| 229 | + email = (p.get("email") or "").split(",")[0].strip() | |
| 230 | + if email: | |
| 231 | + contact["email"] = email | |
| 232 | + if contact: | |
| 233 | + details["contact"] = contact | |
| 234 | + try: | |
| 235 | + if int(p.get("yearBuilt") or 0) > 1800: | |
| 236 | + details["Année de construction"] = int(p["yearBuilt"]) | |
| 237 | + if int(p.get("floorCount") or 0) > 0: | |
| 238 | + details["Étages"] = int(p["floorCount"]) | |
| 239 | + except (TypeError, ValueError): | |
| 240 | + pass | |
| 241 | + | |
| 242 | + perma = (p.get("fullPermaLink") or "").strip().strip("/") | |
| 243 | + url = f"{BASE}/en/{perma}" if perma else BASE | |
| 244 | + | |
| 245 | + return {"city": city, "address": address, "sector": sector, | |
| 246 | + "lat": lat, "lng": lng, "amenities": amenities, "desc": desc, | |
| 247 | + "pets": pets, "details": details, "url": url, | |
| 248 | + "images": images[: self.max_images], | |
| 249 | + "name": (p.get("buildingName") or "").strip()} | |
| 250 | + | |
| 251 | + # -- une annonce par type d'unité disponible (rangées regroupées) ------------ | |
| 252 | + def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing: | |
| 253 | + u = rows[0] | |
| 254 | + type_name = (u.get("typeName") or "").strip() | |
| 255 | + unit_type = normalize_unit_type(type_name) | |
| 256 | + bed = u.get("bedMin", u.get("bed")) | |
| 257 | + bath = u.get("bathMin", u.get("bath")) | |
| 258 | + if bed is not None and (not unit_type or unit_type == type_name): | |
| 259 | + # plans nommés (« Pinnacle », « Tempo »…) : dériver des chambres | |
| 260 | + unit_type = ("Studio" if int(bed) == 0 | |
| 261 | + else normalize_unit_type(f"{int(bed)} chambres")) | |
| 262 | + | |
| 263 | + # loyer : plancher du groupe (masqué sur le site si hideRateWebsites=1) | |
| 264 | + rates: list[float] = [] | |
| 265 | + for r in rows: | |
| 266 | + if r.get("hideRateWebsites"): | |
| 267 | + continue | |
| 268 | + try: | |
| 269 | + lo = float(r.get("rateMin") or r.get("rate") or 0) | |
| 270 | + hi = float(r.get("rateMax") or lo) | |
| 271 | + except (TypeError, ValueError): | |
| 272 | + continue | |
| 273 | + if lo > 0: | |
| 274 | + rates += [lo, max(hi, lo)] | |
| 275 | + price = min(rates) if rates else None | |
| 276 | + price_label = "" | |
| 277 | + if price is not None: | |
| 278 | + price_label = (f"À partir de {price:.0f} $" if max(rates) > price | |
| 279 | + else f"{price:.0f} $ /mois") | |
| 280 | + | |
| 281 | + # superficie plausible seulement (le flux publie parfois 0) | |
| 282 | + area = None | |
| 283 | + for r in rows: | |
| 284 | + try: | |
| 285 | + v = float(r.get("sqFtMin") or r.get("sqFt") or 0) | |
| 286 | + except (TypeError, ValueError): | |
| 287 | + continue | |
| 288 | + if 80 <= v <= 20000 and (area is None or v < area): | |
| 289 | + area = v | |
| 290 | + | |
| 291 | + # disponibilité : date structurée la plus proche du groupe | |
| 292 | + dates = sorted(str(r.get("availabilityDate") or "").strip()[:10] | |
| 293 | + for r in rows | |
| 294 | + if _ISO_DATE_RE.match(str(r.get("availabilityDate") | |
| 295 | + or ""))) | |
| 296 | + avail_date = dates[0] if dates else None | |
| 297 | + availability = f"Disponible le {avail_date}" if avail_date \ | |
| 298 | + else "Disponible" | |
| 299 | + if len(rows) > 1: | |
| 300 | + availability += f" — {len(rows)} unités" | |
| 301 | + | |
| 302 | + details = dict(b["details"]) | |
| 303 | + if (u.get("leaseTerm") or "").strip(): | |
| 304 | + details["Bail"] = u["leaseTerm"].strip() | |
| 305 | + | |
| 306 | + # identifiant stable : immeuble + plan (les ids d'appartements roulent) | |
| 307 | + slug = re.sub(r"[^a-z0-9]+", "-", | |
| 308 | + strip_accents(type_name.lower())).strip("-") | |
| 309 | + ext = f"{bid}-{slug or 'u'}" | |
| 310 | + if bed is not None or bath is not None: | |
| 311 | + ext += f"-{bed}-{bath}" | |
| 312 | + | |
| 313 | + return Listing( | |
| 314 | + source=self.source_id, | |
| 315 | + external_id=ext, | |
| 316 | + url=b["url"], | |
| 317 | + title=f"{b['name']} — {type_name}" if type_name else b["name"], | |
| 318 | + address=b["address"], | |
| 319 | + sector=b["sector"], | |
| 320 | + city=b["city"], | |
| 321 | + province="ON", | |
| 322 | + unit_type=unit_type, | |
| 323 | + bedrooms=float(bed) if bed is not None else None, | |
| 324 | + bathrooms=float(bath) if bath else None, | |
| 325 | + price=price, | |
| 326 | + price_label=price_label, | |
| 327 | + availability=availability, | |
| 328 | + availability_date=avail_date, | |
| 329 | + area_sqft=area, | |
| 330 | + pets=b["pets"], | |
| 331 | + furnished=True if all(r.get("furnished") == 1 | |
| 332 | + for r in rows) else None, | |
| 333 | + description=b["desc"][:600], | |
| 334 | + amenities=b["amenities"][:25], | |
| 335 | + details=details, | |
| 336 | + images=b["images"], | |
| 337 | + lat=b["lat"], | |
| 338 | + lng=b["lng"], | |
| 339 | + ) | |
modified
louka/db.py
+8 −4
@@ -273,6 +273,7 @@ _MIGRATIONS = { | ||
| 273 | 273 | "img_audit": "TEXT", # JSON : traçabilité du contrôle images |
| 274 | 274 | "coord_key": "TEXT", # clé immeuble « lat,lng » 4 déc. (jointure kascores) |
| 275 | 275 | "building_key": "TEXT", # clé immeuble par adresse (buildings.bkey) |
| 276 | + "province": "TEXT DEFAULT 'QC'", # « QC » | « ON » (expansion Ontario 2026-08) | |
| 276 | 277 | }, |
| 277 | 278 | "sync_log": { |
| 278 | 279 | "stats": "TEXT", |
@@ -444,7 +445,9 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 444 | 445 | params = dict( |
| 445 | 446 | uid=lst.uid, source=lst.source, external_id=lst.external_id, |
| 446 | 447 | url=lst.url, title=lst.title, address=lst.address, |
| 447 | − sector=lst.sector, city=lst.city, unit_type=lst.unit_type, | |
| 448 | + sector=lst.sector, city=lst.city, | |
| 449 | + province=(getattr(lst, "province", None) or "QC"), | |
| 450 | + unit_type=lst.unit_type, | |
| 448 | 451 | bedrooms=lst.bedrooms, bathrooms=lst.bathrooms, |
| 449 | 452 | price=lst.price, price_label=lst.price_label, |
| 450 | 453 | availability=lst.availability, |
@@ -465,15 +468,15 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 465 | 468 | if row is None: |
| 466 | 469 | con.execute( |
| 467 | 470 | """INSERT INTO listings (uid, source, external_id, url, title, |
| 468 | − address, sector, city, unit_type, bedrooms, bathrooms, | |
| 469 | − price, price_label, | |
| 471 | + address, sector, city, province, unit_type, bedrooms, | |
| 472 | + bathrooms, price, price_label, | |
| 470 | 473 | availability, availability_date, area_sqft, pets, furnished, |
| 471 | 474 | description, digest, amenities, details, images, lat, lng, |
| 472 | 475 | content_hash, first_seen, last_seen, updated_at, |
| 473 | 476 | miss_count, active, completeness, published, |
| 474 | 477 | quality_reasons) |
| 475 | 478 | VALUES (:uid,:source,:external_id,:url,:title,:address, |
| 476 | − :sector,:city,:unit_type,:bedrooms,:bathrooms, | |
| 479 | + :sector,:city,:province,:unit_type,:bedrooms,:bathrooms, | |
| 477 | 480 | :price,:price_label,:availability, |
| 478 | 481 | :availability_date,:area_sqft,:pets,:furnished, |
| 479 | 482 | :description,:digest,:amenities,:details,:images,:lat,:lng, |
@@ -488,6 +491,7 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 488 | 491 | con.execute( |
| 489 | 492 | """UPDATE listings SET url=:url, title=:title, |
| 490 | 493 | address=:address, sector=:sector, city=:city, |
| 494 | + province=:province, | |
| 491 | 495 | unit_type=:unit_type, bedrooms=:bedrooms, |
| 492 | 496 | bathrooms=:bathrooms, price=:price, |
| 493 | 497 | price_label=:price_label, availability=:availability, |
modified
louka/geocode.py
+82 −23
@@ -36,12 +36,17 @@ _BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin | ||
| 36 | 36 | # garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord |
| 37 | 37 | # et la Gaspésie (expansion provinciale) — même boîte que schema.finalize() |
| 38 | 38 | _BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00) |
| 39 | +# Ontario (expansion 2026-08) : Windsor/île Pelée au sud, Kenora à l'ouest — | |
| 40 | +# même boîte que schema.finalize() | |
| 41 | +_BBOX_ONTARIO = (41.6, 56.9, -95.3, -74.3) | |
| 39 | 42 | |
| 40 | 43 | _VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures", |
| 41 | 44 | "l'ancienne-lorette", "ancienne-lorette"} |
| 42 | 45 | |
| 43 | 46 | |
| 44 | −def _bbox_for(city: str) -> tuple[float, float, float, float]: | |
| 47 | +def _bbox_for(city: str, province: str = "QC") -> tuple[float, float, float, float]: | |
| 48 | + if (province or "QC").upper() == "ON": | |
| 49 | + return _BBOX_ONTARIO | |
| 45 | 50 | key = strip_accents((city or "").strip().lower()) |
| 46 | 51 | return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC |
| 47 | 52 | |
@@ -58,6 +63,16 @@ def norm_key(address: str) -> str: | ||
| 58 | 63 | return re.sub(r"\s+", " ", s).strip() |
| 59 | 64 | |
| 60 | 65 | |
| 66 | +def cache_key(address: str, province: str = "QC") -> str: | |
| 67 | + """Clé geocode_cache province-aware : préfixe « on| » pour l'Ontario | |
| 68 | + (évite toute collision d'adresses homonymes entre provinces) ; les clés | |
| 69 | + québécoises restent identiques — le cache existant est préservé.""" | |
| 70 | + k = norm_key(address) | |
| 71 | + if k and (province or "QC").upper() == "ON": | |
| 72 | + return "on|" + k | |
| 73 | + return k | |
| 74 | + | |
| 75 | + | |
| 61 | 76 | class Geocoder: |
| 62 | 77 | def __init__(self, con) -> None: |
| 63 | 78 | self.con = con |
@@ -88,12 +103,18 @@ class Geocoder: | ||
| 88 | 103 | s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s) |
| 89 | 104 | return s |
| 90 | 105 | |
| 91 | − def _build_query(self, address: str, city: str) -> str: | |
| 106 | + def _build_query(self, address: str, city: str, | |
| 107 | + province: str = "QC") -> str: | |
| 92 | 108 | q = self._clean(address) |
| 93 | 109 | key = strip_accents(q.lower()) |
| 94 | 110 | if city and strip_accents(city.lower()) not in key: |
| 95 | 111 | q += f", {city}" |
| 96 | − if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower(): | |
| 112 | + if (province or "QC").upper() == "ON": | |
| 113 | + # « ON » en majuscules seulement : « on » minuscule est un mot français | |
| 114 | + if "ontario" not in strip_accents(q.lower()) \ | |
| 115 | + and not re.search(r"\bON\b", q): | |
| 116 | + q += ", Ontario" | |
| 117 | + elif "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower(): | |
| 97 | 118 | q += ", Québec" |
| 98 | 119 | return q + ", Canada" |
| 99 | 120 | |
@@ -149,7 +170,8 @@ class Geocoder: | ||
| 149 | 170 | except (KeyError, ValueError): |
| 150 | 171 | return None |
| 151 | 172 | |
| 152 | − def _attempts(self, address: str, city: str) -> list[dict]: | |
| 173 | + def _attempts(self, address: str, city: str, | |
| 174 | + province: str = "QC") -> list[dict]: | |
| 153 | 175 | """Stratégies de requête, de la plus précise à la moins précise. |
| 154 | 176 | |
| 155 | 177 | 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province) |
@@ -157,27 +179,36 @@ class Geocoder: | ||
| 157 | 179 | 3) structurée rue seule -> centroïde de rue (repli acceptable pour la |
| 158 | 180 | carte quand le numéro civique est absent d'OpenStreetMap) |
| 159 | 181 | """ |
| 182 | + prov = (province or "QC").upper() | |
| 160 | 183 | premier = self._clean(address).split(",")[0].strip() |
| 161 | 184 | ville = (city or "").strip() |
| 162 | − if not ville: | |
| 185 | + if not ville and prov != "ON": | |
| 186 | + # heuristique historique QC seulement — en Ontario, sans ville, | |
| 187 | + # on s'en remet à la recherche libre (pas de ville par défaut) | |
| 163 | 188 | k = strip_accents(address.lower()) |
| 164 | 189 | ville = "Lévis" if "levis" in k else "Québec" |
| 165 | − commun = {"city": ville, "state": "Québec", "country": "Canada"} | |
| 190 | + commun = {"state": "Ontario" if prov == "ON" else "Québec", | |
| 191 | + "country": "Canada"} | |
| 192 | + if ville: | |
| 193 | + commun["city"] = ville | |
| 166 | 194 | |
| 167 | 195 | tries: list[dict] = [] |
| 168 | 196 | m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier) |
| 169 | 197 | if m: |
| 170 | 198 | tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun}) |
| 171 | − tries.append({"q": self._build_query(address, city)}) | |
| 199 | + tries.append({"q": self._build_query(address, city, prov)}) | |
| 172 | 200 | if m: |
| 173 | 201 | tries.append({"street": m.group(2), **commun}) |
| 174 | 202 | return tries |
| 175 | 203 | |
| 176 | − def resolve(self, address: str, city: str) -> tuple[float, float] | None: | |
| 204 | + def resolve(self, address: str, city: str, | |
| 205 | + province: str = "QC") -> tuple[float, float] | None: | |
| 177 | 206 | """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable.""" |
| 178 | − key = norm_key(address) | |
| 179 | − if not key or len(key) < 6: | |
| 207 | + prov = (province or "QC").upper() | |
| 208 | + nk = norm_key(address) | |
| 209 | + if not nk or len(nk) < 6: | |
| 180 | 210 | return None |
| 211 | + key = ("on|" + nk) if prov == "ON" else nk # = cache_key(address, prov) | |
| 181 | 212 | row = self.con.execute( |
| 182 | 213 | "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?", |
| 183 | 214 | (key,)).fetchone() |
@@ -187,15 +218,17 @@ class Geocoder: | ||
| 187 | 218 | if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER: |
| 188 | 219 | return None # échec récent : ne pas marteler l'API |
| 189 | 220 | |
| 190 | − bbox = _bbox_for(city) | |
| 221 | + bbox = _bbox_for(city, prov) | |
| 191 | 222 | coords = None |
| 192 | 223 | provider = "nominatim" |
| 193 | − for params in self._attempts(address, city): | |
| 224 | + for params in self._attempts(address, city, prov): | |
| 194 | 225 | c = self._query_nominatim(params) |
| 195 | 226 | if c is not None and _in_bbox(*c, bbox): |
| 196 | 227 | coords = c |
| 197 | 228 | break |
| 198 | − if coords is None: | |
| 229 | + if coords is None and prov != "ON": | |
| 230 | + # repli Adresses Québec (MERN) : dataset québécois SEULEMENT — | |
| 231 | + # jamais interrogé pour une adresse ontarienne | |
| 199 | 232 | c = self._query_adresses_quebec(address, city) |
| 200 | 233 | if c is not None and _in_bbox(*c, bbox): |
| 201 | 234 | coords = c |
@@ -222,14 +255,14 @@ def run(limit: int | None = None) -> dict: | ||
| 222 | 255 | con = db.connect() |
| 223 | 256 | geo = Geocoder(con) |
| 224 | 257 | rows = con.execute( |
| 225 | − """SELECT uid, address, city FROM listings | |
| 258 | + """SELECT uid, address, city, province FROM listings | |
| 226 | 259 | WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0 |
| 227 | 260 | ORDER BY address""").fetchall() |
| 228 | 261 | |
| 229 | − # regrouper par immeuble (adresse normalisée) | |
| 262 | + # regrouper par immeuble (adresse normalisée, préfixée par province) | |
| 230 | 263 | groupes: dict[str, list] = {} |
| 231 | 264 | for r in rows: |
| 232 | − groupes.setdefault(norm_key(r["address"]), []).append(r) | |
| 265 | + groupes.setdefault(cache_key(r["address"], r["province"]), []).append(r) | |
| 233 | 266 | |
| 234 | 267 | done = failed = requests_made = 0 |
| 235 | 268 | for key, members in groupes.items(): |
@@ -241,7 +274,8 @@ def run(limit: int | None = None) -> dict: | ||
| 241 | 274 | if limit is not None and requests_made >= limit: |
| 242 | 275 | continue |
| 243 | 276 | requests_made += 1 |
| 244 | − coords = geo.resolve(members[0]["address"], members[0]["city"]) | |
| 277 | + coords = geo.resolve(members[0]["address"], members[0]["city"], | |
| 278 | + members[0]["province"]) | |
| 245 | 279 | if coords: |
| 246 | 280 | for r in members: |
| 247 | 281 | con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", |
@@ -281,16 +315,18 @@ def run_batch(limit: int | None = None) -> dict: | ||
| 281 | 315 | con = db.connect() |
| 282 | 316 | geo = Geocoder(con) # réutilise _clean / bbox |
| 283 | 317 | rows = con.execute( |
| 284 | − """SELECT uid, address, city FROM listings | |
| 318 | + """SELECT uid, address, city, province FROM listings | |
| 285 | 319 | WHERE active=1 AND lat IS NULL AND address IS NOT NULL |
| 286 | 320 | AND address<>'' AND geocode_failed=0 ORDER BY address""").fetchall() |
| 287 | 321 | uniq: dict[str, dict] = {} |
| 288 | 322 | for r in rows: |
| 289 | − k = norm_key(r["address"]) | |
| 290 | − if not k or len(k) < 6: | |
| 323 | + prov = (r["province"] or "QC").upper() | |
| 324 | + nk = norm_key(r["address"]) | |
| 325 | + if not nk or len(nk) < 6: | |
| 291 | 326 | continue |
| 327 | + k = ("on|" + nk) if prov == "ON" else nk # = cache_key() | |
| 292 | 328 | u = uniq.setdefault(k, {"address": r["address"], "city": r["city"], |
| 293 | − "members": []}) | |
| 329 | + "province": prov, "members": []}) | |
| 294 | 330 | u["members"].append(r["uid"]) |
| 295 | 331 | pending = [] |
| 296 | 332 | for k, u in uniq.items(): |
@@ -308,9 +344,31 @@ def run_batch(limit: int | None = None) -> dict: | ||
| 308 | 344 | if limit is not None: |
| 309 | 345 | pending = pending[:limit] |
| 310 | 346 | |
| 347 | + # Ontario : le lot Adresses Québec (MERN) ne couvre QUE le Québec — | |
| 348 | + # les adresses ON passent par Nominatim unitaire (resolve, throttlé), | |
| 349 | + # qui écrit lui-même geocode_cache (clé « on|… ») et saute le repli AQ. | |
| 350 | + pending_on = [(k, u) for k, u in pending if u["province"] == "ON"] | |
| 351 | + pending = [(k, u) for k, u in pending if u["province"] != "ON"] | |
| 352 | + done = failed = 0 | |
| 353 | + for _k, u in pending_on: | |
| 354 | + coords = geo.resolve(u["address"], u["city"], "ON") | |
| 355 | + if coords: | |
| 356 | + for uid in u["members"]: | |
| 357 | + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", | |
| 358 | + (coords[0], coords[1], uid)) | |
| 359 | + done += len(u["members"]) | |
| 360 | + else: | |
| 361 | + for uid in u["members"]: | |
| 362 | + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", | |
| 363 | + (uid,)) | |
| 364 | + failed += len(u["members"]) | |
| 365 | + con.commit() | |
| 366 | + if pending_on: | |
| 367 | + print(f"[lou-ka] geocode-batch ON {len(pending_on)} adresse(s) " | |
| 368 | + f"(résolues {done}, échecs {failed})") | |
| 369 | + | |
| 311 | 370 | session = requests.Session() |
| 312 | 371 | session.headers["User-Agent"] = USER_AGENT |
| 313 | − done = failed = 0 | |
| 314 | 372 | for i in range(0, len(pending), BATCH_SIZE): |
| 315 | 373 | chunk = pending[i:i + BATCH_SIZE] |
| 316 | 374 | records = {"records": [ |
@@ -371,6 +429,7 @@ def run_batch(limit: int | None = None) -> dict: | ||
| 371 | 429 | f"(résolues {done}, échecs {failed})") |
| 372 | 430 | con.close() |
| 373 | 431 | stats = {"geocoded": done, "failed": failed, |
| 374 | − "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE} | |
| 432 | + "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE, | |
| 433 | + "ontario": len(pending_on)} | |
| 375 | 434 | print(f"[lou-ka] geocode-batch {stats}") |
| 376 | 435 | return stats |
modified
louka/quality.py
+4 −2
@@ -36,11 +36,13 @@ W_URL = 3 # lien vers l'annonce originale (contact / réservation) | ||
| 36 | 36 | # Ces annonces passent le score de complétude mais ne sont pas des logements : |
| 37 | 37 | # on les met en quarantaine (published=0) avec une raison « hors sujet ». |
| 38 | 38 | |
| 39 | −# vocabulaire logement (titre/description) | |
| 39 | +# vocabulaire logement (titre/description) — inclut les termes anglais du | |
| 40 | +# marché ontarien (expansion 2026-08) : un logement ON légitime ne doit | |
| 41 | +# jamais tomber en quarantaine « hors sujet » faute de vocabulaire | |
| 40 | 42 | _RE_RES = re.compile( |
| 41 | 43 | r"chambre|room|studio|appart|apartment|condo|loft|maison|house|" |
| 42 | 44 | r"logement|logis\b|r[ée]sidences?\b|chalet|penthouse|duplex|triplex|" |
| 43 | − r"bachelor|colocation|" | |
| 45 | + r"bachelor|colocation|townhouses?|bungalows?|" | |
| 44 | 46 | r"sous-sol|basement|½|1/2|\b\d[.,]5\b|\bdemie?s?\b|\bbed(?:room)?s?\b|" |
| 45 | 47 | r"\bunit[ée]?s?\b|\bpi[eè]ces\b", re.I) |
| 46 | 48 | |
modified
louka/schema.py
+12 −4
@@ -53,6 +53,7 @@ class Listing: | ||
| 53 | 53 | address: str = "" # adresse civique |
| 54 | 54 | sector: str = "" # quartier/arrondissement (ex. Beauport) |
| 55 | 55 | city: str = "" # Québec, Lévis, ... |
| 56 | + province: str = "QC" # "QC" | "ON" (expansion Ontario 2026-08) | |
| 56 | 57 | unit_type: str = "" # 1½ … 5½, 6½+, Studio, Loft, Chambre… |
| 57 | 58 | bedrooms: float | None = None # chambres fermées (convention QC : 4½ = 2) |
| 58 | 59 | bathrooms: float | None = None # salles de bain (1.5 = salle d'eau en plus) |
@@ -103,11 +104,18 @@ class Listing: | ||
| 103 | 104 | if self.area_sqft is not None: |
| 104 | 105 | break |
| 105 | 106 | |
| 106 | − # coordonnées fournies par la source : rejeter tout point hors de la | |
| 107 | − # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra | |
| 108 | − # le relais sur l'adresse plutôt que d'afficher un point au Kazakhstan | |
| 107 | + # coordonnées fournies par la source : rejeter tout point hors du | |
| 108 | + # territoire couvert (lat/lng inversés, 0/0, coquilles) — le géocodeur | |
| 109 | + # prendra le relais sur l'adresse plutôt que d'afficher un point au | |
| 110 | + # Kazakhstan. QC : 44.5–63°N / -80–-56°E ; ON (expansion 2026-08) : | |
| 111 | + # 41.6°N (Windsor/Pelée) à 56.9°N, -95.2°E (Kenora) à -74.3°E. | |
| 109 | 112 | if self.lat is not None and self.lng is not None: |
| 110 | − if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0): | |
| 113 | + prov = (self.province or "QC").upper() | |
| 114 | + if prov == "ON": | |
| 115 | + ok = 41.6 <= self.lat <= 56.9 and -95.3 <= self.lng <= -74.3 | |
| 116 | + else: | |
| 117 | + ok = 44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0 | |
| 118 | + if not ok: | |
| 111 | 119 | self.lat = self.lng = None |
| 112 | 120 | |
| 113 | 121 | derived = extract_details(self.amenities, self.description, self.title) |
| 114 | 122 | |