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": [
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&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 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
Menu

Theme