Court terme : upgrade majeur — 21 nouveaux connecteurs, 12 enrichis, fiches améliorées
- 21 nouveaux connecteurs (chaleto, hébergement-charlevoix, chaletsalpins,
memoria, pourvoiries FPQ, campingquebec, hebergia, rezerve, accestremblant,
chaletsdanslenord, alouerauxiles, rvmt, boraboreal, homminichalets…)
- enrichissement des existants : airbnb (desc/capacité/commodités via PDP),
booking/vrbo/expedia (Apollo SSR, bug slug vrbo corrigé), sepaq (desc+géo),
tremblantliving (prix API Streamline), qldc/kijiji/parcscanada/bonjourquebec/
gitespassant ; sinistar : prix vérifiés inaccessibles (documenté)
- fiches CT : /api/ct/listings/{uid}/context (analyse prix/nuit par segment,
histogramme, centile) + badge marché + hébergements similaires à proximité
- registre sources_ct.json : 52 sources (7 mortes retirées, 21 ajoutées)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
41 changed files +5,841 −109
modified
data/sources_ct.json
+324 −34
@@ -1,35 +1,325 @@ | |||||
| 1 | { | 1 | { | ||
| 2 | − "sources": [ | 2 | + "sources": [ | ||
| 3 | − { "id": "airbnb", "name": "Airbnb", "url": "https://www.airbnb.ca", "category": "internationale" }, | 3 | + { | ||
| 4 | − { "id": "booking", "name": "Booking.com", "url": "https://www.booking.com", "category": "internationale" }, | 4 | + "id": "airbnb", | ||
| 5 | − { "id": "vrbo", "name": "Vrbo", "url": "https://www.vrbo.com", "category": "internationale" }, | 5 | + "name": "Airbnb", | ||
| 6 | − { "id": "expedia", "name": "Expedia (locations de vacances)", "url": "https://www.expedia.ca", "category": "internationale" }, | 6 | + "url": "https://www.airbnb.ca", | ||
| 7 | − { "id": "agoda", "name": "Agoda Homes", "url": "https://www.agoda.com", "category": "internationale", "status": "en attente de connecteur" }, | 7 | + "category": "internationale" | ||
| 8 | − { "id": "plumguide", "name": "Plum Guide", "url": "https://www.plumguide.com", "category": "internationale", "status": "en attente de connecteur" }, | 8 | + }, | ||
| 9 | − { "id": "glampinghub", "name": "Glamping Hub", "url": "https://glampinghub.com", "category": "internationale" }, | 9 | + { | ||
| 10 | − { "id": "hipcamp", "name": "Hipcamp", "url": "https://www.hipcamp.com", "category": "internationale" }, | 10 | + "id": "booking", | ||
| 11 | − { "id": "marriott_hvmi", "name": "Marriott Homes & Villas", "url": "https://homes-and-villas.marriott.com", "category": "internationale", "status": "en attente de connecteur" }, | 11 | + "name": "Booking.com", | ||
| 12 | − { "id": "furnishedfinder", "name": "Furnished Finder", "url": "https://www.furnishedfinder.com", "category": "internationale", "status": "en attente de connecteur" }, | 12 | + "url": "https://www.booking.com", | ||
| 13 | − { "id": "corporatestays", "name": "Corporate Stays", "url": "https://www.corporatestays.com", "category": "internationale" }, | 13 | + "category": "internationale" | ||
| 14 | − { "id": "monsieurchalets", "name": "MonsieurChalets", "url": "https://www.monsieurchalets.com", "category": "quebecoise" }, | 14 | + }, | ||
| 15 | − { "id": "wechalet", "name": "WeChalet", "url": "https://wechalet.com", "category": "quebecoise" }, | 15 | + { | ||
| 16 | − { "id": "chaletsarabais", "name": "Chalets à Rabais", "url": "https://www.chaletsarabais.com", "category": "quebecoise" }, | 16 | + "id": "vrbo", | ||
| 17 | − { "id": "chaletsalouer", "name": "ChaletsÀLouer.com", "url": "https://www.chaletsalouer.com", "category": "quebecoise" }, | 17 | + "name": "Vrbo", | ||
| 18 | − { "id": "mcal", "name": "Maisons et chalets à louer", "url": "https://maisonsetchaletsalouer.com", "category": "quebecoise" }, | 18 | + "url": "https://www.vrbo.com", | ||
| 19 | − { "id": "qldc", "name": "Québec Location de Chalets", "url": "https://www.quebeclocationdechalets.com", "category": "quebecoise" }, | 19 | + "category": "internationale" | ||
| 20 | − { "id": "rsvpchalets", "name": "RSVP Chalets", "url": "https://rsvpchalets.com", "category": "quebecoise" }, | 20 | + }, | ||
| 21 | − { "id": "chaletsauquebec", "name": "Chalets au Québec", "url": "https://www.chaletsauquebec.com", "category": "quebecoise" }, | 21 | + { | ||
| 22 | − { "id": "tremblant_living", "name": "Tremblant Living", "url": "https://www.tremblantliving.com", "category": "agence" }, | 22 | + "id": "expedia", | ||
| 23 | − { "id": "gites_passant", "name": "Gîtes et Auberges du Passant", "url": "https://www.terroiretsaveurs.com", "category": "gites" }, | 23 | + "name": "Expedia (locations de vacances)", | ||
| 24 | − { "id": "bonjourquebec", "name": "Bonjour Québec (CITQ)", "url": "https://www.bonjourquebec.com", "category": "officielle" }, | 24 | + "url": "https://www.expedia.ca", | ||
| 25 | − { "id": "sepaq", "name": "Sépaq", "url": "https://www.sepaq.com", "category": "officielle" }, | 25 | + "category": "internationale" | ||
| 26 | − { "id": "parcscanada", "name": "Parcs Canada", "url": "https://reservation.pc.gc.ca", "category": "officielle" }, | 26 | + }, | ||
| 27 | − { "id": "sinistar", "name": "Sinistar", "url": "https://www.sinistar.ca", "category": "relogement" }, | 27 | + { | ||
| 28 | − { "id": "louer_ca_ct", "name": "Louer.ca (court terme)", "url": "https://www.louer.ca", "category": "relogement", "status": "en attente de connecteur" }, | 28 | + "id": "agoda", | ||
| 29 | − { "id": "kijiji_ct", "name": "Kijiji (court terme)", "url": "https://www.kijiji.ca", "category": "petites-annonces" }, | 29 | + "name": "Agoda Homes", | ||
| 30 | − { "id": "lespac_ct", "name": "LesPAC (court terme)", "url": "https://www.lespac.com", "category": "petites-annonces", "status": "en attente de connecteur" }, | 30 | + "url": "https://www.agoda.com", | ||
| 31 | − { "id": "fb_marketplace_ct", "name": "Facebook Marketplace (court terme)", "url": "https://www.facebook.com/marketplace", "category": "petites-annonces", "status": "en attente de connecteur" }, | 31 | + "category": "internationale", | ||
| 32 | − { "id": "craigslist_ct", "name": "Craigslist (court terme)", "url": "https://montreal.craigslist.org", "category": "petites-annonces", "status": "en attente de connecteur" }, | 32 | + "status": "en attente de connecteur" | ||
| 33 | − { "id": "bonjourresidences", "name": "Bonjour Résidences", "url": "https://www.bonjourresidences.com", "category": "cas-particuliers", "status": "en attente de connecteur" } | 33 | + }, | ||
| 34 | − ] | 34 | + { | ||
| 35 | −} | 35 | + "id": "plumguide", | ||
| 36 | + "name": "Plum Guide", | ||||
| 37 | + "url": "https://www.plumguide.com", | ||||
| 38 | + "category": "internationale", | ||||
| 39 | + "status": "en attente de connecteur" | ||||
| 40 | + }, | ||||
| 41 | + { | ||||
| 42 | + "id": "glampinghub", | ||||
| 43 | + "name": "Glamping Hub", | ||||
| 44 | + "url": "https://glampinghub.com", | ||||
| 45 | + "category": "internationale" | ||||
| 46 | + }, | ||||
| 47 | + { | ||||
| 48 | + "id": "hipcamp", | ||||
| 49 | + "name": "Hipcamp", | ||||
| 50 | + "url": "https://www.hipcamp.com", | ||||
| 51 | + "category": "internationale" | ||||
| 52 | + }, | ||||
| 53 | + { | ||||
| 54 | + "id": "marriott_hvmi", | ||||
| 55 | + "name": "Marriott Homes & Villas", | ||||
| 56 | + "url": "https://homes-and-villas.marriott.com", | ||||
| 57 | + "category": "internationale", | ||||
| 58 | + "status": "en attente de connecteur" | ||||
| 59 | + }, | ||||
| 60 | + { | ||||
| 61 | + "id": "furnishedfinder", | ||||
| 62 | + "name": "Furnished Finder", | ||||
| 63 | + "url": "https://www.furnishedfinder.com", | ||||
| 64 | + "category": "internationale", | ||||
| 65 | + "status": "en attente de connecteur" | ||||
| 66 | + }, | ||||
| 67 | + { | ||||
| 68 | + "id": "corporatestays", | ||||
| 69 | + "name": "Corporate Stays", | ||||
| 70 | + "url": "https://www.corporatestays.com", | ||||
| 71 | + "category": "internationale" | ||||
| 72 | + }, | ||||
| 73 | + { | ||||
| 74 | + "id": "monsieurchalets", | ||||
| 75 | + "name": "MonsieurChalets", | ||||
| 76 | + "url": "https://www.monsieurchalets.com", | ||||
| 77 | + "category": "quebecoise" | ||||
| 78 | + }, | ||||
| 79 | + { | ||||
| 80 | + "id": "wechalet", | ||||
| 81 | + "name": "WeChalet", | ||||
| 82 | + "url": "https://wechalet.com", | ||||
| 83 | + "category": "quebecoise" | ||||
| 84 | + }, | ||||
| 85 | + { | ||||
| 86 | + "id": "chaletsarabais", | ||||
| 87 | + "name": "Chalets à Rabais", | ||||
| 88 | + "url": "https://www.chaletsarabais.com", | ||||
| 89 | + "category": "quebecoise" | ||||
| 90 | + }, | ||||
| 91 | + { | ||||
| 92 | + "id": "chaletsalouer", | ||||
| 93 | + "name": "ChaletsÀLouer.com", | ||||
| 94 | + "url": "https://www.chaletsalouer.com", | ||||
| 95 | + "category": "quebecoise" | ||||
| 96 | + }, | ||||
| 97 | + { | ||||
| 98 | + "id": "mcal", | ||||
| 99 | + "name": "Maisons et chalets à louer", | ||||
| 100 | + "url": "https://maisonsetchaletsalouer.com", | ||||
| 101 | + "category": "quebecoise" | ||||
| 102 | + }, | ||||
| 103 | + { | ||||
| 104 | + "id": "qldc", | ||||
| 105 | + "name": "Québec Location de Chalets", | ||||
| 106 | + "url": "https://www.quebeclocationdechalets.com", | ||||
| 107 | + "category": "quebecoise" | ||||
| 108 | + }, | ||||
| 109 | + { | ||||
| 110 | + "id": "rsvpchalets", | ||||
| 111 | + "name": "RSVP Chalets", | ||||
| 112 | + "url": "https://rsvpchalets.com", | ||||
| 113 | + "category": "quebecoise" | ||||
| 114 | + }, | ||||
| 115 | + { | ||||
| 116 | + "id": "chaletsauquebec", | ||||
| 117 | + "name": "Chalets au Québec", | ||||
| 118 | + "url": "https://www.chaletsauquebec.com", | ||||
| 119 | + "category": "quebecoise" | ||||
| 120 | + }, | ||||
| 121 | + { | ||||
| 122 | + "id": "tremblant_living", | ||||
| 123 | + "name": "Tremblant Living", | ||||
| 124 | + "url": "https://www.tremblantliving.com", | ||||
| 125 | + "category": "agence" | ||||
| 126 | + }, | ||||
| 127 | + { | ||||
| 128 | + "id": "gites_passant", | ||||
| 129 | + "name": "Gîtes et Auberges du Passant", | ||||
| 130 | + "url": "https://www.terroiretsaveurs.com", | ||||
| 131 | + "category": "gites" | ||||
| 132 | + }, | ||||
| 133 | + { | ||||
| 134 | + "id": "bonjourquebec", | ||||
| 135 | + "name": "Bonjour Québec (CITQ)", | ||||
| 136 | + "url": "https://www.bonjourquebec.com", | ||||
| 137 | + "category": "officielle" | ||||
| 138 | + }, | ||||
| 139 | + { | ||||
| 140 | + "id": "sepaq", | ||||
| 141 | + "name": "Sépaq", | ||||
| 142 | + "url": "https://www.sepaq.com", | ||||
| 143 | + "category": "officielle" | ||||
| 144 | + }, | ||||
| 145 | + { | ||||
| 146 | + "id": "parcscanada", | ||||
| 147 | + "name": "Parcs Canada", | ||||
| 148 | + "url": "https://reservation.pc.gc.ca", | ||||
| 149 | + "category": "officielle" | ||||
| 150 | + }, | ||||
| 151 | + { | ||||
| 152 | + "id": "sinistar", | ||||
| 153 | + "name": "Sinistar", | ||||
| 154 | + "url": "https://www.sinistar.ca", | ||||
| 155 | + "category": "relogement" | ||||
| 156 | + }, | ||||
| 157 | + { | ||||
| 158 | + "id": "louer_ca_ct", | ||||
| 159 | + "name": "Louer.ca (court terme)", | ||||
| 160 | + "url": "https://www.louer.ca", | ||||
| 161 | + "category": "relogement", | ||||
| 162 | + "status": "en attente de connecteur" | ||||
| 163 | + }, | ||||
| 164 | + { | ||||
| 165 | + "id": "kijiji_ct", | ||||
| 166 | + "name": "Kijiji (court terme)", | ||||
| 167 | + "url": "https://www.kijiji.ca", | ||||
| 168 | + "category": "petites-annonces" | ||||
| 169 | + }, | ||||
| 170 | + { | ||||
| 171 | + "id": "lespac_ct", | ||||
| 172 | + "name": "LesPAC (court terme)", | ||||
| 173 | + "url": "https://www.lespac.com", | ||||
| 174 | + "category": "petites-annonces", | ||||
| 175 | + "status": "en attente de connecteur" | ||||
| 176 | + }, | ||||
| 177 | + { | ||||
| 178 | + "id": "fb_marketplace_ct", | ||||
| 179 | + "name": "Facebook Marketplace (court terme)", | ||||
| 180 | + "url": "https://www.facebook.com/marketplace", | ||||
| 181 | + "category": "petites-annonces", | ||||
| 182 | + "status": "en attente de connecteur" | ||||
| 183 | + }, | ||||
| 184 | + { | ||||
| 185 | + "id": "craigslist_ct", | ||||
| 186 | + "name": "Craigslist (court terme)", | ||||
| 187 | + "url": "https://montreal.craigslist.org", | ||||
| 188 | + "category": "petites-annonces", | ||||
| 189 | + "status": "en attente de connecteur" | ||||
| 190 | + }, | ||||
| 191 | + { | ||||
| 192 | + "id": "bonjourresidences", | ||||
| 193 | + "name": "Bonjour Résidences", | ||||
| 194 | + "url": "https://www.bonjourresidences.com", | ||||
| 195 | + "category": "cas-particuliers", | ||||
| 196 | + "status": "en attente de connecteur" | ||||
| 197 | + }, | ||||
| 198 | + { | ||||
| 199 | + "id": "chaleto", | ||||
| 200 | + "name": "Chaletô", | ||||
| 201 | + "url": "https://chaleto.ca", | ||||
| 202 | + "category": "quebecoise" | ||||
| 203 | + }, | ||||
| 204 | + { | ||||
| 205 | + "id": "hebergia", | ||||
| 206 | + "name": "Hebergia", | ||||
| 207 | + "url": "https://hebergia.ca", | ||||
| 208 | + "category": "quebecoise" | ||||
| 209 | + }, | ||||
| 210 | + { | ||||
| 211 | + "id": "rezerve", | ||||
| 212 | + "name": "Rëzerve", | ||||
| 213 | + "url": "https://reserver.ca", | ||||
| 214 | + "category": "quebecoise" | ||||
| 215 | + }, | ||||
| 216 | + { | ||||
| 217 | + "id": "campingquebec", | ||||
| 218 | + "name": "Camping Québec", | ||||
| 219 | + "url": "https://www.campingquebec.com", | ||||
| 220 | + "category": "officielle" | ||||
| 221 | + }, | ||||
| 222 | + { | ||||
| 223 | + "id": "pourvoiries", | ||||
| 224 | + "name": "Pourvoiries du Québec (FPQ)", | ||||
| 225 | + "url": "https://www.pourvoiries.com", | ||||
| 226 | + "category": "officielle" | ||||
| 227 | + }, | ||||
| 228 | + { | ||||
| 229 | + "id": "gitesauquebec", | ||||
| 230 | + "name": "Gîtes au Québec", | ||||
| 231 | + "url": "https://www.gitesauquebec.com", | ||||
| 232 | + "category": "gites" | ||||
| 233 | + }, | ||||
| 234 | + { | ||||
| 235 | + "id": "hebergementcharlevoix", | ||||
| 236 | + "name": "Hébergement Charlevoix", | ||||
| 237 | + "url": "https://www.hebergement-charlevoix.com", | ||||
| 238 | + "category": "agence" | ||||
| 239 | + }, | ||||
| 240 | + { | ||||
| 241 | + "id": "chaletsalpins", | ||||
| 242 | + "name": "Les Chalets Alpins", | ||||
| 243 | + "url": "https://chaletsalpins.ca", | ||||
| 244 | + "category": "agence" | ||||
| 245 | + }, | ||||
| 246 | + { | ||||
| 247 | + "id": "memoriachalets", | ||||
| 248 | + "name": "Memoria Chalets", | ||||
| 249 | + "url": "https://memoriachalets.com", | ||||
| 250 | + "category": "agence" | ||||
| 251 | + }, | ||||
| 252 | + { | ||||
| 253 | + "id": "accestremblant", | ||||
| 254 | + "name": "Accès Tremblant", | ||||
| 255 | + "url": "https://accestremblant.ca", | ||||
| 256 | + "category": "agence" | ||||
| 257 | + }, | ||||
| 258 | + { | ||||
| 259 | + "id": "chaletsdanslenord", | ||||
| 260 | + "name": "Les Chalets dans le Nord", | ||||
| 261 | + "url": "https://leschaletsdanslenord.com", | ||||
| 262 | + "category": "agence" | ||||
| 263 | + }, | ||||
| 264 | + { | ||||
| 265 | + "id": "alouerauxiles", | ||||
| 266 | + "name": "À louer aux Îles", | ||||
| 267 | + "url": "https://www.alouerauxiles.com", | ||||
| 268 | + "category": "agence" | ||||
| 269 | + }, | ||||
| 270 | + { | ||||
| 271 | + "id": "lesversants", | ||||
| 272 | + "name": "Les Versants Mont-Tremblant", | ||||
| 273 | + "url": "https://www.lesversants.com", | ||||
| 274 | + "category": "agence" | ||||
| 275 | + }, | ||||
| 276 | + { | ||||
| 277 | + "id": "captremblant", | ||||
| 278 | + "name": "Cap Tremblant", | ||||
| 279 | + "url": "https://captremblant.com", | ||||
| 280 | + "category": "agence" | ||||
| 281 | + }, | ||||
| 282 | + { | ||||
| 283 | + "id": "rvmt", | ||||
| 284 | + "name": "Rendez-vous Mont-Tremblant", | ||||
| 285 | + "url": "https://www.rvmt.com", | ||||
| 286 | + "category": "agence" | ||||
| 287 | + }, | ||||
| 288 | + { | ||||
| 289 | + "id": "locationdechalets", | ||||
| 290 | + "name": "Location de Chalets 4 Saisons", | ||||
| 291 | + "url": "https://www.locationdechalets.com", | ||||
| 292 | + "category": "agence" | ||||
| 293 | + }, | ||||
| 294 | + { | ||||
| 295 | + "id": "chaletsbsl", | ||||
| 296 | + "name": "Chalets BSL", | ||||
| 297 | + "url": "https://www.chaletsbsl.com", | ||||
| 298 | + "category": "agence" | ||||
| 299 | + }, | ||||
| 300 | + { | ||||
| 301 | + "id": "panoraloges", | ||||
| 302 | + "name": "Panora Loges", | ||||
| 303 | + "url": "https://www.panoraloges.ca", | ||||
| 304 | + "category": "agence" | ||||
| 305 | + }, | ||||
| 306 | + { | ||||
| 307 | + "id": "domesstcome", | ||||
| 308 | + "name": "Dômes St-Côme", | ||||
| 309 | + "url": "https://www.domesstcome.com", | ||||
| 310 | + "category": "agence" | ||||
| 311 | + }, | ||||
| 312 | + { | ||||
| 313 | + "id": "boraboreal", | ||||
| 314 | + "name": "Bora Boréal", | ||||
| 315 | + "url": "https://www.boraboreal.com", | ||||
| 316 | + "category": "agence" | ||||
| 317 | + }, | ||||
| 318 | + { | ||||
| 319 | + "id": "homminichalets", | ||||
| 320 | + "name": "HOM Mini-Chalets", | ||||
| 321 | + "url": "https://homminichalets.com", | ||||
| 322 | + "category": "agence" | ||||
| 323 | + } | ||||
| 324 | + ] | ||||
| 325 | +} | ||||
| \ No newline at end of file | |||||
added
frontend/src/components/CtPriceAnalysis.tsx
+96 −0
@@ -0,0 +1,96 @@ | |||||
| 1 | +// ----------------------------------------------------------------------------- | ||||
| 2 | +// Lou-Ka — Location court terme | ||||
| 3 | +// components/CtPriceAnalysis.tsx : bloc « Analyse du prix / nuit » (fiche CT) | ||||
| 4 | +// Position du tarif dans la distribution des hébergements comparables | ||||
| 5 | +// (segment : type + capacité + région touristique, calculé par l'API). | ||||
| 6 | +// ----------------------------------------------------------------------------- | ||||
| 7 | +import { CtPriceContext, fmtNight } from "../ctapi"; | ||||
| 8 | + | ||||
| 9 | +export function ctDealBadge(p: CtPriceContext | null) { | ||||
| 10 | + if (!p || p.deviation == null || p.verdict == null) return null; | ||||
| 11 | + const pct = Math.round(Math.abs(p.deviation) * 100); | ||||
| 12 | + if (p.verdict === "sous") | ||||
| 13 | + return { cls: "deal-good", txt: `≈ ${pct} % sous le prix médian du segment` }; | ||||
| 14 | + if (p.verdict === "dessus") | ||||
| 15 | + return { cls: "deal-high", txt: `≈ ${pct} % au-dessus du prix médian` }; | ||||
| 16 | + return { cls: "deal-ok", txt: "Dans les prix du segment" }; | ||||
| 17 | +} | ||||
| 18 | + | ||||
| 19 | +function Histo({ p, price }: { p: CtPriceContext; price: number }) { | ||||
| 20 | + const bins = p.histogram; | ||||
| 21 | + if (!bins || bins.length < 4) return null; | ||||
| 22 | + const W = 320, H = 96, top = 18, bottom = 16; | ||||
| 23 | + const lo = bins[0].x0, hi = bins[bins.length - 1].x1; | ||||
| 24 | + if (hi <= lo) return null; | ||||
| 25 | + const max = Math.max(...bins.map((b) => b.n), 1); | ||||
| 26 | + const x = (v: number) => ((Math.min(Math.max(v, lo), hi) - lo) / (hi - lo)) * W; | ||||
| 27 | + const bw = W / bins.length; | ||||
| 28 | + const plotH = H - top - bottom; | ||||
| 29 | + const priceX = x(price), medX = x(p.median); | ||||
| 30 | + const close = Math.abs(priceX - medX) < 64; | ||||
| 31 | + const lblAnchor = (px: number) => (px < 56 ? "start" : px > W - 56 ? "end" : "middle"); | ||||
| 32 | + return ( | ||||
| 33 | + <svg className="fv-histo" viewBox={`0 0 ${W} ${H}`} role="img" | ||||
| 34 | + aria-label={`Position du prix (${price} $/nuit) parmi ${p.n} hébergements comparables`}> | ||||
| 35 | + {bins.map((b, i) => { | ||||
| 36 | + const h = Math.max(1.5, (b.n / max) * plotH); | ||||
| 37 | + const inBin = price >= b.x0 && price < b.x1; | ||||
| 38 | + return ( | ||||
| 39 | + <rect key={i} x={i * bw + 1} y={H - bottom - h} rx="2" | ||||
| 40 | + width={Math.max(1, bw - 2)} height={h} | ||||
| 41 | + fill={inBin ? "var(--accent, #ff6a00)" : "rgba(204, 85, 0, 0.28)"}> | ||||
| 42 | + <title>{`${b.x0} $ – ${b.x1} $ : ${b.n} hébergement${b.n > 1 ? "s" : ""}`}</title> | ||||
| 43 | + </rect> | ||||
| 44 | + ); | ||||
| 45 | + })} | ||||
| 46 | + {/* fourchette interquartile (bande) */} | ||||
| 47 | + <rect x={x(p.p25)} y={H - bottom} width={Math.max(2, x(p.p75) - x(p.p25))} | ||||
| 48 | + height="3.5" rx="1.5" fill="rgba(204, 85, 0, 0.45)" /> | ||||
| 49 | + {/* marqueur médiane */} | ||||
| 50 | + <line x1={medX} x2={medX} y1={top - 2} y2={H - bottom} | ||||
| 51 | + stroke="var(--accent-deep, #cc5500)" strokeWidth="1.6" strokeDasharray="3 3" /> | ||||
| 52 | + {!close && ( | ||||
| 53 | + <text x={medX} y={top - 7} textAnchor={lblAnchor(medX)} | ||||
| 54 | + className="fv-histo-lbl fv-histo-lbl-fv">Médiane</text> | ||||
| 55 | + )} | ||||
| 56 | + {/* marqueur du prix demandé */} | ||||
| 57 | + <line x1={priceX} x2={priceX} y1={top - 2} y2={H - bottom} | ||||
| 58 | + stroke="var(--ink, #141814)" strokeWidth="2" /> | ||||
| 59 | + <text x={priceX} y={close ? top - 7 : H - 4} textAnchor={lblAnchor(priceX)} | ||||
| 60 | + className="fv-histo-lbl">Ce prix{close ? " / médiane" : ""}</text> | ||||
| 61 | + <text x="1" y={H - 4} className="fv-histo-axis" textAnchor="start">{lo} $</text> | ||||
| 62 | + <text x={W - 1} y={H - 4} className="fv-histo-axis" textAnchor="end">{hi} $</text> | ||||
| 63 | + </svg> | ||||
| 64 | + ); | ||||
| 65 | +} | ||||
| 66 | + | ||||
| 67 | +export default function CtPriceAnalysis({ p, price }: | ||||
| 68 | + { p: CtPriceContext | null; price: number | null }) { | ||||
| 69 | + if (!p || price == null) return null; | ||||
| 70 | + const badge = ctDealBadge(p); | ||||
| 71 | + return ( | ||||
| 72 | + <section className="f-bloc f-fairvalue" id="analyse-prix"> | ||||
| 73 | + <h2>Analyse du prix Lou-Ka</h2> | ||||
| 74 | + <div className="fv-head"> | ||||
| 75 | + <div> | ||||
| 76 | + <div className="fv-value">{fmtNight(p.median)} <small>/ nuit</small></div> | ||||
| 77 | + <div className="fv-range"> | ||||
| 78 | + Prix médian du segment · 50 % des prix entre {fmtNight(p.p25)} et {fmtNight(p.p75)} | ||||
| 79 | + </div> | ||||
| 80 | + </div> | ||||
| 81 | + {badge && <div className={`fv-badge ${badge.cls}`}>{badge.txt}</div>} | ||||
| 82 | + </div> | ||||
| 83 | + <Histo p={p} price={price} /> | ||||
| 84 | + <div className="fv-meta"> | ||||
| 85 | + <span>Segment : <b>{p.segment}</b></span> | ||||
| 86 | + <span>{p.n.toLocaleString("fr-CA")} hébergements comparables</span> | ||||
| 87 | + <span>Moins cher que {100 - p.percentile} % du segment</span> | ||||
| 88 | + </div> | ||||
| 89 | + <p className="fine"> | ||||
| 90 | + Comparaison indicative des tarifs affichés (« à partir de ») par les | ||||
| 91 | + sources pour des hébergements du même type dans la même région — les | ||||
| 92 | + prix réels varient selon les dates et la durée du séjour. | ||||
| 93 | + </p> | ||||
| 94 | + </section> | ||||
| 95 | + ); | ||||
| 96 | +} | ||||
modified
frontend/src/ctapi.ts
+19 −0
@@ -87,6 +87,23 @@ export interface CtFilters { | |||||
| 87 | sort?: string; // recent | prix | prix_desc | note | 87 | sort?: string; // recent | prix | prix_desc | note | ||
| 88 | } | 88 | } | ||
| 89 | 89 | ||||
| 90 | +export interface CtPriceContext { | ||||
| 91 | + segment: string; | ||||
| 92 | + n: number; | ||||
| 93 | + median: number; | ||||
| 94 | + p25: number; | ||||
| 95 | + p75: number; | ||||
| 96 | + deviation: number | null; // (prix − médiane) / médiane | ||||
| 97 | + verdict: "sous" | "dans" | "dessus" | null; | ||||
| 98 | + percentile: number; // position du prix dans le segment (0-100) | ||||
| 99 | + histogram: { x0: number; x1: number; n: number }[]; | ||||
| 100 | +} | ||||
| 101 | + | ||||
| 102 | +export interface CtContext { | ||||
| 103 | + price: CtPriceContext | null; | ||||
| 104 | + similar: (CtListing & { distance_km?: number })[]; | ||||
| 105 | +} | ||||
| 106 | + | ||||
| 90 | const CT_SOURCE_NAMES: Record<string, string> = {}; | 107 | const CT_SOURCE_NAMES: Record<string, string> = {}; | ||
| 91 | export function registerCtSourceNames(sources: CtSource[]) { | 108 | export function registerCtSourceNames(sources: CtSource[]) { | ||
| 92 | for (const s of sources) CT_SOURCE_NAMES[s.id] = s.name; | 109 | for (const s of sources) CT_SOURCE_NAMES[s.id] = s.name; | ||
@@ -118,6 +135,8 @@ export function fetchCtListings(f: CtFilters, limit?: number, offset?: number) { | |||||
| 118 | 135 | ||||
| 119 | export const fetchCtListing = (uid: string) => | 136 | export const fetchCtListing = (uid: string) => | ||
| 120 | get<CtListing>(`/api/ct/listings/${encodeURIComponent(uid)}`); | 137 | get<CtListing>(`/api/ct/listings/${encodeURIComponent(uid)}`); | ||
| 138 | +export const fetchCtContext = (uid: string) => | ||||
| 139 | + get<CtContext>(`/api/ct/listings/${encodeURIComponent(uid)}/context`); | ||||
| 121 | export const fetchCtFacets = () => get<CtFacets>("/api/ct/facets"); | 140 | export const fetchCtFacets = () => get<CtFacets>("/api/ct/facets"); | ||
| 122 | export const fetchCtStats = () => get<CtStats>("/api/ct/stats"); | 141 | export const fetchCtStats = () => get<CtStats>("/api/ct/stats"); | ||
| 123 | export const fetchCtSources = () => | 142 | export const fetchCtSources = () => | ||
modified
frontend/src/pages/CourtTermeFiche.tsx
+27 −2
@@ -8,10 +8,12 @@ | |||||
| 8 | import { lazy, Suspense, useEffect, useRef, useState } from "react"; | 8 | import { lazy, Suspense, useEffect, useRef, useState } from "react"; | ||
| 9 | import { Link, useParams } from "react-router-dom"; | 9 | import { Link, useParams } from "react-router-dom"; | ||
| 10 | import { | 10 | import { | ||
| 11 | − CtListing, ctSourceName, fetchCtListing, fetchCtSources, fmtNight, | 11 | + CtContext, CtListing, ctSourceName, fetchCtContext, fetchCtListing, | ||
| 12 | − registerCtSourceNames, | 12 | + fetchCtSources, fmtNight, registerCtSourceNames, | ||
| 13 | } from "../ctapi"; | 13 | } from "../ctapi"; | ||
| 14 | import SmartImg from "../components/SmartImg"; | 14 | import SmartImg from "../components/SmartImg"; | ||
| 15 | +import CtListingCard from "../components/CtListingCard"; | ||||
| 16 | +import CtPriceAnalysis, { ctDealBadge } from "../components/CtPriceAnalysis"; | ||||
| 15 | import { IcoAlert } from "../components/Icons"; | 17 | import { IcoAlert } from "../components/Icons"; | ||
| 16 | 18 | ||||
| 17 | const CtFicheMap = lazy(() => import("../components/CtFicheMap")); | 19 | const CtFicheMap = lazy(() => import("../components/CtFicheMap")); | ||
@@ -84,12 +86,15 @@ function Galerie({ images, titre, typeLabel }: | |||||
| 84 | export default function CourtTermeFichePage() { | 86 | export default function CourtTermeFichePage() { | ||
| 85 | const { uid } = useParams<{ uid: string }>(); | 87 | const { uid } = useParams<{ uid: string }>(); | ||
| 86 | const [l, setL] = useState<CtListing | null>(null); | 88 | const [l, setL] = useState<CtListing | null>(null); | ||
| 89 | + const [ctx, setCtx] = useState<CtContext | null>(null); | ||||
| 87 | const [error, setError] = useState<string | null>(null); | 90 | const [error, setError] = useState<string | null>(null); | ||
| 88 | 91 | ||||
| 89 | useEffect(() => { | 92 | useEffect(() => { | ||
| 90 | fetchCtSources().then((r) => registerCtSourceNames(r.sources)).catch(() => {}); | 93 | fetchCtSources().then((r) => registerCtSourceNames(r.sources)).catch(() => {}); | ||
| 91 | if (!uid) return; | 94 | if (!uid) return; | ||
| 95 | + setCtx(null); | ||||
| 92 | fetchCtListing(uid).then(setL).catch((e) => setError(String(e))); | 96 | fetchCtListing(uid).then(setL).catch((e) => setError(String(e))); | ||
| 97 | + fetchCtContext(uid).then(setCtx).catch(() => setCtx(null)); | ||||
| 93 | window.scrollTo(0, 0); | 98 | window.scrollTo(0, 0); | ||
| 94 | }, [uid]); | 99 | }, [uid]); | ||
| 95 | 100 | ||||
@@ -152,6 +157,12 @@ export default function CourtTermeFichePage() { | |||||
| 152 | {fmtNight(l.price_night, l.price_label)} | 157 | {fmtNight(l.price_night, l.price_label)} | ||
| 153 | {l.price_night != null && <small> /{NBSP}nuit</small>} | 158 | {l.price_night != null && <small> /{NBSP}nuit</small>} | ||
| 154 | </div> | 159 | </div> | ||
| 160 | + {(() => { | ||||
| 161 | + const badge = ctDealBadge(ctx?.price ?? null); | ||||
| 162 | + return badge && ( | ||||
| 163 | + <div className={`deal-badge ${badge.cls}`}>{badge.txt}</div> | ||||
| 164 | + ); | ||||
| 165 | + })()} | ||||
| 155 | {l.rating != null && ( | 166 | {l.rating != null && ( | ||
| 156 | <div className="deal-badge deal-ok"> | 167 | <div className="deal-badge deal-ok"> | ||
| 157 | ★ {l.rating.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} / 5 | 168 | ★ {l.rating.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} / 5 | ||
@@ -218,6 +229,7 @@ export default function CourtTermeFichePage() { | |||||
| 218 | </div> | 229 | </div> | ||
| 219 | 230 | ||||
| 220 | <div className="f-col"> | 231 | <div className="f-col"> | ||
| 232 | + <CtPriceAnalysis p={ctx?.price ?? null} price={l.price_night} /> | ||||
| 221 | {l.lat != null && l.lng != null && ( | 233 | {l.lat != null && l.lng != null && ( | ||
| 222 | <section className="f-bloc f-carte" id="emplacement"> | 234 | <section className="f-bloc f-carte" id="emplacement"> | ||
| 223 | <h2>Emplacement</h2> | 235 | <h2>Emplacement</h2> | ||
@@ -232,6 +244,19 @@ export default function CourtTermeFichePage() { | |||||
| 232 | </div> | 244 | </div> | ||
| 233 | </div> | 245 | </div> | ||
| 234 | 246 | ||||
| 247 | + {ctx && ctx.similar.length > 0 && ( | ||||
| 248 | + <section className="f-bloc f-similaires" aria-label="Hébergements similaires"> | ||||
| 249 | + <h2> | ||||
| 250 | + {l.lat != null && ctx.similar[0]?.distance_km != null | ||||
| 251 | + ? "Hébergements similaires à proximité" | ||||
| 252 | + : `Hébergements similaires — ${l.region || "même région"}`} | ||||
| 253 | + </h2> | ||||
| 254 | + <div className="grid"> | ||||
| 255 | + {ctx.similar.map((s) => <CtListingCard key={s.uid} l={s} />)} | ||||
| 256 | + </div> | ||||
| 257 | + </section> | ||||
| 258 | + )} | ||||
| 259 | + | ||||
| 235 | <div className="fine f-foot"> | 260 | <div className="fine f-foot"> | ||
| 236 | {synced && <>Dernière synchronisation : {synced}. </>} | 261 | {synced && <>Dernière synchronisation : {synced}. </>} | ||
| 237 | Les prix et disponibilités sont ceux affichés par la source — chaque fiche | 262 | Les prix et disponibilités sont ceux affichés par la source — chaque fiche | ||
modified
frontend/src/styles.css
+4 −0
@@ -1055,6 +1055,10 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */ | |||||
| 1055 | } | 1055 | } | ||
| 1056 | .f-foot { margin-top: 26px; } | 1056 | .f-foot { margin-top: 26px; } | ||
| 1057 | 1057 | ||||
| 1058 | +/* --- Fiche court terme : hébergements similaires (pleine largeur) --- */ | ||||
| 1059 | +.f-similaires { margin-top: 22px; } | ||||
| 1060 | +.f-similaires .grid { margin-top: 4px; } | ||||
| 1061 | + | ||||
| 1058 | /* --- Passerelle vers l'annonce originale ------------------------------------ */ | 1062 | /* --- Passerelle vers l'annonce originale ------------------------------------ */ | ||
| 1059 | .passerelle { | 1063 | .passerelle { | ||
| 1060 | min-height: calc(100dvh - 120px); display: flex; align-items: center; | 1064 | min-height: calc(100dvh - 120px); display: flex; align-items: center; | ||
added
louka/shortterm/connectors/_expediadetail.py
+155 −0
@@ -0,0 +1,155 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Location court terme | ||||
| 3 | +# connectors/_expediadetail.py : parseur partagé des pages détail de la | ||||
| 4 | +# plateforme Expedia (Vrbo + Expedia — même moteur, même SSR). | ||||
| 5 | +# | ||||
| 6 | +# La page détail (…/location/pXXXXXX[vb] ou …hXXXXXX.Hotel-Information), | ||||
| 7 | +# récupérée via Scrapfly ASP SANS rendu JS, embarque : | ||||
| 8 | +# - __APOLLO_STATE__ (JSON.parse("…")) → PropertyInfo : | ||||
| 9 | +# . propertyContentSectionGroups(…).aboutThisProperty → description | ||||
| 10 | +# (blocs PropertyContentItemMarkup, HTML — inclut souvent le n° CITQ) | ||||
| 11 | +# . summary.amenities(…) → commodités localisées (infoItems[].text) | ||||
| 12 | +# - microdonnées schema.org SSR : lat/lng (itemProp latitude/longitude), | ||||
| 13 | +# capacité (occupancy → value), municipalité (addressLocality) | ||||
| 14 | +# - galerie : URLs media.vrbo.com / images.trvl-media.com (~6 photos SSR) | ||||
| 15 | +# Vérifié live 2026-08-25 sur p3363083vb (Vrbo) et h130341845 (Expedia). | ||||
| 16 | +# ----------------------------------------------------------------------------- | ||||
| 17 | +from __future__ import annotations | ||||
| 18 | + | ||||
| 19 | +import html as _html | ||||
| 20 | +import json | ||||
| 21 | +import re | ||||
| 22 | + | ||||
| 23 | +_APOLLO_RE = re.compile( | ||||
| 24 | + r'__APOLLO_STATE__\s*=\s*JSON\.parse\("(.*?)(?<!\\)"\)', re.S) | ||||
| 25 | +_LAT_RE = re.compile(r'itemProp="latitude" content="(-?[\d.]+)"') | ||||
| 26 | +_LNG_RE = re.compile(r'itemProp="longitude" content="(-?[\d.]+)"') | ||||
| 27 | +_OCC_RE = re.compile( | ||||
| 28 | + r'itemProp="occupancy".{0,200}?itemProp="value" content="(\d+)"', re.S) | ||||
| 29 | +_CITY_RE = re.compile(r'itemProp="addressLocality" content="([^"]+)"') | ||||
| 30 | +_IMG_RE = re.compile( | ||||
| 31 | + r'https://(?:media\.vrbo\.com|images\.trvl-media\.com)/lodging/' | ||||
| 32 | + r'[^"\s\\)&?]+') | ||||
| 33 | + | ||||
| 34 | + | ||||
| 35 | +def _apollo(html: str) -> dict: | ||||
| 36 | + """Entités du store Apollo SSR ({} si absent/illisible).""" | ||||
| 37 | + m = _APOLLO_RE.search(html or "") | ||||
| 38 | + if not m: | ||||
| 39 | + return {} | ||||
| 40 | + try: | ||||
| 41 | + # la chaîne est un littéral JSON : la re-quoter puis parser deux fois | ||||
| 42 | + return json.loads(json.loads('"' + m.group(1) + '"')) | ||||
| 43 | + except ValueError: | ||||
| 44 | + return {} | ||||
| 45 | + | ||||
| 46 | + | ||||
| 47 | +def _markup_texts(node, out: list[str]) -> None: | ||||
| 48 | + """Collecte récursive des blocs PropertyContentItemMarkup (description).""" | ||||
| 49 | + if isinstance(node, dict): | ||||
| 50 | + if node.get("__typename") == "PropertyContentItemMarkup": | ||||
| 51 | + txt = ((node.get("content") or {}).get("text") or "").strip() | ||||
| 52 | + if txt: | ||||
| 53 | + out.append(txt) | ||||
| 54 | + return | ||||
| 55 | + for v in node.values(): | ||||
| 56 | + _markup_texts(v, out) | ||||
| 57 | + elif isinstance(node, list): | ||||
| 58 | + for v in node: | ||||
| 59 | + _markup_texts(v, out) | ||||
| 60 | + | ||||
| 61 | + | ||||
| 62 | +def _strip_html(raw: str) -> str: | ||||
| 63 | + t = re.sub(r"<br\s*/?>|</p>", "\n", raw) | ||||
| 64 | + t = re.sub(r"<[^>]+>", " ", t) | ||||
| 65 | + t = _html.unescape(t) | ||||
| 66 | + lines = [re.sub(r"\s+", " ", ln).strip() for ln in t.split("\n")] | ||||
| 67 | + return "\n".join(ln for ln in lines if ln).strip() | ||||
| 68 | + | ||||
| 69 | + | ||||
| 70 | +def parse_detail(html: str) -> dict: | ||||
| 71 | + """Payload détail {description, amenities, capacity, lat, lng, city, | ||||
| 72 | + images} d'une page hébergement Vrbo/Expedia ({} si page invalide).""" | ||||
| 73 | + store = _apollo(html or "") | ||||
| 74 | + pinfo = next((v for k, v in store.items() | ||||
| 75 | + if k.startswith("PropertyInfo") and isinstance(v, dict)), {}) | ||||
| 76 | + if not pinfo and not _LAT_RE.search(html or ""): | ||||
| 77 | + return {} # page vide / redirection hors fiche | ||||
| 78 | + | ||||
| 79 | + out: dict = {} | ||||
| 80 | + | ||||
| 81 | + # description : sections « À propos de cet hébergement » | ||||
| 82 | + paras: list[str] = [] | ||||
| 83 | + for k, v in pinfo.items(): | ||||
| 84 | + if k.startswith("propertyContentSectionGroups") and isinstance(v, dict): | ||||
| 85 | + _markup_texts(v.get("aboutThisProperty"), paras) | ||||
| 86 | + if not paras: # repli : éditorial du quartier | ||||
| 87 | + loc = (pinfo.get("summary") or {}).get("location") or {} | ||||
| 88 | + ed = ((loc.get("whatsAround") or {}).get("editorial") or {}) | ||||
| 89 | + paras = [t for t in (ed.get("content") or []) if isinstance(t, str)] | ||||
| 90 | + desc = "\n\n".join(_strip_html(p) for p in paras).strip() | ||||
| 91 | + if desc: | ||||
| 92 | + out["description"] = desc[:6000] | ||||
| 93 | + | ||||
| 94 | + # commodités localisées (summary.amenities → sections → infoItems) | ||||
| 95 | + amenities: list[str] = [] | ||||
| 96 | + for k, v in (pinfo.get("summary") or {}).items(): | ||||
| 97 | + if not (k.startswith("amenities") and isinstance(v, dict)): | ||||
| 98 | + continue | ||||
| 99 | + for sec in v.get("amenities") or []: | ||||
| 100 | + for cont in (sec or {}).get("contents") or []: | ||||
| 101 | + for it in (cont or {}).get("infoItems") or []: | ||||
| 102 | + txt = ((it or {}).get("text") or "").strip() | ||||
| 103 | + if txt and txt not in amenities: | ||||
| 104 | + amenities.append(txt) | ||||
| 105 | + if amenities: | ||||
| 106 | + out["amenities"] = amenities[:80] | ||||
| 107 | + | ||||
| 108 | + # microdonnées SSR : géo, capacité, municipalité | ||||
| 109 | + m = _LAT_RE.search(html) | ||||
| 110 | + n = _LNG_RE.search(html) | ||||
| 111 | + if m and n: | ||||
| 112 | + try: | ||||
| 113 | + out["lat"], out["lng"] = float(m.group(1)), float(n.group(1)) | ||||
| 114 | + except ValueError: | ||||
| 115 | + pass | ||||
| 116 | + m = _OCC_RE.search(html) | ||||
| 117 | + if m: | ||||
| 118 | + out["capacity"] = float(m.group(1)) | ||||
| 119 | + m = _CITY_RE.search(html) | ||||
| 120 | + if m: | ||||
| 121 | + out["city"] = _html.unescape(m.group(1)).strip() | ||||
| 122 | + | ||||
| 123 | + # galerie SSR : dédupliquée par chemin, servie en 1200 px | ||||
| 124 | + images: list[str] = [] | ||||
| 125 | + for u in _IMG_RE.findall(html): | ||||
| 126 | + big = u + "?impolicy=resizecrop&rw=1200&ra=fit" | ||||
| 127 | + if big not in images: | ||||
| 128 | + images.append(big) | ||||
| 129 | + if len(images) >= 15: | ||||
| 130 | + break | ||||
| 131 | + if images: | ||||
| 132 | + out["images"] = images | ||||
| 133 | + return out | ||||
| 134 | + | ||||
| 135 | + | ||||
| 136 | +def apply_detail(lst, d: dict) -> None: | ||||
| 137 | + """Applique le payload détail sans écraser ce que la carte a fourni | ||||
| 138 | + (sauf galerie : on garde la plus grande).""" | ||||
| 139 | + if not d: | ||||
| 140 | + return | ||||
| 141 | + if d.get("description") and len(d["description"]) > len(lst.description or ""): | ||||
| 142 | + lst.description = d["description"] | ||||
| 143 | + if d.get("amenities"): | ||||
| 144 | + seen = {a.lower() for a in lst.amenities} | ||||
| 145 | + for a in d["amenities"]: | ||||
| 146 | + if a.lower() not in seen: | ||||
| 147 | + lst.amenities.append(a) | ||||
| 148 | + seen.add(a.lower()) | ||||
| 149 | + for f in ("capacity", "lat", "lng"): | ||||
| 150 | + if d.get(f) is not None and getattr(lst, f, None) is None: | ||||
| 151 | + setattr(lst, f, d[f]) | ||||
| 152 | + if d.get("city") and not lst.city: | ||||
| 153 | + lst.city = d["city"] | ||||
| 154 | + if d.get("images") and len(d["images"]) > len(lst.images): | ||||
| 155 | + lst.images = list(d["images"]) | ||||
added
louka/shortterm/connectors/accestremblant.py
+173 −0
@@ -0,0 +1,173 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Location court terme | ||||
| 3 | +# connectors/accestremblant.py : Accès Tremblant (accestremblant.ca) | ||||
| 4 | +# | ||||
| 5 | +# Agence de condos à Mont-Tremblant (~17 fiches) — WordPress Avada | ||||
| 6 | +# (portfolio) + moteur Guesty (guestybookings.com). | ||||
| 7 | +# | ||||
| 8 | +# Méthode : | ||||
| 9 | +# 1. LISTE : sitemap https://accestremblant.ca/avada_portfolio-sitemap.xml | ||||
| 10 | +# → /condos/<slug>/ (lastmod = clé du cache détail). | ||||
| 11 | +# 2. DÉTAIL (cache self.detail) : la fiche WP fournit spécs (spans | ||||
| 12 | +# icon-condos : « 8 Personnes », « 3 chambres », « 3 salles de bain » + | ||||
| 13 | +# extras type « Foyer au gaz »), description (JSON-LD Yoast), photos | ||||
| 14 | +# (wp-content/uploads) et l'id Guesty (lien guestybookings.com). | ||||
| 15 | +# 3. PRIX : chaque fiche contient un carrousel « autres condos » avec | ||||
| 16 | +# « À partir de N $ » pour les AUTRES fiches ; chaque payload détail | ||||
| 17 | +# mémorise cette carte slug→prix et fetch() fusionne le tout (le prix | ||||
| 18 | +# d'une fiche vient donc des autres pages, même à froid depuis le cache). | ||||
| 19 | +# External_id = slug WP (stable) ; l'id Guesty est gardé dans details. | ||||
| 20 | +# ----------------------------------------------------------------------------- | ||||
| 21 | +from __future__ import annotations | ||||
| 22 | + | ||||
| 23 | +import html as _html | ||||
| 24 | +import json | ||||
| 25 | +import re | ||||
| 26 | + | ||||
| 27 | +from ..schema import StListing | ||||
| 28 | +from .base import StConnector | ||||
| 29 | + | ||||
| 30 | +SITE = "https://accestremblant.ca" | ||||
| 31 | +SITEMAP = f"{SITE}/avada_portfolio-sitemap.xml" | ||||
| 32 | + | ||||
| 33 | +_TAG_RE = re.compile(r"<[^>]+>") | ||||
| 34 | + | ||||
| 35 | + | ||||
| 36 | +def _text(fragment: str) -> str: | ||||
| 37 | + return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment))).strip() | ||||
| 38 | + | ||||
| 39 | + | ||||
| 40 | +class AccesTremblant(StConnector): | ||||
| 41 | + source_id = "accestremblant" | ||||
| 42 | + | ||||
| 43 | + # -- liste (sitemap) ---------------------------------------------------- | ||||
| 44 | + def _sitemap(self) -> list[tuple[str, str]]: | ||||
| 45 | + """[(url fiche, lastmod)] — dédupliqué, sans la page index.""" | ||||
| 46 | + xml = self.get(SITEMAP).text | ||||
| 47 | + seen: dict[str, str] = {} | ||||
| 48 | + for m in re.finditer(r"(?s)<url>\s*<loc>([^<]+)</loc>" | ||||
| 49 | + r"(?:\s*<lastmod>([^<]+)</lastmod>)?", xml): | ||||
| 50 | + url, lastmod = m.group(1).strip(), (m.group(2) or "").strip() | ||||
| 51 | + if re.fullmatch(rf"{re.escape(SITE)}/condos/[^/]+/", url): | ||||
| 52 | + seen.setdefault(url, lastmod) | ||||
| 53 | + return sorted(seen.items()) | ||||
| 54 | + | ||||
| 55 | + # -- page détail ------------------------------------------------------ | ||||
| 56 | + def _detail(self, url: str) -> dict: | ||||
| 57 | + h = self.get(url).text | ||||
| 58 | + d: dict = {} | ||||
| 59 | + | ||||
| 60 | + # spécs : <span class="icon-condos">… 8 Personnes / 3 chambres / … | ||||
| 61 | + extras: list[str] = [] | ||||
| 62 | + for raw in re.findall(r'(?s)<span class="icon-condos"[^>]*>(.*?)</span>', | ||||
| 63 | + h): | ||||
| 64 | + t = _text(raw) | ||||
| 65 | + if not t: | ||||
| 66 | + continue | ||||
| 67 | + m = re.match(r"(\d+)\s*[Pp]ersonnes?", t) | ||||
| 68 | + if m: | ||||
| 69 | + d["capacity"] = float(m.group(1)) | ||||
| 70 | + continue | ||||
| 71 | + m = re.match(r"(\d+)\s*[Cc]hambres?", t) | ||||
| 72 | + if m: | ||||
| 73 | + d["bedrooms"] = float(m.group(1)) | ||||
| 74 | + continue | ||||
| 75 | + m = re.match(r"(\d+)\s*[Ss]alles?\s*de\s*bain", t) | ||||
| 76 | + if m: | ||||
| 77 | + d["bathrooms"] = float(m.group(1)) | ||||
| 78 | + continue | ||||
| 79 | + if len(t) <= 60: | ||||
| 80 | + extras.append(t) | ||||
| 81 | + if extras: | ||||
| 82 | + d["amenities"] = extras | ||||
| 83 | + | ||||
| 84 | + # description : JSON-LD Yoast (WebPage.description) | ||||
| 85 | + m = re.search(r'(?s)<script type="application/ld\+json"[^>]*>' | ||||
| 86 | + r"(.*?)</script>", h) | ||||
| 87 | + if m: | ||||
| 88 | + try: | ||||
| 89 | + graph = json.loads(m.group(1)).get("@graph") or [] | ||||
| 90 | + for node in graph: | ||||
| 91 | + if node.get("@type") == "WebPage" and node.get("description"): | ||||
| 92 | + d["description"] = _text(node["description"])[:4000] | ||||
| 93 | + break | ||||
| 94 | + except ValueError: | ||||
| 95 | + pass | ||||
| 96 | + | ||||
| 97 | + # id Guesty (lien « réserver » guestybookings.com) | ||||
| 98 | + m = re.search(r"guestybookings\.com/(?:fr/)?properties/([a-f0-9]{24})", | ||||
| 99 | + h) | ||||
| 100 | + if m: | ||||
| 101 | + d["guesty_id"] = m.group(1) | ||||
| 102 | + | ||||
| 103 | + # photos : uploads WP (originaux, sans logos ni vignettes -NxN) | ||||
| 104 | + imgs: list[str] = [] | ||||
| 105 | + for u in re.findall(r'(https://accestremblant\.ca/wp-content/uploads/' | ||||
| 106 | + r'20\d\d/\d\d/[^" ]+\.(?:jpe?g|png|webp))', h): | ||||
| 107 | + if re.search(r"-\d{2,4}x\d{2,4}\.", u): | ||||
| 108 | + continue | ||||
| 109 | + if re.search(r"logo|favicon|icon", u, re.I): | ||||
| 110 | + continue | ||||
| 111 | + if u not in imgs: | ||||
| 112 | + imgs.append(u) | ||||
| 113 | + d["images"] = imgs[:20] | ||||
| 114 | + | ||||
| 115 | + # carte des prix « autres condos » : slug → à partir de N $ | ||||
| 116 | + prices: dict[str, float] = {} | ||||
| 117 | + for slug, val in re.findall( | ||||
| 118 | + r'href="https://accestremblant\.ca/condos/([^"/]+)/"[^>]*>' | ||||
| 119 | + r"\s*À partir de\s*([\d ,]+)\s*\$", h): | ||||
| 120 | + try: | ||||
| 121 | + prices[slug] = float(val.replace(" ", "").replace(",", ".")) | ||||
| 122 | + except ValueError: | ||||
| 123 | + continue | ||||
| 124 | + d["prices_seen"] = prices | ||||
| 125 | + return d | ||||
| 126 | + | ||||
| 127 | + # -- contrat ---------------------------------------------------------- | ||||
| 128 | + def fetch(self) -> list[StListing]: | ||||
| 129 | + rows = [] | ||||
| 130 | + price_map: dict[str, float] = {} | ||||
| 131 | + for url, lastmod in self._sitemap(): | ||||
| 132 | + slug = url.rstrip("/").rsplit("/", 1)[-1] | ||||
| 133 | + try: | ||||
| 134 | + det = self.detail(slug, lastmod, lambda u=url: self._detail(u)) | ||||
| 135 | + except Exception: | ||||
| 136 | + continue | ||||
| 137 | + price_map.update(det.get("prices_seen") or {}) | ||||
| 138 | + rows.append((slug, url, det)) | ||||
| 139 | + | ||||
| 140 | + listings: list[StListing] = [] | ||||
| 141 | + for slug, url, det in rows: | ||||
| 142 | + title = slug.replace("-", " ").title() | ||||
| 143 | + desc = det.get("description") or "" | ||||
| 144 | + m = re.match(r"([^|]{2,60})\|", desc) | ||||
| 145 | + if m: # « Verbier C | Découvrez… » | ||||
| 146 | + title = m.group(1).strip() | ||||
| 147 | + desc = desc.split("|", 1)[1].strip() | ||||
| 148 | + price = price_map.get(slug) | ||||
| 149 | + | ||||
| 150 | + details = {k: v for k, v in { | ||||
| 151 | + "guesty_id": det.get("guesty_id"), | ||||
| 152 | + }.items() if v} | ||||
| 153 | + | ||||
| 154 | + listings.append(StListing( | ||||
| 155 | + source=self.source_id, | ||||
| 156 | + external_id=slug, | ||||
| 157 | + url=url, | ||||
| 158 | + title=title, | ||||
| 159 | + property_type="Condo", | ||||
| 160 | + city="Mont-Tremblant", | ||||
| 161 | + region="Laurentides", | ||||
| 162 | + price_night=price, | ||||
| 163 | + price_label=(f"à partir de {price:.0f} $ / nuit" | ||||
| 164 | + if price else ""), | ||||
| 165 | + capacity=det.get("capacity"), | ||||
| 166 | + bedrooms=det.get("bedrooms"), | ||||
| 167 | + bathrooms=det.get("bathrooms"), | ||||
| 168 | + description=desc[:4000], | ||||
| 169 | + amenities=det.get("amenities") or [], | ||||
| 170 | + details=details, | ||||
| 171 | + images=det.get("images") or [], | ||||
| 172 | + )) | ||||
| 173 | + return listings | ||||
modified
louka/shortterm/connectors/airbnb.py
+183 −1
@@ -22,9 +22,21 @@ | |||||
| 22 | # L'ordre des cellules est mélangé à chaque run pour que les cellules restantes | 22 | # L'ordre des cellules est mélangé à chaque run pour que les cellules restantes | ||
| 23 | # après épuisement du budget tournent d'un run à l'autre. | 23 | # après épuisement du budget tournent d'un run à l'autre. | ||
| 24 | # | 24 | # | ||
| 25 | +# Enrichissement détail : la fiche /rooms/<id> embarque le même genre de JSON | ||||
| 26 | +# (<script data-deferred-state-0> → data.node.pdpPresentation) avec description | ||||
| 27 | +# longue (descriptions.longDescriptionHtml), capacité (personCapacity + | ||||
| 28 | +# overview.items "6 guests · 2 bedrooms…"), commodités groupées | ||||
| 29 | +# (amenities.seeAllAmenitiesGroups, drapeau available), règles (rules.groupItems | ||||
| 30 | +# → animaux) et municipalité (localizedLocation). Visite via le cache détail | ||||
| 31 | +# self.detail() (louka_ct.db) avec budget par run : le parc (~15 000) se | ||||
| 32 | +# remplit au fil des syncs. Les échecs réseau/anti-bot ne sont PAS mis en | ||||
| 33 | +# cache (retentés au prochain run) ; 8 échecs consécutifs coupent | ||||
| 34 | +# l'enrichissement du run (tempête anti-bot). | ||||
| 35 | +# | ||||
| 25 | # Réglages env : LOUKA_AIRBNB_PAGES (pages par cellule feuille, défaut 15), | 36 | # Réglages env : LOUKA_AIRBNB_PAGES (pages par cellule feuille, défaut 15), | ||
| 26 | # LOUKA_AIRBNB_BUDGET (budget de requêtes HTML, défaut 1200), | 37 | # LOUKA_AIRBNB_BUDGET (budget de requêtes HTML, défaut 1200), | ||
| 27 | −# LOUKA_AIRBNB_DEPTH (profondeur max du quadtree, défaut 7). | 38 | +# LOUKA_AIRBNB_DEPTH (profondeur max du quadtree, défaut 7), | ||
| 39 | +# LOUKA_AIRBNB_DETAIL_LIMIT (fiches détail par run, défaut 800). | ||||
| 28 | # ----------------------------------------------------------------------------- | 40 | # ----------------------------------------------------------------------------- | ||
| 29 | from __future__ import annotations | 41 | from __future__ import annotations | ||
| 30 | 42 | ||||
@@ -147,6 +159,25 @@ _NIGHTLY_RE = re.compile(r"(\d+)\s*nights?\s*x\s*\$\s*([\d,]+(?:\.\d+)?)", re.I) | |||||
| 147 | _MONEY_RE = re.compile(r"\$\s*([\d,]+(?:\.\d+)?)") | 159 | _MONEY_RE = re.compile(r"\$\s*([\d,]+(?:\.\d+)?)") | ||
| 148 | _NUM_RE = re.compile(r"(\d+(?:\.\d+)?)") | 160 | _NUM_RE = re.compile(r"(\d+(?:\.\d+)?)") | ||
| 149 | 161 | ||||
| 162 | +# Clé de version du parseur de fiche détail (bump → re-visite du parc) | ||||
| 163 | +_PDP_KEY = "pdp-v1" | ||||
| 164 | + | ||||
| 165 | + | ||||
| 166 | +class _DetailSkip(Exception): | ||||
| 167 | + """Fiche détail indisponible ce run (budget épuisé, blocage anti-bot) — | ||||
| 168 | + on ne met RIEN en cache pour retenter au prochain sync.""" | ||||
| 169 | + | ||||
| 170 | + | ||||
| 171 | +def _html_to_text(fragment: str) -> str: | ||||
| 172 | + """HTML de description Airbnb (<br />, <b>…) → texte propre.""" | ||||
| 173 | + import html as _h | ||||
| 174 | + txt = re.sub(r"<br\s*/?>", "\n", fragment) | ||||
| 175 | + txt = re.sub(r"<[^>]+>", " ", txt) | ||||
| 176 | + txt = _h.unescape(txt) | ||||
| 177 | + txt = re.sub(r"[ \t]+", " ", txt) | ||||
| 178 | + txt = re.sub(r" ?\n ?", "\n", txt) | ||||
| 179 | + return re.sub(r"\n{3,}", "\n\n", txt).strip() | ||||
| 180 | + | ||||
| 150 | 181 | ||||
| 151 | def _b64_room_id(demand_id: str) -> str: | 182 | def _b64_room_id(demand_id: str) -> str: | ||
| 152 | """"RGVtYW5kU3RheUxpc3Rpbmc6MTIz" → "123" (DemandStayListing:<id>).""" | 183 | """"RGVtYW5kU3RheUxpc3Rpbmc6MTIz" → "123" (DemandStayListing:<id>).""" | ||
@@ -210,6 +241,156 @@ class Airbnb(StConnector): | |||||
| 210 | return self.get_scrapfly(url, render_js=True, asp=True, | 241 | return self.get_scrapfly(url, render_js=True, asp=True, | ||
| 211 | rendering_wait=3000) | 242 | rendering_wait=3000) | ||
| 212 | 243 | ||||
| 244 | + # -- fiche détail (/rooms/<id>) -------------------------------------------- | ||||
| 245 | + def _pdp_html(self, room_id: str) -> str: | ||||
| 246 | + """HTML d'une fiche : Bright Data d'abord, Scrapfly ASP en secours | ||||
| 247 | + (pas de rendu JS : le JSON est embarqué côté serveur).""" | ||||
| 248 | + url = f"https://www.airbnb.ca/rooms/{room_id}?locale=en¤cy=CAD" | ||||
| 249 | + html = self._brightdata(url) | ||||
| 250 | + if "data-deferred-state" in html: | ||||
| 251 | + return html | ||||
| 252 | + try: | ||||
| 253 | + return self.get_scrapfly(url, render_js=False, asp=True) | ||||
| 254 | + except Exception: # noqa: BLE001 — 429/403/timeout : simple échec | ||||
| 255 | + return "" | ||||
| 256 | + | ||||
| 257 | + @staticmethod | ||||
| 258 | + def _parse_pdp(html: str) -> dict: | ||||
| 259 | + """Champs riches depuis data.node.pdpPresentation du JSON embarqué.""" | ||||
| 260 | + pp = None | ||||
| 261 | + for blob in re.findall( | ||||
| 262 | + r'<script[^>]+id="data-deferred-state[^"]*"[^>]*>(.*?)</script>', | ||||
| 263 | + html, re.S): | ||||
| 264 | + try: | ||||
| 265 | + data = json.loads(blob) | ||||
| 266 | + except ValueError: | ||||
| 267 | + continue | ||||
| 268 | + for entry in data.get("niobeClientData") or []: | ||||
| 269 | + if not (isinstance(entry, list) and len(entry) > 1 | ||||
| 270 | + and isinstance(entry[1], dict)): | ||||
| 271 | + continue | ||||
| 272 | + node = ((entry[1].get("data") or {}).get("node") or {}) | ||||
| 273 | + if isinstance(node.get("pdpPresentation"), dict): | ||||
| 274 | + pp = node["pdpPresentation"] | ||||
| 275 | + break | ||||
| 276 | + if pp: | ||||
| 277 | + break | ||||
| 278 | + if not pp: | ||||
| 279 | + return {} | ||||
| 280 | + | ||||
| 281 | + out: dict = {} | ||||
| 282 | + # description longue : texte ORIGINAL de l'hôte (souvent français au | ||||
| 283 | + # Québec), repli sur la version traduite | ||||
| 284 | + desc = (pp.get("descriptions") or {}).get("longDescriptionHtml") or {} | ||||
| 285 | + txt = (desc.get("localizedString") | ||||
| 286 | + or desc.get("localizedStringWithTranslationPreference") or "") | ||||
| 287 | + if txt: | ||||
| 288 | + out["description"] = _html_to_text(txt)[:6000] | ||||
| 289 | + | ||||
| 290 | + cap = pp.get("personCapacity") | ||||
| 291 | + if isinstance(cap, (int, float)) and 0 < cap <= 200: | ||||
| 292 | + out["capacity"] = float(cap) | ||||
| 293 | + | ||||
| 294 | + # overview.items : "6 guests", "2 bedrooms", "3 beds", "2 baths" | ||||
| 295 | + ov = pp.get("overview") or {} | ||||
| 296 | + for item in ov.get("items") or []: | ||||
| 297 | + low = (item or "").lower() | ||||
| 298 | + m = _NUM_RE.search(low) | ||||
| 299 | + if not m: | ||||
| 300 | + continue | ||||
| 301 | + val = float(m.group(1)) | ||||
| 302 | + if "guest" in low: | ||||
| 303 | + out.setdefault("capacity", val) | ||||
| 304 | + elif "bedroom" in low: | ||||
| 305 | + out["bedrooms"] = val | ||||
| 306 | + elif "bed" in low: | ||||
| 307 | + out["beds"] = val | ||||
| 308 | + elif "bath" in low: | ||||
| 309 | + out["bathrooms"] = val | ||||
| 310 | + if ov.get("title"): | ||||
| 311 | + out["overview_title"] = ov["title"] | ||||
| 312 | + | ||||
| 313 | + # commodités disponibles (les groupes "Not included" ont available=False) | ||||
| 314 | + amen: list[str] = [] | ||||
| 315 | + for grp in (pp.get("amenities") or {}).get("seeAllAmenitiesGroups") or []: | ||||
| 316 | + for a in grp.get("amenities") or []: | ||||
| 317 | + t = (a.get("title") or "").strip() | ||||
| 318 | + if a.get("available") and t and t not in amen: | ||||
| 319 | + amen.append(t) | ||||
| 320 | + if amen: | ||||
| 321 | + out["amenities"] = amen[:120] | ||||
| 322 | + | ||||
| 323 | + # règles de la maison → animaux ("No pets", "Pets allowed", "2 pets…") | ||||
| 324 | + for grp in (pp.get("rules") or {}).get("groupItems") or []: | ||||
| 325 | + for it in grp.get("items") or []: | ||||
| 326 | + if it.get("type") != "HOUSE_RULES_PETS": | ||||
| 327 | + continue | ||||
| 328 | + t = (it.get("title") or "").lower() | ||||
| 329 | + if "no pets" in t or "pas d" in t or "aucun animal" in t: | ||||
| 330 | + out["pets"] = "non" | ||||
| 331 | + elif "pets allowed" in t or "animaux accept" in t: | ||||
| 332 | + out["pets"] = "oui" | ||||
| 333 | + elif t: | ||||
| 334 | + out["pets"] = "conditions" | ||||
| 335 | + | ||||
| 336 | + loc = (pp.get("localizedLocation") or "").split(",")[0].strip() | ||||
| 337 | + if loc: | ||||
| 338 | + out["city"] = loc | ||||
| 339 | + return out | ||||
| 340 | + | ||||
| 341 | + @staticmethod | ||||
| 342 | + def _apply_pdp(lst: StListing, p: dict) -> None: | ||||
| 343 | + """Applique un payload détail sans écraser ce que la carte a fourni.""" | ||||
| 344 | + if p.get("description") and not lst.description: | ||||
| 345 | + lst.description = p["description"] | ||||
| 346 | + if p.get("amenities") and not lst.amenities: | ||||
| 347 | + lst.amenities = list(p["amenities"]) | ||||
| 348 | + for attr in ("capacity", "bedrooms", "beds", "bathrooms"): | ||||
| 349 | + if getattr(lst, attr) is None and p.get(attr) is not None: | ||||
| 350 | + setattr(lst, attr, p[attr]) | ||||
| 351 | + if p.get("pets") and lst.pets is None: | ||||
| 352 | + lst.pets = p["pets"] | ||||
| 353 | + if p.get("city") and not lst.city: | ||||
| 354 | + lst.city = p["city"] | ||||
| 355 | + if not lst.property_type and p.get("overview_title"): | ||||
| 356 | + ptype, _ = _card_type_and_city(p["overview_title"]) | ||||
| 357 | + lst.property_type = ptype | ||||
| 358 | + lst.finalize() # drapeaux/citq/pets dérivés du nouveau texte | ||||
| 359 | + | ||||
| 360 | + def _enrich_details(self, listings: list[StListing]) -> None: | ||||
| 361 | + """Visite les fiches détail via le cache self.detail() sous budget : | ||||
| 362 | + les hits de cache sont gratuits, seuls les fetchs réseau comptent.""" | ||||
| 363 | + limit = max(0, int(os.environ.get("LOUKA_AIRBNB_DETAIL_LIMIT", "800") | ||||
| 364 | + or 800)) | ||||
| 365 | + used = enriched = 0 | ||||
| 366 | + streak = 0 # échecs réseau consécutifs | ||||
| 367 | + | ||||
| 368 | + for lst in listings: | ||||
| 369 | + def fetch_fn(rid=lst.external_id): | ||||
| 370 | + nonlocal used, streak | ||||
| 371 | + if used >= limit or streak >= 8: | ||||
| 372 | + raise _DetailSkip | ||||
| 373 | + used += 1 | ||||
| 374 | + html = self._pdp_html(rid) | ||||
| 375 | + if "data-deferred-state" not in html: | ||||
| 376 | + streak += 1 | ||||
| 377 | + raise _DetailSkip # blocage/vide : pas de mise en cache | ||||
| 378 | + streak = 0 | ||||
| 379 | + return self._parse_pdp(html) | ||||
| 380 | + | ||||
| 381 | + try: | ||||
| 382 | + payload = self.detail(lst.external_id, _PDP_KEY, fetch_fn) | ||||
| 383 | + except _DetailSkip: | ||||
| 384 | + continue | ||||
| 385 | + except Exception: # noqa: BLE001 — une fiche ne bloque pas le run | ||||
| 386 | + continue | ||||
| 387 | + if payload: | ||||
| 388 | + self._apply_pdp(lst, payload) | ||||
| 389 | + enriched += 1 | ||||
| 390 | + print(f"[airbnb] détail : {enriched} annonces enrichies" | ||||
| 391 | + f" ({used}/{limit} fetchs réseau, série d'échecs {streak})", | ||||
| 392 | + file=sys.stderr) | ||||
| 393 | + | ||||
| 213 | # -- parse JSON embarqué ---------------------------------------------------- | 394 | # -- parse JSON embarqué ---------------------------------------------------- | ||
| 214 | @staticmethod | 395 | @staticmethod | ||
| 215 | def _deferred_results(html: str) -> tuple[list[dict], list[str]]: | 396 | def _deferred_results(html: str) -> tuple[list[dict], list[str]]: | ||
@@ -454,4 +635,5 @@ class Airbnb(StConnector): | |||||
| 454 | if stack: | 635 | if stack: | ||
| 455 | print(f"[airbnb] budget épuisé ({budget} req)," | 636 | print(f"[airbnb] budget épuisé ({budget} req)," | ||
| 456 | f" {len(stack)} cellules non visitées", file=sys.stderr) | 637 | f" {len(stack)} cellules non visitées", file=sys.stderr) | ||
| 638 | + self._enrich_details(out) | ||||
| 457 | return out | 639 | return out | ||
added
louka/shortterm/connectors/alouerauxiles.py
+202 −0
@@ -0,0 +1,202 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Location court terme | ||||
| 3 | +# connectors/alouerauxiles.py : À louer aux Îles (alouerauxiles.com) | ||||
| 4 | +# | ||||
| 5 | +# Annuaire local des Îles-de-la-Madeleine (~100 maisons/chalets, région très | ||||
| 6 | +# mal couverte ailleurs). Site statique « Tactical Soft » : cartes par île. | ||||
| 7 | +# | ||||
| 8 | +# Méthode : | ||||
| 9 | +# 1. LISTE : les pages d'île FR (/havre-aubert, /cap-aux-meules, …) sont du | ||||
| 10 | +# HTML serveur contenant un bloc `<div class=item id=<id> tag=rent …>` | ||||
| 11 | +# par annonce : id stable, nom (alt="…"), capacité/chambres | ||||
| 12 | +# (cap="3 chambres (5 pers. max)"), prix (dayrate=186, $/nuit calculé) | ||||
| 13 | +# et lien public (href=https://alouerauxiles.com/<slug>). Dédup par id | ||||
| 14 | +# (une annonce peut apparaître sur plusieurs pages). | ||||
| 15 | +# 2. DÉTAIL (cache self.detail) : /php/page_fr.php?id=<id> → type | ||||
| 16 | +# d'hébergement, personnes/chambres/salles de bain (attributs title=), | ||||
| 17 | +# animaux, description, permis CITQ, adresse + ville (après le code | ||||
| 18 | +# postal), commodités (« Commodités : ») et photos (pages/idlm/rent/…). | ||||
| 19 | +# External_id = id du bloc item (numérique ou slug, stable). | ||||
| 20 | +# ----------------------------------------------------------------------------- | ||||
| 21 | +from __future__ import annotations | ||||
| 22 | + | ||||
| 23 | +import html as _html | ||||
| 24 | +import re | ||||
| 25 | + | ||||
| 26 | +from ..schema import StListing | ||||
| 27 | +from .base import StConnector | ||||
| 28 | + | ||||
| 29 | +SITE = "https://alouerauxiles.com" | ||||
| 30 | +WWW = "https://www.alouerauxiles.com" | ||||
| 31 | + | ||||
| 32 | +# pages d'île francophones (les slugs anglais sont des doublons) | ||||
| 33 | +ISLANDS = ["havre-aubert", "cap-aux-meules", "havre-aux-maisons", | ||||
| 34 | + "pointe-aux-loups", "grosse-ile", "grande-entree", "ile-d-entree"] | ||||
| 35 | + | ||||
| 36 | +_TAG_RE = re.compile(r"<[^>]+>") | ||||
| 37 | + | ||||
| 38 | +# libellé du site → type canonique Lou-Ka | ||||
| 39 | +_TYPES = {"chalet": "Chalet", "maison": "Maison", "résidence": "Maison", | ||||
| 40 | + "studio": "Studio", "appartement": "Appartement", "loft": "Loft", | ||||
| 41 | + "chambre": "Chambre", "gîte": "Gîte", "auberge": "Auberge"} | ||||
| 42 | + | ||||
| 43 | + | ||||
| 44 | +def _text(fragment: str) -> str: | ||||
| 45 | + return _html.unescape(re.sub(r"\s+", " ", _TAG_RE.sub(" ", fragment)) | ||||
| 46 | + ).replace("", "").strip() | ||||
| 47 | + | ||||
| 48 | + | ||||
| 49 | +def _num(raw) -> float | None: | ||||
| 50 | + m = re.search(r"\d+(?:[.,]\d+)?", str(raw or "")) | ||||
| 51 | + return float(m.group(0).replace(",", ".")) if m else None | ||||
| 52 | + | ||||
| 53 | + | ||||
| 54 | +class ALouerAuxIles(StConnector): | ||||
| 55 | + source_id = "alouerauxiles" | ||||
| 56 | + | ||||
| 57 | + # -- liste (pages d'île) -------------------------------------------------- | ||||
| 58 | + def _island_items(self) -> dict[str, dict]: | ||||
| 59 | + items: dict[str, dict] = {} | ||||
| 60 | + for island in ISLANDS: | ||||
| 61 | + try: | ||||
| 62 | + h = self.get(f"{WWW}/{island}").text | ||||
| 63 | + except Exception: | ||||
| 64 | + continue | ||||
| 65 | + for block in re.findall(r"<div class=item ([^>]+)>", h): | ||||
| 66 | + if "tag=rent" not in block: | ||||
| 67 | + continue | ||||
| 68 | + m = re.search(r"\bid=([\w-]+)", block) | ||||
| 69 | + if not m: | ||||
| 70 | + continue | ||||
| 71 | + lid = m.group(1) | ||||
| 72 | + it = items.setdefault(lid, {"island": island}) | ||||
| 73 | + m = re.search(r'alt="([^"]+)"', block) | ||||
| 74 | + if m: | ||||
| 75 | + it["name"] = _text(m.group(1)) | ||||
| 76 | + m = re.search(r'cap="(\d+)\s*chambres?\s*\((\d+)\s*pers', | ||||
| 77 | + block) | ||||
| 78 | + if m: | ||||
| 79 | + it["bedrooms"] = float(m.group(1)) | ||||
| 80 | + it["capacity"] = float(m.group(2)) | ||||
| 81 | + m = re.search(r"\bdayrate=(\d+(?:\.\d+)?)", block) | ||||
| 82 | + if m: | ||||
| 83 | + it["dayrate"] = float(m.group(1)) | ||||
| 84 | + m = re.search(r"href=(https://alouerauxiles\.com/[\w-]+)\b", | ||||
| 85 | + block) | ||||
| 86 | + if m: | ||||
| 87 | + it["url"] = m.group(1) | ||||
| 88 | + return items | ||||
| 89 | + | ||||
| 90 | + # -- page détail ------------------------------------------------------ | ||||
| 91 | + def _detail(self, lid: str) -> dict: | ||||
| 92 | + h = self.get(f"{SITE}/php/page_fr.php", params={"id": lid}).text | ||||
| 93 | + d: dict = {} | ||||
| 94 | + | ||||
| 95 | + m = re.search(r"<b class=t1>([^<]+)</b>", h) | ||||
| 96 | + if m: | ||||
| 97 | + d["type_label"] = _text(m.group(1)) | ||||
| 98 | + | ||||
| 99 | + m = re.search(r'title="(\d+)\s*personnes', h) | ||||
| 100 | + if m: | ||||
| 101 | + d["capacity"] = float(m.group(1)) | ||||
| 102 | + m = re.search(r'title="(\d+)\s*chambres?"', h) | ||||
| 103 | + if m: | ||||
| 104 | + d["bedrooms"] = float(m.group(1)) | ||||
| 105 | + m = re.search(r'title="(\d+)\s*salle\(?s?\)?\s*de\s*bain', h) | ||||
| 106 | + if m: | ||||
| 107 | + d["bathrooms"] = float(m.group(1)) | ||||
| 108 | + m = re.search(r'title="\s*animaux([^"]*)"', h) | ||||
| 109 | + if m: | ||||
| 110 | + d["pets"] = "non" if "non" in m.group(1).lower() else "oui" | ||||
| 111 | + | ||||
| 112 | + # description : bloc txtdiv (nom + texte de présentation) | ||||
| 113 | + m = re.search(r"(?s)<div id=txtdiv>(.*?)</div>", h) | ||||
| 114 | + if m: | ||||
| 115 | + frag = re.sub(r"(?s)<b class=t2>.*?</b>", " ", m.group(1)) | ||||
| 116 | + d["description"] = _text(frag)[:4000] | ||||
| 117 | + m = re.search(r"(?s)<b class=t2>([^<]+)</b>", | ||||
| 118 | + h[h.find("txtdiv"):] if "txtdiv" in h else "") | ||||
| 119 | + if m: | ||||
| 120 | + d["title"] = _text(m.group(1)) | ||||
| 121 | + | ||||
| 122 | + m = re.search(r"CITQ\D{0,12}(\d{6})", h, re.I) | ||||
| 123 | + if m: | ||||
| 124 | + d["citq"] = m.group(1) | ||||
| 125 | + | ||||
| 126 | + # adresse : rue + « G4T 3H6 l'Étang-du-Nord » → ville après le code | ||||
| 127 | + m = re.search(r"(?s)Adresse\s*:\s*</b>(.*?)(?:<b class|Contact)", h) | ||||
| 128 | + if m: | ||||
| 129 | + lines = [_text(x) for x in re.split(r"<br\s*/?>", m.group(1))] | ||||
| 130 | + lines = [x for x in lines if x] | ||||
| 131 | + if lines: | ||||
| 132 | + d["address"] = lines[0] | ||||
| 133 | + for x in lines: | ||||
| 134 | + pm = re.search(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d\s+(.{3,40})$", x) | ||||
| 135 | + if pm: | ||||
| 136 | + d["city"] = pm.group(1).strip() | ||||
| 137 | + break | ||||
| 138 | + | ||||
| 139 | + # commodités : lignes « - … » entre « Commodités : » et « À proximité » | ||||
| 140 | + m = re.search(r"(?s)Commodités\s*:(.*?)(?:À proximité|Contact\s*:|$)", | ||||
| 141 | + h) | ||||
| 142 | + if m: | ||||
| 143 | + amens = [_text(x).lstrip("- ").strip() | ||||
| 144 | + for x in re.split(r"<br\s*/?>", m.group(1))] | ||||
| 145 | + d["amenities"] = [a for a in amens if 2 <= len(a) <= 80][:50] | ||||
| 146 | + | ||||
| 147 | + # photos du dossier de l'annonce (originaux img/, pas les vignettes) | ||||
| 148 | + imgs: list[str] = [] | ||||
| 149 | + for u in re.findall(r"[\"'=](?:\.\./)?(pages/idlm/rent/[\w-]+/img/" | ||||
| 150 | + r"[^\"'\s>]+\.(?:jpe?g|png|webp))", h, re.I): | ||||
| 151 | + full = f"{SITE}/{u}" | ||||
| 152 | + if full not in imgs: | ||||
| 153 | + imgs.append(full) | ||||
| 154 | + d["images"] = imgs[:20] | ||||
| 155 | + return d | ||||
| 156 | + | ||||
| 157 | + # -- contrat ---------------------------------------------------------- | ||||
| 158 | + def fetch(self) -> list[StListing]: | ||||
| 159 | + listings: list[StListing] = [] | ||||
| 160 | + for lid, it in self._island_items().items(): | ||||
| 161 | + key = f"{it.get('name', '')}|{it.get('capacity', '')}|" \ | ||||
| 162 | + f"{it.get('dayrate', '')}|{it.get('bedrooms', '')}" | ||||
| 163 | + try: | ||||
| 164 | + det = self.detail(lid, key, lambda i=lid: self._detail(i)) | ||||
| 165 | + except Exception: | ||||
| 166 | + det = {} | ||||
| 167 | + title = det.get("title") or it.get("name") or "" | ||||
| 168 | + if not title: | ||||
| 169 | + continue | ||||
| 170 | + | ||||
| 171 | + type_label = (det.get("type_label") or "").lower() | ||||
| 172 | + ptype = "" | ||||
| 173 | + for needle, canon in _TYPES.items(): | ||||
| 174 | + if needle in type_label: | ||||
| 175 | + ptype = canon | ||||
| 176 | + break | ||||
| 177 | + | ||||
| 178 | + price = it.get("dayrate") | ||||
| 179 | + city = det.get("city") or it["island"].replace("-", " ").title() | ||||
| 180 | + listings.append(StListing( | ||||
| 181 | + source=self.source_id, | ||||
| 182 | + external_id=lid, | ||||
| 183 | + url=it.get("url") or f"{SITE}/php/page_fr.php?id={lid}", | ||||
| 184 | + title=title, | ||||
| 185 | + property_type=ptype or "Maison", | ||||
| 186 | + address=det.get("address") or "", | ||||
| 187 | + city=city, | ||||
| 188 | + region="Îles-de-la-Madeleine", | ||||
| 189 | + price_night=price, | ||||
| 190 | + price_label=(f"à partir de {price:.0f} $ / nuit" | ||||
| 191 | + if price else ""), | ||||
| 192 | + capacity=det.get("capacity") or it.get("capacity"), | ||||
| 193 | + bedrooms=det.get("bedrooms") or it.get("bedrooms"), | ||||
| 194 | + bathrooms=det.get("bathrooms"), | ||||
| 195 | + pets=det.get("pets"), | ||||
| 196 | + citq=det.get("citq") or "", | ||||
| 197 | + description=det.get("description") or "", | ||||
| 198 | + amenities=det.get("amenities") or [], | ||||
| 199 | + details={"ile": it["island"]}, | ||||
| 200 | + images=det.get("images") or [], | ||||
| 201 | + )) | ||||
| 202 | + return listings | ||||
modified
louka/shortterm/connectors/bonjourquebec.py
+55 −5
@@ -18,10 +18,16 @@ | |||||
| 18 | # quelques milliers max) — gîtes et insolites sont gardés en entier ; | 18 | # quelques milliers max) — gîtes et insolites sont gardés en entier ; | ||
| 19 | # 3. fiche /fiche/<id> (cache self.detail — 1 seule visite par fiche) : | 19 | # 3. fiche /fiche/<id> (cache self.detail — 1 seule visite par fiche) : | ||
| 20 | # région touristique, ville, adresse, no d'enregistrement CITQ, | 20 | # région touristique, ville, adresse, no d'enregistrement CITQ, | ||
| 21 | −# description, services/équipements, animaux, tarifs max (détails), | 21 | +# description, services/équipements, animaux, tarifs, photos. | ||
| 22 | −# photos. Pas de prix « à partir de » sur le site → seuls les maximums | 22 | +# PRIX : le site ne publie QUE des maximums par nuitée (widget Tarifs : | ||
| 23 | −# affichés sont conservés dans details (jamais utilisés comme | 23 | +# « Maximum pour l'unité la plus chère », « Prix maximum par nuitée | ||
| 24 | −# price_night pour ne pas fausser le « à partir de »). | 24 | +# prêt-à-camper »). On les expose honnêtement via price_label | ||
| 25 | +# (« maximum X $ / nuit ») — finalize() en déduit price_night ; le | ||||
| 26 | +# libellé garde la nuance (ce n'est pas un « à partir de »). Les | ||||
| 27 | +# emplacements de camping nu sont ignorés (hors mandat). | ||||
| 28 | +# CAPACITÉ : jamais publiée en « personnes » sur les fiches — on récupère | ||||
| 29 | +# ce qui existe : chambres des gîtes (« Chambre : N unités ») et | ||||
| 30 | +# mentions « N personnes » dans la description (rare). | ||||
| 25 | # ----------------------------------------------------------------------------- | 31 | # ----------------------------------------------------------------------------- | ||
| 26 | from __future__ import annotations | 32 | from __future__ import annotations | ||
| 27 | 33 | ||||
@@ -60,6 +66,38 @@ _TYPE_KEYWORDS = [ | |||||
| 60 | ] | 66 | ] | ||
| 61 | 67 | ||||
| 62 | 68 | ||||
| 69 | +_PRICE_VAL_RE = re.compile(r"\d[\d\s ,.]*\$") | ||||
| 70 | +_CAP_RE = re.compile(r"(\d{1,2})\s*personnes") | ||||
| 71 | +_CHAMBRES_RE = re.compile(r"^Chambre\s*:\s*(\d+)\s*unité", re.I) | ||||
| 72 | + | ||||
| 73 | + | ||||
| 74 | +def _price_label(tarifs: list[str]) -> str: | ||||
| 75 | + """Libellé prix/nuit depuis le widget Tarifs (le site n'affiche que des | ||||
| 76 | + maximums par nuitée). Priorité : unité la plus chère > prêt-à-camper > | ||||
| 77 | + autre « par nuitée » — emplacements de camping nu exclus.""" | ||||
| 78 | + pairs: list[tuple[str, str]] = [] | ||||
| 79 | + label = "" | ||||
| 80 | + for txt in tarifs or []: | ||||
| 81 | + m = _PRICE_VAL_RE.search(txt) | ||||
| 82 | + if m and label: | ||||
| 83 | + pairs.append((label.lower(), re.sub(r"[\s ]+", " ", | ||||
| 84 | + m.group(0)).strip())) | ||||
| 85 | + label = "" | ||||
| 86 | + elif not m and txt: | ||||
| 87 | + label = txt | ||||
| 88 | + | ||||
| 89 | + def pick(needle: str, exclude: str = "") -> str: | ||||
| 90 | + for lab, val in pairs: | ||||
| 91 | + if needle in lab and (not exclude or exclude not in lab): | ||||
| 92 | + return val | ||||
| 93 | + return "" | ||||
| 94 | + | ||||
| 95 | + val = (pick("unité la plus chère") | ||||
| 96 | + or pick("prêt-à-camper") | ||||
| 97 | + or pick("nuit", exclude="camping")) | ||||
| 98 | + return f"maximum {val} / nuit" if val else "" | ||||
| 99 | + | ||||
| 100 | + | ||||
| 63 | def _abs(url: str) -> str: | 101 | def _abs(url: str) -> str: | ||
| 64 | url = _html.unescape(url or "").strip() | 102 | url = _html.unescape(url or "").strip() | ||
| 65 | if not url: | 103 | if not url: | ||
@@ -233,6 +271,16 @@ class BonjourQuebec(StConnector): | |||||
| 233 | "unites": d.get("unites"), | 271 | "unites": d.get("unites"), | ||
| 234 | }.items() if v} | 272 | }.items() if v} | ||
| 235 | 273 | ||||
| 274 | + # capacité : mention « N personnes » dans la description (rare) | ||||
| 275 | + caps = [int(x) for x in _CAP_RE.findall(desc) if 1 <= int(x) <= 40] | ||||
| 276 | + capacity = float(max(caps)) if caps else None | ||||
| 277 | + # chambres : les gîtes déclarent « Chambre : N unités » | ||||
| 278 | + bedrooms = None | ||||
| 279 | + for u in d.get("unites") or []: | ||||
| 280 | + m = _CHAMBRES_RE.match(u) | ||||
| 281 | + if m and 0 < int(m.group(1)) <= 30: | ||||
| 282 | + bedrooms = float(m.group(1)) | ||||
| 283 | + | ||||
| 236 | url = d.get("url_final") or f"{BASE}/fiche/{ext}" | 284 | url = d.get("url_final") or f"{BASE}/fiche/{ext}" | ||
| 237 | lst = StListing( | 285 | lst = StListing( | ||
| 238 | source=self.source_id, | 286 | source=self.source_id, | ||
@@ -243,7 +291,9 @@ class BonjourQuebec(StConnector): | |||||
| 243 | address=d.get("address", ""), | 291 | address=d.get("address", ""), | ||
| 244 | city=d.get("city", ""), | 292 | city=d.get("city", ""), | ||
| 245 | region=d.get("region", ""), | 293 | region=d.get("region", ""), | ||
| 246 | − capacity=None, | 294 | + price_label=_price_label(d.get("tarifs") or []), | ||
| 295 | + capacity=capacity, | ||||
| 296 | + bedrooms=bedrooms, | ||||
| 247 | pets=d.get("pets"), | 297 | pets=d.get("pets"), | ||
| 248 | citq=d.get("citq", ""), | 298 | citq=d.get("citq", ""), | ||
| 249 | description=desc, | 299 | description=desc, | ||
modified
louka/shortterm/connectors/booking.py
+124 −1
@@ -17,12 +17,21 @@ | |||||
| 17 | # Recherche AVEC dates génériques (~30 jours, 2 nuits) : sans dates, Booking | 17 | # Recherche AVEC dates génériques (~30 jours, 2 nuits) : sans dates, Booking | ||
| 18 | # ne renvoie ni prix ni configuration des unités. Le prix est donc indicatif | 18 | # ne renvoie ni prix ni configuration des unités. Le prix est donc indicatif | ||
| 19 | # → price_label « à partir de … » + price_night (le plus bas trouvé). | 19 | # → price_label « à partir de … » + price_night (le plus bas trouvé). | ||
| 20 | +# | ||||
| 21 | +# Enrichissement : la page détail /hotel/ca/<pageName>.fr.html (Scrapfly ASP | ||||
| 22 | +# SANS rendu JS) embarque son propre store Apollo SSR : description complète | ||||
| 23 | +# (data-testid="property-description"), commodités localisées (entités | ||||
| 24 | +# Instance/SimpleFacility) et galerie (AccommodationPhoto). Vérifié live | ||||
| 25 | +# 2026-08-25 sur /hotel/ca/renarde.fr.html. | ||||
| 26 | +# Réglage env : LOUKA_BOOKING_DETAIL_LIMIT (fetchs détail par sync, défaut | ||||
| 27 | +# 150 ; cache permanent dans louka_ct.db, le parc se complète au fil des syncs). | ||||
| 20 | # ----------------------------------------------------------------------------- | 28 | # ----------------------------------------------------------------------------- | ||
| 21 | from __future__ import annotations | 29 | from __future__ import annotations | ||
| 22 | 30 | ||||
| 23 | import datetime | 31 | import datetime | ||
| 24 | import html as _html | 32 | import html as _html | ||
| 25 | import json | 33 | import json | ||
| 34 | +import os | ||||
| 26 | import re | 35 | import re | ||
| 27 | import sys | 36 | import sys | ||
| 28 | from urllib.parse import quote | 37 | from urllib.parse import quote | ||
@@ -30,6 +39,10 @@ from urllib.parse import quote | |||||
| 30 | from ..schema import StListing | 39 | from ..schema import StListing | ||
| 31 | from .base import StConnector | 40 | from .base import StConnector | ||
| 32 | 41 | ||||
| 42 | + | ||||
| 43 | +class _DetailSkip(Exception): | ||||
| 44 | + """Fiche détail sautée (budget épuisé / page bloquée) — pas de cache.""" | ||||
| 45 | + | ||||
| 33 | # (texte de recherche Booking, région touristique QC) | 46 | # (texte de recherche Booking, région touristique QC) | ||
| 34 | DESTINATIONS = [ | 47 | DESTINATIONS = [ | ||
| 35 | ("Mont-Tremblant", "Laurentides"), | 48 | ("Mont-Tremblant", "Laurentides"), | ||
@@ -69,6 +82,8 @@ TYPE_MAP = { | |||||
| 69 | _CAPLA_RE = re.compile( | 82 | _CAPLA_RE = re.compile( | ||
| 70 | r'<script[^>]*data-capla-store-data="apollo"[^>]*>(.*?)</script>', re.S) | 83 | r'<script[^>]*data-capla-store-data="apollo"[^>]*>(.*?)</script>', re.S) | ||
| 71 | _IMG_BASE = "https://cf.bstatic.com" | 84 | _IMG_BASE = "https://cf.bstatic.com" | ||
| 85 | +_DESC_RE = re.compile( | ||||
| 86 | + r'data-testid="property-description"[^>]*>(.*?)</(?:p|div)>', re.S) | ||||
| 72 | 87 | ||||
| 73 | 88 | ||||
| 74 | class Booking(StConnector): | 89 | class Booking(StConnector): | ||
@@ -179,6 +194,112 @@ class Booking(StConnector): | |||||
| 179 | lng=loc.get("longitude"), | 194 | lng=loc.get("longitude"), | ||
| 180 | ) | 195 | ) | ||
| 181 | 196 | ||||
| 197 | + # -- page détail ------------------------------------------------------------ | ||||
| 198 | + @staticmethod | ||||
| 199 | + def _parse_detail(html: str) -> dict: | ||||
| 200 | + """Payload {description, amenities, images} d'une page détail Booking | ||||
| 201 | + ({} si page bloquée/invalide).""" | ||||
| 202 | + out: dict = {} | ||||
| 203 | + | ||||
| 204 | + # description complète (SSR) — HTML → texte | ||||
| 205 | + m = _DESC_RE.search(html or "") | ||||
| 206 | + if m: | ||||
| 207 | + txt = re.sub(r"<br\s*/?>|</p>", "\n", m.group(1)) | ||||
| 208 | + txt = _html.unescape(re.sub(r"<[^>]+>", " ", txt)) | ||||
| 209 | + lines = [re.sub(r"\s+", " ", ln).strip() for ln in txt.split("\n")] | ||||
| 210 | + desc = "\n".join(ln for ln in lines if ln).strip() | ||||
| 211 | + if desc: | ||||
| 212 | + out["description"] = desc[:6000] | ||||
| 213 | + | ||||
| 214 | + # store Apollo de la page détail : commodités localisées + galerie | ||||
| 215 | + mm = _CAPLA_RE.search(html or "") | ||||
| 216 | + if mm: | ||||
| 217 | + try: | ||||
| 218 | + store = json.loads(mm.group(1)) | ||||
| 219 | + except ValueError: | ||||
| 220 | + try: | ||||
| 221 | + store = json.loads(_html.unescape(mm.group(1))) | ||||
| 222 | + except ValueError: | ||||
| 223 | + store = {} | ||||
| 224 | + amenities: list[str] = [] | ||||
| 225 | + for key, val in store.items(): | ||||
| 226 | + if not isinstance(val, dict): | ||||
| 227 | + continue | ||||
| 228 | + name = "" | ||||
| 229 | + if key.startswith("Instance:"): # équipements du lieu | ||||
| 230 | + name = (val.get("title") or "").strip() | ||||
| 231 | + elif key.startswith("SimpleFacility:"): # équipements des unités | ||||
| 232 | + name = (val.get("name") or "").strip() | ||||
| 233 | + if name and name not in amenities: | ||||
| 234 | + amenities.append(name) | ||||
| 235 | + if amenities: | ||||
| 236 | + out["amenities"] = amenities[:80] | ||||
| 237 | + | ||||
| 238 | + images: list[str] = [] | ||||
| 239 | + for key, val in store.items(): | ||||
| 240 | + if not (key.startswith("AccommodationPhoto:") | ||||
| 241 | + and isinstance(val, dict)): | ||||
| 242 | + continue | ||||
| 243 | + for k2, v2 in val.items(): | ||||
| 244 | + if k2.startswith("resource(") and isinstance(v2, dict) \ | ||||
| 245 | + and v2.get("relativeUrl"): | ||||
| 246 | + rel = re.sub(r"/(?:square|max)\w+/", "/max1024x768/", | ||||
| 247 | + v2["relativeUrl"], count=1) | ||||
| 248 | + u = _IMG_BASE + rel | ||||
| 249 | + if u not in images: | ||||
| 250 | + images.append(u) | ||||
| 251 | + break | ||||
| 252 | + if len(images) >= 15: | ||||
| 253 | + break | ||||
| 254 | + if images: | ||||
| 255 | + out["images"] = images | ||||
| 256 | + return out | ||||
| 257 | + | ||||
| 258 | + def _enrich_details(self, listings: list[StListing]) -> None: | ||||
| 259 | + """Visite les fiches détail via le cache self.detail() sous budget : | ||||
| 260 | + les hits de cache sont gratuits, seuls les fetchs réseau comptent.""" | ||||
| 261 | + limit = max(0, int(os.environ.get("LOUKA_BOOKING_DETAIL_LIMIT", "150") | ||||
| 262 | + or 150)) | ||||
| 263 | + used = enriched = streak = 0 | ||||
| 264 | + for lst in listings: | ||||
| 265 | + def fetch_fn(url=lst.url): | ||||
| 266 | + nonlocal used, streak | ||||
| 267 | + if used >= limit or streak >= 5: # tempête anti-bot : on coupe | ||||
| 268 | + raise _DetailSkip | ||||
| 269 | + used += 1 | ||||
| 270 | + res = self.scrapfly(url, render_js=False, asp=True) | ||||
| 271 | + if res.get("status_code") in (404, 410): | ||||
| 272 | + return {} # fiche retirée : cacher vide | ||||
| 273 | + payload = self._parse_detail(res.get("content") or "") | ||||
| 274 | + if not payload: | ||||
| 275 | + streak += 1 | ||||
| 276 | + raise _DetailSkip # blocage/vide : pas de cache | ||||
| 277 | + streak = 0 | ||||
| 278 | + return payload | ||||
| 279 | + | ||||
| 280 | + try: | ||||
| 281 | + d = self.detail(lst.external_id, "v1", fetch_fn) | ||||
| 282 | + except _DetailSkip: | ||||
| 283 | + continue | ||||
| 284 | + except Exception: # noqa: BLE001 — une fiche ne bloque pas le run | ||||
| 285 | + continue | ||||
| 286 | + if not d: | ||||
| 287 | + continue | ||||
| 288 | + if d.get("description") and len(d["description"]) > \ | ||||
| 289 | + len(lst.description or ""): | ||||
| 290 | + lst.description = d["description"] | ||||
| 291 | + if d.get("amenities"): | ||||
| 292 | + seen = {a.lower() for a in lst.amenities} | ||||
| 293 | + for a in d["amenities"]: | ||||
| 294 | + if a.lower() not in seen: | ||||
| 295 | + lst.amenities.append(a) | ||||
| 296 | + seen.add(a.lower()) | ||||
| 297 | + if d.get("images") and len(d["images"]) > len(lst.images): | ||||
| 298 | + lst.images = list(d["images"]) | ||||
| 299 | + enriched += 1 | ||||
| 300 | + print(f"[booking] détail : {enriched} annonces enrichies" | ||||
| 301 | + f" ({used}/{limit} fetchs réseau)", file=sys.stderr) | ||||
| 302 | + | ||||
| 182 | # -- contrat --------------------------------------------------------------- | 303 | # -- contrat --------------------------------------------------------------- | ||
| 183 | def fetch(self) -> list[StListing]: | 304 | def fetch(self) -> list[StListing]: | ||
| 184 | today = datetime.date.today() | 305 | today = datetime.date.today() | ||
@@ -207,4 +328,6 @@ class Booking(StConnector): | |||||
| 207 | continue | 328 | continue | ||
| 208 | if lst and lst.external_id not in listings: | 329 | if lst and lst.external_id not in listings: | ||
| 209 | listings[lst.external_id] = lst | 330 | listings[lst.external_id] = lst | ||
| 210 | − return list(listings.values()) | 331 | + out = list(listings.values()) | ||
| 332 | + self._enrich_details(out) | ||||
| 333 | + return out | ||||
added
louka/shortterm/connectors/boraboreal.py
+174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/campingquebec.py
+274 −0
Large diff (274 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/captremblant.py
+157 −0
Large diff (157 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/chaleto.py
+198 −0
Large diff (198 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/chaletsalpins.py
+200 −0
Large diff (200 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/chaletsbsl.py
+168 −0
Large diff (168 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/chaletsdanslenord.py
+219 −0
Large diff (219 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/domesstcome.py
+157 −0
Large diff (157 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/expedia.py
+51 −3
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/gitesauquebec.py
+218 −0
Large diff (218 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/gitespassant.py
+29 −4
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/glampinghub.py
+71 −2
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/hebergementcharlevoix.py
+164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/hebergia.py
+164 −0
Large diff (164 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/homminichalets.py
+142 −0
Large diff (142 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/kijiji.py
+95 −25
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/lesversants.py
+156 −0
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/locationdechalets.py
+265 −0
Large diff (265 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/memoriachalets.py
+248 −0
Large diff (248 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/panoraloges.py
+135 −0
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/parcscanada.py
+135 −15
Large diff (193 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/pourvoiries.py
+288 −0
Large diff (288 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/qldc.py
+54 −7
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/rezerve.py
+116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added
louka/shortterm/connectors/rvmt.py
+215 −0
Large diff (215 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/sepaq.py
+141 −2
Large diff (182 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/sinistar.py
+4 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/tremblantliving.py
+63 −2
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/connectors/vrbo.py
+55 −6
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
modified
louka/shortterm/web.py
+123 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff