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": [ | |
| 3 | − { "id": "airbnb", "name": "Airbnb", "url": "https://www.airbnb.ca", "category": "internationale" }, | |
| 4 | − { "id": "booking", "name": "Booking.com", "url": "https://www.booking.com", "category": "internationale" }, | |
| 5 | − { "id": "vrbo", "name": "Vrbo", "url": "https://www.vrbo.com", "category": "internationale" }, | |
| 6 | − { "id": "expedia", "name": "Expedia (locations de vacances)", "url": "https://www.expedia.ca", "category": "internationale" }, | |
| 7 | − { "id": "agoda", "name": "Agoda Homes", "url": "https://www.agoda.com", "category": "internationale", "status": "en attente de connecteur" }, | |
| 8 | − { "id": "plumguide", "name": "Plum Guide", "url": "https://www.plumguide.com", "category": "internationale", "status": "en attente de connecteur" }, | |
| 9 | − { "id": "glampinghub", "name": "Glamping Hub", "url": "https://glampinghub.com", "category": "internationale" }, | |
| 10 | − { "id": "hipcamp", "name": "Hipcamp", "url": "https://www.hipcamp.com", "category": "internationale" }, | |
| 11 | − { "id": "marriott_hvmi", "name": "Marriott Homes & Villas", "url": "https://homes-and-villas.marriott.com", "category": "internationale", "status": "en attente de connecteur" }, | |
| 12 | − { "id": "furnishedfinder", "name": "Furnished Finder", "url": "https://www.furnishedfinder.com", "category": "internationale", "status": "en attente de connecteur" }, | |
| 13 | − { "id": "corporatestays", "name": "Corporate Stays", "url": "https://www.corporatestays.com", "category": "internationale" }, | |
| 14 | − { "id": "monsieurchalets", "name": "MonsieurChalets", "url": "https://www.monsieurchalets.com", "category": "quebecoise" }, | |
| 15 | − { "id": "wechalet", "name": "WeChalet", "url": "https://wechalet.com", "category": "quebecoise" }, | |
| 16 | − { "id": "chaletsarabais", "name": "Chalets à Rabais", "url": "https://www.chaletsarabais.com", "category": "quebecoise" }, | |
| 17 | − { "id": "chaletsalouer", "name": "ChaletsÀLouer.com", "url": "https://www.chaletsalouer.com", "category": "quebecoise" }, | |
| 18 | − { "id": "mcal", "name": "Maisons et chalets à louer", "url": "https://maisonsetchaletsalouer.com", "category": "quebecoise" }, | |
| 19 | − { "id": "qldc", "name": "Québec Location de Chalets", "url": "https://www.quebeclocationdechalets.com", "category": "quebecoise" }, | |
| 20 | − { "id": "rsvpchalets", "name": "RSVP Chalets", "url": "https://rsvpchalets.com", "category": "quebecoise" }, | |
| 21 | − { "id": "chaletsauquebec", "name": "Chalets au Québec", "url": "https://www.chaletsauquebec.com", "category": "quebecoise" }, | |
| 22 | − { "id": "tremblant_living", "name": "Tremblant Living", "url": "https://www.tremblantliving.com", "category": "agence" }, | |
| 23 | − { "id": "gites_passant", "name": "Gîtes et Auberges du Passant", "url": "https://www.terroiretsaveurs.com", "category": "gites" }, | |
| 24 | − { "id": "bonjourquebec", "name": "Bonjour Québec (CITQ)", "url": "https://www.bonjourquebec.com", "category": "officielle" }, | |
| 25 | − { "id": "sepaq", "name": "Sépaq", "url": "https://www.sepaq.com", "category": "officielle" }, | |
| 26 | − { "id": "parcscanada", "name": "Parcs Canada", "url": "https://reservation.pc.gc.ca", "category": "officielle" }, | |
| 27 | − { "id": "sinistar", "name": "Sinistar", "url": "https://www.sinistar.ca", "category": "relogement" }, | |
| 28 | − { "id": "louer_ca_ct", "name": "Louer.ca (court terme)", "url": "https://www.louer.ca", "category": "relogement", "status": "en attente de connecteur" }, | |
| 29 | − { "id": "kijiji_ct", "name": "Kijiji (court terme)", "url": "https://www.kijiji.ca", "category": "petites-annonces" }, | |
| 30 | − { "id": "lespac_ct", "name": "LesPAC (court terme)", "url": "https://www.lespac.com", "category": "petites-annonces", "status": "en attente de connecteur" }, | |
| 31 | − { "id": "fb_marketplace_ct", "name": "Facebook Marketplace (court terme)", "url": "https://www.facebook.com/marketplace", "category": "petites-annonces", "status": "en attente de connecteur" }, | |
| 32 | − { "id": "craigslist_ct", "name": "Craigslist (court terme)", "url": "https://montreal.craigslist.org", "category": "petites-annonces", "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" } | |
| 34 | − ] | |
| 35 | −} | |
| 2 | + "sources": [ | |
| 3 | + { | |
| 4 | + "id": "airbnb", | |
| 5 | + "name": "Airbnb", | |
| 6 | + "url": "https://www.airbnb.ca", | |
| 7 | + "category": "internationale" | |
| 8 | + }, | |
| 9 | + { | |
| 10 | + "id": "booking", | |
| 11 | + "name": "Booking.com", | |
| 12 | + "url": "https://www.booking.com", | |
| 13 | + "category": "internationale" | |
| 14 | + }, | |
| 15 | + { | |
| 16 | + "id": "vrbo", | |
| 17 | + "name": "Vrbo", | |
| 18 | + "url": "https://www.vrbo.com", | |
| 19 | + "category": "internationale" | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + "id": "expedia", | |
| 23 | + "name": "Expedia (locations de vacances)", | |
| 24 | + "url": "https://www.expedia.ca", | |
| 25 | + "category": "internationale" | |
| 26 | + }, | |
| 27 | + { | |
| 28 | + "id": "agoda", | |
| 29 | + "name": "Agoda Homes", | |
| 30 | + "url": "https://www.agoda.com", | |
| 31 | + "category": "internationale", | |
| 32 | + "status": "en attente de connecteur" | |
| 33 | + }, | |
| 34 | + { | |
| 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 | 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 | 107 | const CT_SOURCE_NAMES: Record<string, string> = {}; |
| 91 | 108 | export function registerCtSourceNames(sources: CtSource[]) { |
| 92 | 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 | 136 | export const fetchCtListing = (uid: string) => |
| 120 | 137 | get<CtListing>(`/api/ct/listings/${encodeURIComponent(uid)}`); |
| 138 | +export const fetchCtContext = (uid: string) => | |
| 139 | + get<CtContext>(`/api/ct/listings/${encodeURIComponent(uid)}/context`); | |
| 121 | 140 | export const fetchCtFacets = () => get<CtFacets>("/api/ct/facets"); |
| 122 | 141 | export const fetchCtStats = () => get<CtStats>("/api/ct/stats"); |
| 123 | 142 | export const fetchCtSources = () => |
modified
frontend/src/pages/CourtTermeFiche.tsx
+27 −2
@@ -8,10 +8,12 @@ | ||
| 8 | 8 | import { lazy, Suspense, useEffect, useRef, useState } from "react"; |
| 9 | 9 | import { Link, useParams } from "react-router-dom"; |
| 10 | 10 | import { |
| 11 | − CtListing, ctSourceName, fetchCtListing, fetchCtSources, fmtNight, | |
| 12 | − registerCtSourceNames, | |
| 11 | + CtContext, CtListing, ctSourceName, fetchCtContext, fetchCtListing, | |
| 12 | + fetchCtSources, fmtNight, registerCtSourceNames, | |
| 13 | 13 | } from "../ctapi"; |
| 14 | 14 | import SmartImg from "../components/SmartImg"; |
| 15 | +import CtListingCard from "../components/CtListingCard"; | |
| 16 | +import CtPriceAnalysis, { ctDealBadge } from "../components/CtPriceAnalysis"; | |
| 15 | 17 | import { IcoAlert } from "../components/Icons"; |
| 16 | 18 | |
| 17 | 19 | const CtFicheMap = lazy(() => import("../components/CtFicheMap")); |
@@ -84,12 +86,15 @@ function Galerie({ images, titre, typeLabel }: | ||
| 84 | 86 | export default function CourtTermeFichePage() { |
| 85 | 87 | const { uid } = useParams<{ uid: string }>(); |
| 86 | 88 | const [l, setL] = useState<CtListing | null>(null); |
| 89 | + const [ctx, setCtx] = useState<CtContext | null>(null); | |
| 87 | 90 | const [error, setError] = useState<string | null>(null); |
| 88 | 91 | |
| 89 | 92 | useEffect(() => { |
| 90 | 93 | fetchCtSources().then((r) => registerCtSourceNames(r.sources)).catch(() => {}); |
| 91 | 94 | if (!uid) return; |
| 95 | + setCtx(null); | |
| 92 | 96 | fetchCtListing(uid).then(setL).catch((e) => setError(String(e))); |
| 97 | + fetchCtContext(uid).then(setCtx).catch(() => setCtx(null)); | |
| 93 | 98 | window.scrollTo(0, 0); |
| 94 | 99 | }, [uid]); |
| 95 | 100 | |
@@ -152,6 +157,12 @@ export default function CourtTermeFichePage() { | ||
| 152 | 157 | {fmtNight(l.price_night, l.price_label)} |
| 153 | 158 | {l.price_night != null && <small> /{NBSP}nuit</small>} |
| 154 | 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 | 166 | {l.rating != null && ( |
| 156 | 167 | <div className="deal-badge deal-ok"> |
| 157 | 168 | ★ {l.rating.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} / 5 |
@@ -218,6 +229,7 @@ export default function CourtTermeFichePage() { | ||
| 218 | 229 | </div> |
| 219 | 230 | |
| 220 | 231 | <div className="f-col"> |
| 232 | + <CtPriceAnalysis p={ctx?.price ?? null} price={l.price_night} /> | |
| 221 | 233 | {l.lat != null && l.lng != null && ( |
| 222 | 234 | <section className="f-bloc f-carte" id="emplacement"> |
| 223 | 235 | <h2>Emplacement</h2> |
@@ -232,6 +244,19 @@ export default function CourtTermeFichePage() { | ||
| 232 | 244 | </div> |
| 233 | 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 | 260 | <div className="fine f-foot"> |
| 236 | 261 | {synced && <>Dernière synchronisation : {synced}. </>} |
| 237 | 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 | 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 | 1062 | /* --- Passerelle vers l'annonce originale ------------------------------------ */ |
| 1059 | 1063 | .passerelle { |
| 1060 | 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 | 22 | # L'ordre des cellules est mélangé à chaque run pour que les cellules restantes |
| 23 | 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 | 36 | # Réglages env : LOUKA_AIRBNB_PAGES (pages par cellule feuille, défaut 15), |
| 26 | 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 | 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 | 159 | _MONEY_RE = re.compile(r"\$\s*([\d,]+(?:\.\d+)?)") |
| 148 | 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 | 182 | def _b64_room_id(demand_id: str) -> str: |
| 152 | 183 | """"RGVtYW5kU3RheUxpc3Rpbmc6MTIz" → "123" (DemandStayListing:<id>).""" |
@@ -210,6 +241,156 @@ class Airbnb(StConnector): | ||
| 210 | 241 | return self.get_scrapfly(url, render_js=True, asp=True, |
| 211 | 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 | 394 | # -- parse JSON embarqué ---------------------------------------------------- |
| 214 | 395 | @staticmethod |
| 215 | 396 | def _deferred_results(html: str) -> tuple[list[dict], list[str]]: |
@@ -454,4 +635,5 @@ class Airbnb(StConnector): | ||
| 454 | 635 | if stack: |
| 455 | 636 | print(f"[airbnb] budget épuisé ({budget} req)," |
| 456 | 637 | f" {len(stack)} cellules non visitées", file=sys.stderr) |
| 638 | + self._enrich_details(out) | |
| 457 | 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 | 18 | # quelques milliers max) — gîtes et insolites sont gardés en entier ; |
| 19 | 19 | # 3. fiche /fiche/<id> (cache self.detail — 1 seule visite par fiche) : |
| 20 | 20 | # région touristique, ville, adresse, no d'enregistrement CITQ, |
| 21 | −# description, services/équipements, animaux, tarifs max (détails), | |
| 22 | −# photos. Pas de prix « à partir de » sur le site → seuls les maximums | |
| 23 | −# affichés sont conservés dans details (jamais utilisés comme | |
| 24 | −# price_night pour ne pas fausser le « à partir de »). | |
| 21 | +# description, services/équipements, animaux, tarifs, photos. | |
| 22 | +# PRIX : le site ne publie QUE des maximums par nuitée (widget Tarifs : | |
| 23 | +# « Maximum pour l'unité la plus chère », « Prix maximum par nuitée | |
| 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 | 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 | 101 | def _abs(url: str) -> str: |
| 64 | 102 | url = _html.unescape(url or "").strip() |
| 65 | 103 | if not url: |
@@ -233,6 +271,16 @@ class BonjourQuebec(StConnector): | ||
| 233 | 271 | "unites": d.get("unites"), |
| 234 | 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 | 284 | url = d.get("url_final") or f"{BASE}/fiche/{ext}" |
| 237 | 285 | lst = StListing( |
| 238 | 286 | source=self.source_id, |
@@ -243,7 +291,9 @@ class BonjourQuebec(StConnector): | ||
| 243 | 291 | address=d.get("address", ""), |
| 244 | 292 | city=d.get("city", ""), |
| 245 | 293 | region=d.get("region", ""), |
| 246 | − capacity=None, | |
| 294 | + price_label=_price_label(d.get("tarifs") or []), | |
| 295 | + capacity=capacity, | |
| 296 | + bedrooms=bedrooms, | |
| 247 | 297 | pets=d.get("pets"), |
| 248 | 298 | citq=d.get("citq", ""), |
| 249 | 299 | description=desc, |
modified
louka/shortterm/connectors/booking.py
+124 −1
@@ -17,12 +17,21 @@ | ||
| 17 | 17 | # Recherche AVEC dates génériques (~30 jours, 2 nuits) : sans dates, Booking |
| 18 | 18 | # ne renvoie ni prix ni configuration des unités. Le prix est donc indicatif |
| 19 | 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 | 29 | from __future__ import annotations |
| 22 | 30 | |
| 23 | 31 | import datetime |
| 24 | 32 | import html as _html |
| 25 | 33 | import json |
| 34 | +import os | |
| 26 | 35 | import re |
| 27 | 36 | import sys |
| 28 | 37 | from urllib.parse import quote |
@@ -30,6 +39,10 @@ from urllib.parse import quote | ||
| 30 | 39 | from ..schema import StListing |
| 31 | 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 | 46 | # (texte de recherche Booking, région touristique QC) |
| 34 | 47 | DESTINATIONS = [ |
| 35 | 48 | ("Mont-Tremblant", "Laurentides"), |
@@ -69,6 +82,8 @@ TYPE_MAP = { | ||
| 69 | 82 | _CAPLA_RE = re.compile( |
| 70 | 83 | r'<script[^>]*data-capla-store-data="apollo"[^>]*>(.*?)</script>', re.S) |
| 71 | 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 | 89 | class Booking(StConnector): |
@@ -179,6 +194,112 @@ class Booking(StConnector): | ||
| 179 | 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 | 303 | # -- contrat --------------------------------------------------------------- |
| 183 | 304 | def fetch(self) -> list[StListing]: |
| 184 | 305 | today = datetime.date.today() |
@@ -207,4 +328,6 @@ class Booking(StConnector): | ||
| 207 | 328 | continue |
| 208 | 329 | if lst and lst.external_id not in listings: |
| 209 | 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