SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

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>
Simon-Pierre Boucher committed (Aug 25, 2026) parent bf51f56 Browse files

41 changed files +5,841 −109

41 changed files
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&currency=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
Menu

Theme