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