SPB Git

spb/lou-ka

Public

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

court terme : nouvelle section « Location court terme » isolée (backend louka/shortterm + API /api/ct + page /court-terme) — 1er connecteur Airbnb

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 22, 2026) parent 6941d85 Browse files

24 changed files +2,844 −2

modified .gitignore +3 −0
@@ -23,4 +23,7 @@ data/imgcache/
23 data/louka.db-shm 23 data/louka.db-shm
24 data/louka.db-wal 24 data/louka.db-wal
25 data/louka.db.backup-* 25 data/louka.db.backup-*
26+data/louka_ct.db
27+data/louka_ct.db-shm
28+data/louka_ct.db-wal
26 frontend/tsconfig.tsbuildinfo 29 frontend/tsconfig.tsbuildinfo
added data/sources_ct.json +42 −0
@@ -0,0 +1,42 @@
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": "tripadvisor", "name": "TripAdvisor Rentals", "url": "https://www.tripadvisor.ca", "category": "internationale" },
7+ { "id": "expedia", "name": "Expedia (locations de vacances)", "url": "https://www.expedia.ca", "category": "internationale" },
8+ { "id": "agoda", "name": "Agoda Homes", "url": "https://www.agoda.com", "category": "internationale" },
9+ { "id": "plumguide", "name": "Plum Guide", "url": "https://www.plumguide.com", "category": "internationale" },
10+ { "id": "glampinghub", "name": "Glamping Hub", "url": "https://glampinghub.com", "category": "internationale" },
11+ { "id": "hipcamp", "name": "Hipcamp", "url": "https://www.hipcamp.com", "category": "internationale" },
12+ { "id": "marriott_hvmi", "name": "Marriott Homes & Villas", "url": "https://homes-and-villas.marriott.com", "category": "internationale" },
13+ { "id": "sonder", "name": "Sonder", "url": "https://www.sonder.com", "category": "internationale" },
14+ { "id": "blueground", "name": "Blueground", "url": "https://www.theblueground.com", "category": "internationale" },
15+ { "id": "furnishedfinder", "name": "Furnished Finder", "url": "https://www.furnishedfinder.com", "category": "internationale" },
16+ { "id": "corporatestays", "name": "Corporate Stays", "url": "https://www.corporatestays.com", "category": "internationale" },
17+ { "id": "monsieurchalets", "name": "MonsieurChalets", "url": "https://www.monsieurchalets.com", "category": "quebecoise" },
18+ { "id": "wechalet", "name": "WeChalet", "url": "https://wechalet.com", "category": "quebecoise" },
19+ { "id": "chaletsarabais", "name": "Chalets à Rabais", "url": "https://www.chaletsarabais.com", "category": "quebecoise" },
20+ { "id": "chaletsalouer", "name": "ChaletsÀLouer.com", "url": "https://www.chaletsalouer.com", "category": "quebecoise" },
21+ { "id": "mcal", "name": "Maisons et chalets à louer", "url": "https://maisonsetchaletsalouer.com", "category": "quebecoise" },
22+ { "id": "qldc", "name": "Québec Location de Chalets", "url": "https://www.quebeclocationdechalets.com", "category": "quebecoise" },
23+ { "id": "rsvpchalets", "name": "RSVP Chalets", "url": "https://rsvpchalets.com", "category": "quebecoise" },
24+ { "id": "chaletsquebec", "name": "Chalets Québec", "url": "https://www.chaletsquebec.com", "category": "quebecoise" },
25+ { "id": "chaletsauquebec", "name": "Chalets au Québec", "url": "https://www.chaletsauquebec.com", "category": "quebecoise" },
26+ { "id": "hebergementquebec", "name": "Hébergement Québec", "url": "https://hebergementquebec.com", "category": "quebecoise" },
27+ { "id": "chaletsdirect", "name": "ChaletsDirect", "url": "https://www.chaletsdirect.com", "category": "quebecoise" },
28+ { "id": "locationschalets", "name": "LocationsChalets", "url": "https://www.locationschalets.com", "category": "quebecoise" },
29+ { "id": "tremblant_living", "name": "Tremblant Living", "url": "https://www.tremblantliving.com", "category": "agence" },
30+ { "id": "gites_passant", "name": "Gîtes et Auberges du Passant", "url": "https://www.terroiretsaveurs.com", "category": "gites" },
31+ { "id": "bonjourquebec", "name": "Bonjour Québec (CITQ)", "url": "https://www.bonjourquebec.com", "category": "officielle" },
32+ { "id": "sepaq", "name": "Sépaq", "url": "https://www.sepaq.com", "category": "officielle" },
33+ { "id": "parcscanada", "name": "Parcs Canada", "url": "https://reservation.pc.gc.ca", "category": "officielle" },
34+ { "id": "sinistar", "name": "Sinistar", "url": "https://www.sinistar.ca", "category": "relogement" },
35+ { "id": "louer_ca_ct", "name": "Louer.ca (court terme)", "url": "https://www.louer.ca", "category": "relogement" },
36+ { "id": "kijiji_ct", "name": "Kijiji (court terme)", "url": "https://www.kijiji.ca", "category": "petites-annonces" },
37+ { "id": "lespac_ct", "name": "LesPAC (court terme)", "url": "https://www.lespac.com", "category": "petites-annonces" },
38+ { "id": "fb_marketplace_ct", "name": "Facebook Marketplace (court terme)", "url": "https://www.facebook.com/marketplace", "category": "petites-annonces" },
39+ { "id": "craigslist_ct", "name": "Craigslist (court terme)", "url": "https://montreal.craigslist.org", "category": "petites-annonces" },
40+ { "id": "bonjourresidences", "name": "Bonjour Résidences", "url": "https://www.bonjourresidences.com", "category": "cas-particuliers" }
41+ ]
42+}
modified frontend/src/App.tsx +9 −0
@@ -20,6 +20,8 @@ import { LogoIcon } from "./components/Logo";
20 import GroupeKaBadge from "./ka/GroupeKaBadge"; 20 import GroupeKaBadge from "./ka/GroupeKaBadge";
21 import KaFooter from "./ka/KaFooter"; 21 import KaFooter from "./ka/KaFooter";
22 import ContactPage from "./pages/Contact"; 22 import ContactPage from "./pages/Contact";
23+import CourtTermePage from "./pages/CourtTerme";
24+import CourtTermeFichePage from "./pages/CourtTermeFiche";
23 import Home from "./pages/Home"; 25 import Home from "./pages/Home";
24 import ListingPage from "./pages/Listing"; 26 import ListingPage from "./pages/Listing";
25 import BotPage from "./pages/Bot"; 27 import BotPage from "./pages/Bot";
@@ -77,6 +79,7 @@ function Ticker() {
77 const NAV_LINKS = [ 79 const NAV_LINKS = [
78 { to: "/", label: "Logements", icon: <IcoHouse size={17} />, end: true }, 80 { to: "/", label: "Logements", icon: <IcoHouse size={17} />, end: true },
79 { to: "/?view=carte", label: "Carte", icon: <IcoMap size={17} />, end: false, force: true }, 81 { to: "/?view=carte", label: "Carte", icon: <IcoMap size={17} />, end: false, force: true },
82+ { to: "/court-terme", label: "Court terme", icon: <IcoCompass size={17} />, end: false },
80 { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false }, 83 { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },
81 { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false }, 84 { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },
82 { to: "/contact", label: "Contact", icon: <IcoUser size={17} />, end: false }, 85 { to: "/contact", label: "Contact", icon: <IcoUser size={17} />, end: false },
@@ -229,6 +232,9 @@ function Header() {
229 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}> 232 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
230 Logements 233 Logements
231 </NavLink> 234 </NavLink>
235+ <NavLink to="/court-terme" className={({ isActive }) => (isActive ? "active" : "")}>
236+ Court terme
237+ </NavLink>
232 <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}> 238 <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
233 Stats 239 Stats
234 </NavLink> 240 </NavLink>
@@ -290,6 +296,7 @@ function Footer() {
290 Agrégateur indépendant : chaque fiche renvoie vers l'annonce originale. 296 Agrégateur indépendant : chaque fiche renvoie vers l'annonce originale.
291 </span> 297 </span>
292 <NavLink to="/">Logements</NavLink> 298 <NavLink to="/">Logements</NavLink>
299+ <NavLink to="/court-terme">Court terme</NavLink>
293 <NavLink to="/villes">Villes</NavLink> 300 <NavLink to="/villes">Villes</NavLink>
294 <NavLink to="/stats">Stats</NavLink> 301 <NavLink to="/stats">Stats</NavLink>
295 <NavLink to="/sources">Sources</NavLink> 302 <NavLink to="/sources">Sources</NavLink>
@@ -343,6 +350,8 @@ export default function App() {
343 <Routes> 350 <Routes>
344 <Route path="/" element={<Home />} /> 351 <Route path="/" element={<Home />} />
345 <Route path="/logement/:uid" element={<ListingPage />} /> 352 <Route path="/logement/:uid" element={<ListingPage />} />
353+ <Route path="/court-terme" element={<CourtTermePage />} />
354+ <Route path="/court-terme/:uid" element={<CourtTermeFichePage />} />
346 <Route path="/villes" element={<VillesPage />} /> 355 <Route path="/villes" element={<VillesPage />} />
347 <Route path="/ville/:ville" element={<VillePage />} /> 356 <Route path="/ville/:ville" element={<VillePage />} />
348 <Route path="/ville/:ville/:type" element={<VillePage />} /> 357 <Route path="/ville/:ville/:type" element={<VillePage />} />
added frontend/src/components/CtFicheMap.tsx +48 −0
@@ -0,0 +1,48 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Location court terme
3+// components/CtFicheMap.tsx : mini-carte 3D de la fiche court terme
4+// (KaSpotlightMap) — chargée paresseusement depuis CourtTermeFiche.tsx.
5+// -----------------------------------------------------------------------------
6+import { useMemo } from "react";
7+import "mapbox-gl/dist/mapbox-gl.css";
8+import "@groupe-ka/ka-maps/styles.css";
9+import type { MapProperty } from "@groupe-ka/ka-maps";
10+import { KaBrandBadge, KaSpotlightMap } from "@groupe-ka/ka-maps/react";
11+import type { CtListing } from "../ctapi";
12+import { louKaMapTheme } from "../kamaps/theme";
13+import { MAPBOX_TOKEN } from "../kamaps/config";
14+
15+export default function CtFicheMap({ l }: { l: CtListing }) {
16+ const property = useMemo<MapProperty | null>(() => {
17+ if (l.lat == null || l.lng == null) return null;
18+ return {
19+ id: l.uid,
20+ appSource: "lou-ka",
21+ latitude: l.lat,
22+ longitude: l.lng,
23+ kind: "listing",
24+ listingType: "rent",
25+ price: l.price_night ?? undefined,
26+ propertyType: l.property_type || undefined,
27+ address: l.address || l.title || undefined,
28+ city: l.city || undefined,
29+ thumbnailUrl: l.images?.[0],
30+ };
31+ }, [l.uid, l.lat, l.lng, l.price_night, l.property_type, l.address, l.title, l.city, l.images]);
32+
33+ if (!property) return null;
34+
35+ return (
36+ <div className="lmap3d" role="img"
37+ aria-label={`Carte 3D — ${l.address || l.title}`}>
38+ <KaSpotlightMap
39+ theme={louKaMapTheme}
40+ mapboxToken={MAPBOX_TOKEN}
41+ property={property}
42+ buildingColor="#ff6a00"
43+ >
44+ <KaBrandBadge />
45+ </KaSpotlightMap>
46+ </div>
47+ );
48+}
added frontend/src/components/CtListingCard.tsx +52 −0
@@ -0,0 +1,52 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Location court terme
3+// components/CtListingCard.tsx : carte d'hébergement (grille /court-terme)
4+// -----------------------------------------------------------------------------
5+import { Link } from "react-router-dom";
6+import { CtListing, ctSourceName, fmtNight } from "../ctapi";
7+import { IcoCamera } from "./Icons";
8+import SmartImg from "./SmartImg";
9+
10+export default function CtListingCard({ l }: { l: CtListing }) {
11+ const img = l.images && l.images.length > 0 ? l.images[0] : null;
12+ const meta = [
13+ l.capacity ? `${l.capacity} pers.` : "",
14+ l.bedrooms ? `${l.bedrooms} ch.` : "",
15+ l.details?.spa ? "Spa" : "",
16+ l.details?.waterfront ? "Bord de l'eau" : "",
17+ ].filter(Boolean);
18+ return (
19+ <Link to={`/court-terme/${encodeURIComponent(l.uid)}`} className="card">
20+ <div className="card-img">
21+ <SmartImg src={img} width_={480} fallbackLabel={l.property_type || "Séjour"}
22+ alt={l.title} loading="lazy" decoding="async" />
23+ {l.property_type && <span className="badge type">{l.property_type}</span>}
24+ {l.images.length > 1 && (
25+ <span className="badge right"><IcoCamera size={12} /> {l.images.length}</span>
26+ )}
27+ </div>
28+ <div className="card-body">
29+ <div className="card-price">
30+ {fmtNight(l.price_night, l.price_label)}
31+ {l.price_night != null && <small> / nuit</small>}
32+ {l.rating != null && (
33+ <span className="avail" style={{ marginLeft: "auto" }}>
34+ ★ {l.rating.toLocaleString("fr-CA", { maximumFractionDigits: 1 })}
35+ {l.reviews ? ` (${l.reviews})` : ""}
36+ </span>
37+ )}
38+ </div>
39+ <div className="card-title">{l.title}</div>
40+ <div className="card-meta">
41+ {l.city && <span>{l.city}</span>}
42+ {l.city && l.region && <span className="sep" />}
43+ {l.region && <span>{l.region}</span>}
44+ </div>
45+ <div className="card-foot">
46+ <span className="source-tag">{ctSourceName(l.source)}</span>
47+ {meta.length > 0 && <span className="avail">{meta.join(" · ")}</span>}
48+ </div>
49+ </div>
50+ </Link>
51+ );
52+}
added frontend/src/components/CtMapView.tsx +118 −0
@@ -0,0 +1,118 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Location court terme
3+// components/CtMapView.tsx : carte des hébergements court terme (Ka Maps),
4+// pilotée par le viewport via /api/ct/listings.geojson — chargée paresseusement.
5+// -----------------------------------------------------------------------------
6+import { useCallback, useMemo } from "react";
7+import { useNavigate } from "react-router-dom";
8+import "mapbox-gl/dist/mapbox-gl.css";
9+import "@groupe-ka/ka-maps/styles.css";
10+import type { MapProperty } from "@groupe-ka/ka-maps";
11+import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps";
12+import {
13+ KaBrandBadge,
14+ KaMapView,
15+ LoadingIndicator,
16+ PropertyPreview,
17+ ResultCount,
18+ SearchAreaControl,
19+ Tilt3DControl,
20+} from "@groupe-ka/ka-maps/react";
21+import { CtFilters, ctSourceName } from "../ctapi";
22+import { louKaMapTheme } from "../kamaps/theme";
23+import { ctMapAdapter } from "../kamaps/ctAdapter";
24+import { MAPBOX_TOKEN } from "../kamaps/config";
25+
26+const QUEBEC_CENTER = { lat: 46.82, lng: -71.25 };
27+
28+function PreviewCard({ p }: { p: MapProperty }) {
29+ const navigate = useNavigate();
30+ const extra = (p.extra ?? {}) as {
31+ title?: string | null; priceLabel?: string | null;
32+ capacity?: number | null; bedrooms?: number | null;
33+ rating?: number | null; source?: string;
34+ };
35+ const prix = p.price != null
36+ ? `${p.price.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`
37+ : extra.priceLabel || "Prix sur demande";
38+ const meta = [
39+ p.propertyType,
40+ extra.capacity ? `${extra.capacity} pers.` : "",
41+ extra.bedrooms ? `${extra.bedrooms} ch.` : "",
42+ extra.rating != null ? `★ ${extra.rating.toLocaleString("fr-CA", { maximumFractionDigits: 1 })}` : "",
43+ ].filter(Boolean).join(" · ");
44+ return (
45+ <div className="mv-pop">
46+ {p.thumbnailUrl ? (
47+ <img src={p.thumbnailUrl} alt="" loading="lazy" />
48+ ) : (
49+ <div className="mv-noimg" aria-hidden="true">⌂</div>
50+ )}
51+ <div className="mv-pop-body">
52+ <div className="mv-pop-price">
53+ {prix}
54+ <small>{p.price != null ? " /nuit" : ""}</small>
55+ </div>
56+ <div className="mv-pop-title">{extra.title ?? p.address ?? ""}</div>
57+ <div className="mv-pop-meta">{meta}</div>
58+ <div className="mv-pop-src">{ctSourceName(extra.source ?? "")}</div>
59+ <a
60+ className="mv-pop-cta"
61+ href={p.originalUrl}
62+ onClick={(e) => {
63+ e.preventDefault();
64+ navigate(p.originalUrl ?? "/court-terme");
65+ }}
66+ >
67+ Voir la fiche →
68+ </a>
69+ </div>
70+ </div>
71+ );
72+}
73+
74+export default function CtMapView({ filters }: { filters: CtFilters }) {
75+ const initialCamera = useMemo(() => {
76+ const cam = cameraFromParams(new URLSearchParams(window.location.search));
77+ return cam ?? { ...QUEBEC_CENTER, zoom: 6.2 };
78+ }, []);
79+
80+ const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {
81+ const url = new URL(window.location.href);
82+ url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();
83+ window.history.replaceState(null, "", url);
84+ }, []);
85+
86+ const mapFilters = useMemo(
87+ () => Object.fromEntries(Object.entries(filters).filter(([, v]) => v)),
88+ [filters],
89+ );
90+
91+ return (
92+ <div className="mapview" role="application" aria-label="Carte des hébergements court terme">
93+ <KaMapView
94+ theme={louKaMapTheme}
95+ adapter={ctMapAdapter}
96+ mapboxToken={MAPBOX_TOKEN}
97+ filters={mapFilters}
98+ center={initialCamera}
99+ zoom={initialCamera.zoom}
100+ pitch={45}
101+ basemap={{ theme: "default", showLandmarks: true }}
102+ cluster={{ maxZoom: 15, valueClamp: [50, 2000] }}
103+ searchMode="manual"
104+ onMoveEnd={onMoveEnd}
105+ >
106+ <KaBrandBadge />
107+ <Tilt3DControl />
108+ <SearchAreaControl />
109+ <LoadingIndicator label="Mise à jour des hébergements…" />
110+ <ResultCount
111+ emptyTitle="Aucun hébergement trouvé dans cette zone."
112+ emptyHint="Élargissez la carte ou modifiez vos filtres."
113+ />
114+ <PropertyPreview render={(p) => <PreviewCard p={p} />} />
115+ </KaMapView>
116+ </div>
117+ );
118+}
added frontend/src/ctapi.ts +127 −0
@@ -0,0 +1,127 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Location court terme (chalets, séjours, hébergements)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// ctapi.ts : types + client API /api/ct/* — sous-système séparé du long terme.
5+// -----------------------------------------------------------------------------
6+
7+export interface CtDetails {
8+ spa?: boolean;
9+ pool?: boolean;
10+ waterfront?: boolean;
11+ sauna?: boolean;
12+ wifi?: boolean;
13+ fireplace?: boolean;
14+ ev_charger?: boolean;
15+ [k: string]: unknown;
16+}
17+
18+export interface CtListing {
19+ uid: string;
20+ source: string;
21+ external_id: string;
22+ url: string;
23+ title: string;
24+ property_type: string;
25+ address: string;
26+ city: string;
27+ region: string;
28+ price_night: number | null;
29+ price_label: string;
30+ capacity: number | null;
31+ bedrooms: number | null;
32+ beds: number | null;
33+ bathrooms: number | null;
34+ pets: string | null; // "oui" | "non" | "conditions"
35+ citq: string | null; // numéro d'enregistrement CITQ
36+ rating: number | null; // sur 5
37+ reviews: number | null;
38+ description: string;
39+ amenities: string[];
40+ details: CtDetails;
41+ images: string[];
42+ lat: number | null;
43+ lng: number | null;
44+ first_seen?: number;
45+ last_seen?: number;
46+ active?: number;
47+}
48+
49+export interface CtFacets {
50+ regions: { region: string; n: number }[];
51+ types: { type: string; n: number }[];
52+ sources: { source: string; n: number }[];
53+}
54+
55+export interface CtStats {
56+ total: number;
57+ sources: number;
58+ regions: number;
59+ avg_night: number | null;
60+ geocoded: number;
61+}
62+
63+export interface CtSource {
64+ id: string;
65+ name: string;
66+ url: string;
67+ category: string;
68+ active_listings: number;
69+ last_sync: number | null;
70+}
71+
72+export interface CtFilters {
73+ region?: string;
74+ city?: string;
75+ type?: string;
76+ source?: string;
77+ price_min?: string;
78+ price_max?: string;
79+ capacity_min?: string;
80+ bedrooms_min?: string;
81+ pets?: string; // "oui"
82+ spa?: string; // "1"
83+ waterfront?: string; // "1"
84+ q?: string;
85+ sort?: string; // recent | prix | prix_desc | note
86+}
87+
88+const CT_SOURCE_NAMES: Record<string, string> = {};
89+export function registerCtSourceNames(sources: CtSource[]) {
90+ for (const s of sources) CT_SOURCE_NAMES[s.id] = s.name;
91+}
92+export function ctSourceName(id: string): string {
93+ return CT_SOURCE_NAMES[id] ?? id;
94+}
95+
96+async function get<T>(path: string): Promise<T> {
97+ const ctrl = new AbortController();
98+ const timer = setTimeout(() => ctrl.abort(), 20000);
99+ try {
100+ const res = await fetch(path, { signal: ctrl.signal });
101+ if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
102+ return (await res.json()) as T;
103+ } finally {
104+ clearTimeout(timer);
105+ }
106+}
107+
108+export function fetchCtListings(f: CtFilters, limit?: number, offset?: number) {
109+ const params = new URLSearchParams();
110+ for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
111+ if (limit != null) params.set("limit", String(limit));
112+ if (offset) params.set("offset", String(offset));
113+ return get<{ total: number; count: number; listings: CtListing[] }>(
114+ `/api/ct/listings?${params}`);
115+}
116+
117+export const fetchCtListing = (uid: string) =>
118+ get<CtListing>(`/api/ct/listings/${encodeURIComponent(uid)}`);
119+export const fetchCtFacets = () => get<CtFacets>("/api/ct/facets");
120+export const fetchCtStats = () => get<CtStats>("/api/ct/stats");
121+export const fetchCtSources = () =>
122+ get<{ sources: CtSource[] }>("/api/ct/sources");
123+
124+export const fmtNight = (p: number | null, label?: string) =>
125+ p != null
126+ ? `${p.toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`
127+ : label || "Prix sur demande";
added frontend/src/kamaps/ctAdapter.ts +91 −0
@@ -0,0 +1,91 @@
1+// -----------------------------------------------------------------------------
2+// Author: Simon-Pierre Boucher
3+// Contact: contact@spboucher.ai
4+// Project: Groupe Ka / Ka Maps (Lou-Ka — location court terme)
5+// kamaps/ctAdapter.ts : adaptateur /api/ct/listings.geojson → MapProperty.
6+// -----------------------------------------------------------------------------
7+import type {
8+ BoundsQuery,
9+ BoundsQueryResult,
10+ KaDataAdapter,
11+ MapProperty,
12+} from "@groupe-ka/ka-maps";
13+import { bboxToString } from "@groupe-ka/ka-maps";
14+import type { CtFilters } from "../ctapi";
15+
16+interface CtFeatureProps {
17+ uid: string;
18+ title: string | null;
19+ property_type: string | null;
20+ city: string | null;
21+ region: string | null;
22+ price_night: number | null;
23+ price_label: string | null;
24+ capacity: number | null;
25+ bedrooms: number | null;
26+ rating: number | null;
27+ source: string;
28+ image: string | null;
29+}
30+
31+interface CtFC {
32+ type: "FeatureCollection";
33+ features: {
34+ geometry: { coordinates: [number, number] };
35+ properties: CtFeatureProps;
36+ }[];
37+ totalGeocoded?: number;
38+}
39+
40+function toMapProperty(coords: [number, number], p: CtFeatureProps): MapProperty {
41+ return {
42+ id: p.uid,
43+ appSource: "lou-ka",
44+ longitude: coords[0],
45+ latitude: coords[1],
46+ kind: "listing",
47+ listingType: "rent",
48+ price: p.price_night ?? undefined,
49+ propertyType: p.property_type ?? undefined,
50+ address: p.title ?? undefined,
51+ city: p.city ?? undefined,
52+ region: p.region ?? undefined,
53+ thumbnailUrl: p.image ?? undefined,
54+ originalUrl: `/court-terme/${encodeURIComponent(p.uid)}`,
55+ extra: {
56+ title: p.title,
57+ priceLabel: p.price_label,
58+ capacity: p.capacity,
59+ bedrooms: p.bedrooms,
60+ rating: p.rating,
61+ source: p.source,
62+ },
63+ };
64+}
65+
66+/** Adaptateur viewport→données pour la carte court terme. */
67+export const ctMapAdapter: KaDataAdapter = {
68+ id: "lou-ka-ct-listings",
69+ appSource: "lou-ka",
70+ async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> {
71+ const params = new URLSearchParams();
72+ const filters = (query.filters ?? {}) as Partial<CtFilters>;
73+ for (const [k, v] of Object.entries(filters)) {
74+ if (v) params.set(k, String(v));
75+ }
76+ params.set("bbox", bboxToString(query.bbox));
77+ params.set("limit", "4000");
78+
79+ const res = await fetch(`/api/ct/listings.geojson?${params}`, {
80+ signal: query.signal,
81+ });
82+ if (!res.ok) throw new Error(`Carte : API ${res.status}`);
83+ const data = (await res.json()) as CtFC;
84+
85+ return {
86+ properties: data.features.map((f) =>
87+ toMapProperty(f.geometry.coordinates, f.properties),
88+ ),
89+ };
90+ },
91+};
added frontend/src/pages/CourtTerme.tsx +471 −0
@@ -0,0 +1,471 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Location court terme
3+// pages/CourtTerme.tsx : agrégateur d'hébergements court terme (chalets,
4+// séjours, hébergement insolite) — héro, filtres, grille + vue carte.
5+// Section séparée du Lou-Ka standard (logements au mois) : API /api/ct/*.
6+// -----------------------------------------------------------------------------
7+import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
8+import { useSearchParams } from "react-router-dom";
9+import {
10+ CtFacets, CtFilters, CtListing, CtStats,
11+ ctSourceName, fetchCtFacets, fetchCtListings, fetchCtSources, fetchCtStats,
12+ registerCtSourceNames,
13+} from "../ctapi";
14+import CtListingCard from "../components/CtListingCard";
15+import Pager from "../components/Pager";
16+import {
17+ IcoAlert, IcoHouse, IcoList, IcoMap, IcoPaw, IcoPin, IcoSearch, IcoSliders,
18+} from "../components/Icons";
19+
20+const CtMapView = lazy(() => import("../components/CtMapView"));
21+
22+const PAGE_SIZE = 12;
23+const NIGHT_STEPS = [75, 100, 150, 200, 250, 300, 400, 500, 750, 1000];
24+const CAPACITY_STEPS = [2, 4, 6, 8, 10, 12];
25+const SORTS: { key: string; label: string }[] = [
26+ { key: "recent", label: "Plus récents" },
27+ { key: "prix", label: "Prix croissant" },
28+ { key: "prix_desc", label: "Prix décroissant" },
29+ { key: "note", label: "Mieux notés" },
30+];
31+
32+function useDebounced<T>(value: T, delayMs = 300): T {
33+ const [debounced, setDebounced] = useState(value);
34+ useEffect(() => {
35+ const t = setTimeout(() => setDebounced(value), delayMs);
36+ return () => clearTimeout(t);
37+ }, [value, delayMs]);
38+ return debounced;
39+}
40+
41+export default function CourtTermePage() {
42+ const [listings, setListings] = useState<CtListing[] | null>(null);
43+ const [total, setTotal] = useState(0);
44+ const [facets, setFacets] = useState<CtFacets | null>(null);
45+ const [stats, setStats] = useState<CtStats | null>(null);
46+ const [error, setError] = useState<string | null>(null);
47+
48+ const [params] = useSearchParams();
49+ const [q, setQ] = useState(params.get("q") ?? "");
50+ const [region, setRegion] = useState(params.get("region") ?? "");
51+ const [type, setType] = useState(params.get("type") ?? "");
52+ const [source, setSource] = useState(params.get("source") ?? "");
53+ const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
54+ const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
55+ const [capMin, setCapMin] = useState(params.get("capacity_min") ?? "");
56+ const [pets, setPets] = useState(params.get("pets") ?? "");
57+ const [spa, setSpa] = useState(params.get("spa") ?? "");
58+ const [waterfront, setWaterfront] = useState(params.get("waterfront") ?? "");
59+ const [sort, setSort] = useState(params.get("sort") ?? "recent");
60+
61+ const [sheetOpen, setSheetOpen] = useState(false);
62+ const [advOpen, setAdvOpen] = useState(false);
63+ useEffect(() => {
64+ document.body.style.overflow = sheetOpen ? "hidden" : "";
65+ if (!sheetOpen) return () => { document.body.style.overflow = ""; };
66+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };
67+ window.addEventListener("keydown", onKey);
68+ return () => {
69+ document.body.style.overflow = "";
70+ window.removeEventListener("keydown", onKey);
71+ };
72+ }, [sheetOpen]);
73+
74+ const activeFilters = [q, region, type, source, priceMin, priceMax, capMin,
75+ pets, spa, waterfront].filter(Boolean).length;
76+ const advCount = [capMin, pets, source].filter(Boolean).length;
77+
78+ const [view, setView] = useState<"liste" | "carte">(
79+ params.get("view") === "carte" ? "carte" : "liste");
80+ useEffect(() => {
81+ setView(params.get("view") === "carte" ? "carte" : "liste");
82+ }, [params]);
83+
84+ const qDebounced = useDebounced(q);
85+ const filters: CtFilters = useMemo(() => ({
86+ q: qDebounced, region, type, source,
87+ price_min: priceMin, price_max: priceMax,
88+ capacity_min: capMin, pets, spa, waterfront, sort,
89+ }), [qDebounced, region, type, source, priceMin, priceMax, capMin, pets,
90+ spa, waterfront, sort]);
91+
92+ useEffect(() => {
93+ fetchCtSources().then((r) => registerCtSourceNames(r.sources)).catch(() => {});
94+ fetchCtFacets().then(setFacets).catch(() => {});
95+ fetchCtStats().then(setStats).catch(() => {});
96+ }, []);
97+
98+ // pagination
99+ const [page, setPage] = useState(() => {
100+ const p = Number(params.get("page"));
101+ return Number.isFinite(p) && p >= 1 ? Math.floor(p) : 1;
102+ });
103+ const resultsRef = useRef<HTMLDivElement | null>(null);
104+ const filtersKey = useMemo(() => JSON.stringify(filters), [filters]);
105+ const firstRun = useRef(true);
106+ useEffect(() => {
107+ if (firstRun.current) { firstRun.current = false; return; }
108+ setPage(1);
109+ }, [filtersKey]);
110+
111+ const goToPage = (p: number) => {
112+ setPage(p);
113+ resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
114+ };
115+
116+ useEffect(() => {
117+ if (view === "carte") return;
118+ let cancelled = false;
119+ setListings(null);
120+ setError(null);
121+ fetchCtListings(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE)
122+ .then((r) => {
123+ if (!cancelled) {
124+ setListings(r.listings);
125+ setTotal(r.total);
126+ const last = Math.max(1, Math.ceil(r.total / PAGE_SIZE));
127+ if (page > last) setPage(last);
128+ }
129+ })
130+ .catch((e) => !cancelled && setError(String(e)));
131+ return () => { cancelled = true; };
132+ }, [filters, page, view]);
133+
134+ const resetAll = () => {
135+ setQ(""); setRegion(""); setType(""); setSource("");
136+ setPriceMin(""); setPriceMax(""); setCapMin("");
137+ setPets(""); setSpa(""); setWaterfront(""); setSort("recent");
138+ };
139+
140+ const pills: { label: string; clear: () => void }[] = [];
141+ if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });
142+ if (region) pills.push({ label: region, clear: () => setRegion("") });
143+ if (type) pills.push({ label: type, clear: () => setType("") });
144+ if (priceMin) pills.push({ label: `≥ ${priceMin} $/nuit`, clear: () => setPriceMin("") });
145+ if (priceMax) pills.push({ label: `≤ ${priceMax} $/nuit`, clear: () => setPriceMax("") });
146+ if (capMin) pills.push({ label: `${capMin}+ personnes`, clear: () => setCapMin("") });
147+ if (pets) pills.push({ label: "Animaux acceptés", clear: () => setPets("") });
148+ if (spa) pills.push({ label: "Spa", clear: () => setSpa("") });
149+ if (waterfront) pills.push({ label: "Bord de l'eau", clear: () => setWaterfront("") });
150+ if (source) pills.push({ label: ctSourceName(source), clear: () => setSource("") });
151+
152+ return (
153+ <div className="container">
154+ <section className="hero">
155+ <span className="kicker">Chalets, séjours et hébergements — partout au Québec.</span>
156+ <h1>
157+ Trouvez votre prochaine <span className="hl">escapade.</span>
158+ </h1>
159+ <p className="lede">
160+ Chalets, condos et hébergements insolites en location court terme,
161+ regroupés depuis les plateformes québécoises et internationales — avec
162+ un lien direct vers l'annonce originale pour réserver.
163+ </p>
164+ <div className="benefits" aria-label="Avantages">
165+ <div className="benefit">
166+ <span className="benefit-ico"><IcoHouse size={20} /></span>
167+ <span className="benefit-label">Chalets et séjours</span>
168+ </div>
169+ <div className="benefit">
170+ <span className="benefit-ico"><IcoSearch size={20} /></span>
171+ <span className="benefit-label">Toutes les plateformes</span>
172+ </div>
173+ <div className="benefit">
174+ <span className="benefit-ico blue"><IcoPin size={20} /></span>
175+ <span className="benefit-label">Toutes les régions</span>
176+ </div>
177+ </div>
178+ <div className="stat-row">
179+ <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>
180+ {stats && stats.total > 0 && (
181+ <>
182+ <span className="stat-chip"><b>{stats.total}</b> hébergements</span>
183+ <span className="stat-chip"><b>{stats.regions}</b> régions</span>
184+ <span className="stat-chip"><b>{stats.sources}</b> sources</span>
185+ {stats.avg_night != null && (
186+ <span className="stat-chip">
187+ prix moyen <b>{Math.round(stats.avg_night)} $ / nuit</b>
188+ </span>
189+ )}
190+ </>
191+ )}
192+ </div>
193+ </section>
194+
195+ {sheetOpen && (
196+ <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />
197+ )}
198+ <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">
199+ <div className="sheet-head">
200+ <span>Filtres</span>
201+ <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">
202+ ✕
203+ </button>
204+ </div>
205+
206+ <div className="f-primary">
207+ <div className="f-search">
208+ <IcoSearch size={15} strokeWidth={2.4} />
209+ <input
210+ id="ctf-q" placeholder="Ville, région ou mot-clé" value={q}
211+ onChange={(e) => setQ(e.target.value)}
212+ aria-label="Recherche"
213+ />
214+ {q && (
215+ <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>
216+ )}
217+ </div>
218+ <label className="f-ctl">
219+ <span>Région</span>
220+ <select value={region} onChange={(e) => setRegion(e.target.value)}>
221+ <option value="">Toutes</option>
222+ {(facets?.regions ?? []).map((r) => (
223+ <option key={r.region} value={r.region}>{r.region} ({r.n})</option>
224+ ))}
225+ </select>
226+ </label>
227+ <label className="f-ctl">
228+ <span>Type</span>
229+ <select value={type} onChange={(e) => setType(e.target.value)}>
230+ <option value="">Tous</option>
231+ {(facets?.types ?? []).map((t) => (
232+ <option key={t.type} value={t.type}>{t.type} ({t.n})</option>
233+ ))}
234+ </select>
235+ </label>
236+ <div className="f-ctl">
237+ <span>Prix / nuit</span>
238+ <div className="range-pair">
239+ <select aria-label="Prix minimum par nuit" value={priceMin}
240+ onChange={(e) => setPriceMin(e.target.value)}>
241+ <option value="">Min</option>
242+ {NIGHT_STEPS.map((p) => (
243+ <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>
244+ {p} $
245+ </option>
246+ ))}
247+ </select>
248+ <span className="range-sep">—</span>
249+ <select aria-label="Prix maximum par nuit" value={priceMax}
250+ onChange={(e) => setPriceMax(e.target.value)}>
251+ <option value="">Max</option>
252+ {NIGHT_STEPS.map((p) => (
253+ <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>
254+ {p} $
255+ </option>
256+ ))}
257+ </select>
258+ </div>
259+ </div>
260+ <button
261+ className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`}
262+ onClick={() => setAdvOpen(!advOpen)}
263+ aria-expanded={advOpen}
264+ >
265+ Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}
266+ </button>
267+ </div>
268+
269+ {(advOpen || sheetOpen) && (
270+ <div className="f-adv">
271+ <div className="f-group">
272+ <label>Capacité minimale</label>
273+ <div className="seg" role="group">
274+ <button className={capMin === "" ? "on" : ""} onClick={() => setCapMin("")}>
275+ Peu importe
276+ </button>
277+ {CAPACITY_STEPS.map((c) => (
278+ <button key={c} className={capMin === String(c) ? "on" : ""}
279+ onClick={() => setCapMin(String(c))}>
280+ {c}+
281+ </button>
282+ ))}
283+ </div>
284+ </div>
285+ <div className="f-group">
286+ <label>Animaux</label>
287+ <div className="seg" role="group">
288+ {[["", "Peu importe"], ["oui", "Acceptés"]].map(([v, l]) => (
289+ <button key={v} className={pets === v ? "on" : ""}
290+ onClick={() => setPets(v)}>
291+ {v === "oui" && <IcoPaw size={13} />} {l}
292+ </button>
293+ ))}
294+ </div>
295+ </div>
296+ <div className="f-group">
297+ <label>Tri</label>
298+ <div className="seg" role="group">
299+ {SORTS.map((s) => (
300+ <button key={s.key} className={sort === s.key ? "on" : ""}
301+ onClick={() => setSort(s.key)}>
302+ {s.label}
303+ </button>
304+ ))}
305+ </div>
306+ </div>
307+ <div className="f-group">
308+ <label>Plateforme</label>
309+ <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
310+ <option value="">Toutes</option>
311+ {(facets?.sources ?? []).map((s) => (
312+ <option key={s.source} value={s.source}>
313+ {ctSourceName(s.source)} ({s.n})
314+ </option>
315+ ))}
316+ </select>
317+ </div>
318+ <div className="f-group f-group-end">
319+ <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>
320+ Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}
321+ </button>
322+ </div>
323+ </div>
324+ )}
325+
326+ <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
327+ Voir les résultats {listings ? `(${total})` : ""}
328+ </button>
329+ </section>
330+
331+ <div className="chips" role="group" aria-label="Filtres rapides">
332+ <button
333+ className={`chip chip-ico ${spa === "1" ? "on" : ""}`}
334+ onClick={() => setSpa(spa === "1" ? "" : "1")}
335+ >
336+ ♨ Spa
337+ </button>
338+ <button
339+ className={`chip chip-ico ${waterfront === "1" ? "on" : ""}`}
340+ onClick={() => setWaterfront(waterfront === "1" ? "" : "1")}
341+ >
342+ ≈ Bord de l'eau
343+ </button>
344+ <button
345+ className={`chip chip-ico ${pets === "oui" ? "on" : ""}`}
346+ onClick={() => setPets(pets === "oui" ? "" : "oui")}
347+ >
348+ <IcoPaw size={13} /> Animaux ok
349+ </button>
350+ <span className="chip-sep" aria-hidden="true" />
351+ {(facets?.types ?? []).slice(0, 5).map((t) => (
352+ <button
353+ key={t.type}
354+ className={`chip ${type === t.type ? "on" : ""}`}
355+ onClick={() => setType(type === t.type ? "" : t.type)}
356+ >
357+ {t.type}
358+ </button>
359+ ))}
360+ </div>
361+
362+ {pills.length > 0 && (
363+ <div className="pills" aria-label="Filtres actifs">
364+ {pills.map((p) => (
365+ <button key={p.label} className="pill" onClick={p.clear}
366+ aria-label={`Retirer le filtre ${p.label}`}>
367+ {p.label} <span className="pill-x">✕</span>
368+ </button>
369+ ))}
370+ <button className="pill pill-clear" onClick={resetAll}>
371+ Tout effacer
372+ </button>
373+ </div>
374+ )}
375+
376+ <div className="results-head" ref={resultsRef}>
377+ <h2>Hébergements disponibles</h2>
378+ <div className="results-tools">
379+ {view === "liste" && listings && (
380+ <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span>
381+ )}
382+ <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">
383+ <button
384+ role="tab" aria-selected={view === "liste"}
385+ className={view === "liste" ? "on" : ""}
386+ onClick={() => setView("liste")}
387+ >
388+ <IcoList size={13} /> Liste
389+ </button>
390+ <button
391+ role="tab" aria-selected={view === "carte"}
392+ className={view === "carte" ? "on" : ""}
393+ onClick={() => setView("carte")}
394+ >
395+ <IcoMap size={13} /> Carte
396+ </button>
397+ </div>
398+ </div>
399+ </div>
400+
401+ {error && (
402+ <div className="notice">
403+ <div className="big"><IcoAlert size={40} /></div>
404+ <h2>Impossible de charger les hébergements</h2>
405+ <p>{error}</p>
406+ <button className="btn btn-primary" onClick={() => window.location.reload()}>
407+ Réessayer
408+ </button>
409+ </div>
410+ )}
411+
412+ {!error && view === "liste" && listings === null && (
413+ <div className="grid" aria-busy="true">
414+ {Array.from({ length: 8 }).map((_, i) => (
415+ <div className="skel" key={i}>
416+ <div className="sk-img" />
417+ <div className="sk-line" />
418+ <div className="sk-line short" />
419+ </div>
420+ ))}
421+ </div>
422+ )}
423+
424+ {!error && view === "liste" && listings !== null && listings.length === 0 && (
425+ <div className="notice">
426+ <div className="big"><IcoSearch size={40} /></div>
427+ <h2>Aucun hébergement ne correspond</h2>
428+ <p>
429+ {stats && stats.total === 0
430+ ? "La section court terme démarre : les premières synchronisations sont en cours."
431+ : "Essayez d'élargir vos critères"}
432+ {activeFilters > 0 && (
433+ <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>
434+ )}.
435+ </p>
436+ </div>
437+ )}
438+
439+ {!error && view === "carte" && (
440+ <Suspense
441+ fallback={
442+ <div className="map-split">
443+ <div className="mapview map-loading">Chargement de la carte…</div>
444+ </div>
445+ }
446+ >
447+ <CtMapView filters={filters} />
448+ </Suspense>
449+ )}
450+
451+ {!error && view === "liste" && listings !== null && listings.length > 0 && (
452+ <>
453+ <div className="grid">
454+ {listings.map((l) => (
455+ <CtListingCard key={l.uid} l={l} />
456+ ))}
457+ </div>
458+ <Pager page={page} pageSize={PAGE_SIZE} total={total} onPage={goToPage} />
459+ </>
460+ )}
461+
462+ <button
463+ className={`fab${view === "carte" ? " fab-carte" : ""}`}
464+ onClick={() => setSheetOpen(true)}
465+ aria-label="Ouvrir les filtres"
466+ >
467+ <IcoSliders size={15} /> Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
468+ </button>
469+ </div>
470+ );
471+}
added frontend/src/pages/CourtTermeFiche.tsx +252 −0
@@ -0,0 +1,252 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Location court terme
3+// pages/CourtTermeFiche.tsx : fiche d'un hébergement court terme.
4+// Ordre DOM = ordre visuel, identique mobile ET desktop (standard Groupe Ka
5+// « Ordre des sections — pages détail ») : galerie → prix + titre + chips +
6+// CTA → description → commodités → détails pratiques → carte.
7+// -----------------------------------------------------------------------------
8+import { lazy, Suspense, useEffect, useRef, useState } from "react";
9+import { Link, useParams } from "react-router-dom";
10+import {
11+ CtListing, ctSourceName, fetchCtListing, fetchCtSources, fmtNight,
12+ registerCtSourceNames,
13+} from "../ctapi";
14+import SmartImg from "../components/SmartImg";
15+import { IcoAlert } from "../components/Icons";
16+
17+const CtFicheMap = lazy(() => import("../components/CtFicheMap"));
18+
19+const NBSP = " ";
20+
21+const PETS_LABEL: Record<string, string> = {
22+ oui: "Animaux acceptés", non: "Animaux refusés", conditions: "Animaux sous conditions",
23+};
24+
25+const DETAIL_LABELS: Record<string, string> = {
26+ spa: "Spa", pool: "Piscine", waterfront: "Bord de l'eau", sauna: "Sauna",
27+ wifi: "Wi-Fi", fireplace: "Foyer", ev_charger: "Borne de recharge",
28+};
29+
30+function Galerie({ images, titre, typeLabel }:
31+ { images: string[]; titre: string; typeLabel?: string }) {
32+ const [idx, setIdx] = useState(0);
33+ const track = useRef<HTMLDivElement>(null);
34+
35+ const onScroll = () => {
36+ const el = track.current;
37+ if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
38+ };
39+ const goto = (i: number) =>
40+ track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
41+
42+ if (images.length === 0)
43+ return (
44+ <div className="carousel">
45+ <SmartImg src={null} fallbackLabel={typeLabel}
46+ alt="Aucune photo fournie par la source" />
47+ </div>
48+ );
49+
50+ return (
51+ <>
52+ <div className="carousel">
53+ <div className="carousel-track" ref={track} onScroll={onScroll}>
54+ {images.map((u, i) => (
55+ <SmartImg
56+ key={u} src={u} width_={800} fallbackLabel={typeLabel}
57+ loading={i === 0 ? "eager" : "lazy"} decoding="async"
58+ alt={`${titre} — photo ${i + 1} de ${images.length}`}
59+ />
60+ ))}
61+ </div>
62+ <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>
63+ {idx > 0 && (
64+ <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>
65+ )}
66+ {idx < images.length - 1 && (
67+ <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>
68+ )}
69+ </div>
70+ {images.length > 1 && (
71+ <div className="thumbs">
72+ {images.map((u, i) => (
73+ <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}
74+ aria-label={`Photo ${i + 1}`}>
75+ <SmartImg src={u} width_={160} alt="" loading="lazy" decoding="async" />
76+ </button>
77+ ))}
78+ </div>
79+ )}
80+ </>
81+ );
82+}
83+
84+export default function CourtTermeFichePage() {
85+ const { uid } = useParams<{ uid: string }>();
86+ const [l, setL] = useState<CtListing | null>(null);
87+ const [error, setError] = useState<string | null>(null);
88+
89+ useEffect(() => {
90+ fetchCtSources().then((r) => registerCtSourceNames(r.sources)).catch(() => {});
91+ if (!uid) return;
92+ fetchCtListing(uid).then(setL).catch((e) => setError(String(e)));
93+ window.scrollTo(0, 0);
94+ }, [uid]);
95+
96+ if (error)
97+ return (
98+ <div className="notice container">
99+ <div className="big"><IcoAlert size={40} /></div>
100+ <h2>Hébergement introuvable</h2>
101+ <p>{error}</p>
102+ <Link className="btn btn-primary" to="/court-terme">Retour au court terme</Link>
103+ </div>
104+ );
105+
106+ if (!l)
107+ return (
108+ <div className="container detail">
109+ <div className="fiche" aria-busy="true">
110+ <div className="skel"><div className="sk-img" /></div>
111+ <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
112+ </div>
113+ </div>
114+ );
115+
116+ const chips: string[] = [];
117+ if (l.property_type) chips.push(l.property_type);
118+ if (l.capacity) chips.push(`${l.capacity} personnes`);
119+ if (l.bedrooms) chips.push(`${l.bedrooms} chambre${l.bedrooms > 1 ? "s" : ""}`);
120+ if (l.beds) chips.push(`${l.beds} lit${l.beds > 1 ? "s" : ""}`);
121+ if (l.bathrooms) chips.push(`${l.bathrooms} salle${l.bathrooms > 1 ? "s" : ""} de bain`);
122+ if (l.pets) chips.push(PETS_LABEL[l.pets] ?? l.pets);
123+ if (l.citq) chips.push(`CITQ ${l.citq}`);
124+
125+ const flags = Object.entries(DETAIL_LABELS)
126+ .filter(([k]) => l.details?.[k] === true || l.details?.[k] === 1)
127+ .map(([, label]) => label);
128+ const autres = l.amenities.filter(
129+ (a) => !flags.some((b) => b.toLowerCase() === a.toLowerCase()));
130+
131+ const synced = l.last_seen
132+ ? new Date(l.last_seen * 1000).toLocaleDateString("fr-CA", {
133+ day: "numeric", month: "long", year: "numeric" }) : null;
134+
135+ return (
136+ <div className="container detail">
137+ <nav className="crumbs" aria-label="Fil d'Ariane">
138+ <Link to="/court-terme">Court terme</Link> ›
139+ {l.region && <span>{l.region}</span>} ›
140+ <span>{l.title}</span>
141+ </nav>
142+
143+ <div className="fiche">
144+ <div className="f-col">
145+ <section className="f-bloc f-galerie" aria-label="Photos">
146+ <Galerie images={l.images ?? []} titre={l.title}
147+ typeLabel={l.property_type || "Séjour"} />
148+ </section>
149+
150+ <section className="f-bloc f-hero">
151+ <div className="price">
152+ {fmtNight(l.price_night, l.price_label)}
153+ {l.price_night != null && <small> /{NBSP}nuit</small>}
154+ </div>
155+ {l.rating != null && (
156+ <div className="deal-badge deal-ok">
157+ ★ {l.rating.toLocaleString("fr-CA", { maximumFractionDigits: 1 })} / 5
158+ {l.reviews ? ` · ${l.reviews} avis` : ""}
159+ </div>
160+ )}
161+ <h1>{l.title}</h1>
162+ <div className="loc">
163+ {[l.address, l.city, l.region].filter(Boolean).join(" · ")}
164+ </div>
165+ <div className="chips-scroll" role="list" aria-label="Caractéristiques clés">
166+ {chips.map((c) => <span className="chip-key" role="listitem" key={c}>{c}</span>)}
167+ </div>
168+ <a className="cta cta-desktop" href={l.url}
169+ target="_blank" rel="noopener noreferrer">
170+ Réserver chez {ctSourceName(l.source)} ↗
171+ </a>
172+ </section>
173+
174+ <section className="f-bloc f-desc" id="description">
175+ <h2>Description</h2>
176+ {l.description
177+ ? <p style={{ color: "var(--ink-2)", whiteSpace: "pre-line" }}>{l.description}</p>
178+ : <p className="fine">La source ne fournit pas de description pour cet hébergement.</p>}
179+ </section>
180+
181+ <section className="f-bloc f-incl" id="commodites">
182+ <h2>Commodités</h2>
183+ <div className="amenity-row">
184+ {flags.map((b) => (
185+ <span className="amenity confirmed" key={`c-${b}`}>✓ {b}</span>
186+ ))}
187+ {autres.map((a) => (
188+ <span className="amenity unconfirmed" key={a}>{a}</span>
189+ ))}
190+ </div>
191+ {flags.length === 0 && autres.length === 0 && (
192+ <p className="fine">La source ne précise pas les commodités.</p>
193+ )}
194+ </section>
195+
196+ <section className="f-bloc f-pratique">
197+ <h2>Détails pratiques</h2>
198+ <div className="kv">
199+ <div className="cell"><div className="k">Plateforme</div><div className="v">{ctSourceName(l.source)}</div></div>
200+ {l.price_label && (
201+ <div className="cell"><div className="k">Prix affiché</div><div className="v">{l.price_label}</div></div>
202+ )}
203+ {l.citq && (
204+ <div className="cell"><div className="k">Enregistrement CITQ</div><div className="v">{l.citq}</div></div>
205+ )}
206+ {synced && (
207+ <div className="cell"><div className="k">Synchronisé</div><div className="v">{synced}</div></div>
208+ )}
209+ </div>
210+ {!l.citq && (
211+ <p className="fine">
212+ Au Québec, tout séjour de 31 nuits ou moins doit être offert par un
213+ établissement détenant un numéro d'enregistrement CITQ. Vérifiez le
214+ numéro affiché sur l'annonce originale avant de réserver.
215+ </p>
216+ )}
217+ </section>
218+ </div>
219+
220+ <div className="f-col">
221+ {l.lat != null && l.lng != null && (
222+ <section className="f-bloc f-carte" id="emplacement">
223+ <h2>Emplacement</h2>
224+ <Suspense fallback={<div className="lmap3d lmap3d-skel" aria-busy="true" />}>
225+ <CtFicheMap l={l} />
226+ </Suspense>
227+ <p className="fine">
228+ Position approximative selon les coordonnées fournies par la source.
229+ </p>
230+ </section>
231+ )}
232+ </div>
233+ </div>
234+
235+ <div className="fine f-foot">
236+ {synced && <>Dernière synchronisation : {synced}. </>}
237+ Les prix et disponibilités sont ceux affichés par la source — chaque fiche
238+ renvoie à l'annonce originale pour réserver.
239+ </div>
240+
241+ <div className="cta-sticky">
242+ <span className="cta-sticky-prix">
243+ {fmtNight(l.price_night, l.price_label)}
244+ {l.price_night != null && <small>/nuit</small>}
245+ </span>
246+ <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
247+ Réserver chez {ctSourceName(l.source)} ↗
248+ </a>
249+ </div>
250+ </div>
251+ );
252+}
added louka/shortterm/__init__.py +8 −0
@@ -0,0 +1,8 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Section « Location court terme » (chalets, séjours, hébergement)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# Sous-système ISOLÉ du pipeline standard Lou-Ka (logements au mois) :
5+# base de données séparée (data/louka_ct.db), connecteurs séparés
6+# (louka/shortterm/connectors/), API séparée (/api/ct/*), page séparée
7+# (/court-terme). Aucun croisement avec les tables/règles du long terme.
8+# -----------------------------------------------------------------------------
added louka/shortterm/connectors/__init__.py +30 −0
@@ -0,0 +1,30 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Location court terme
3+# connectors/__init__.py : registre AUTO-DÉCOUVRANT des connecteurs court terme
4+# (même mécanique que louka/connectors, mais registre totalement séparé)
5+# -----------------------------------------------------------------------------
6+from __future__ import annotations
7+
8+import importlib
9+import pkgutil
10+import sys
11+
12+from .base import StConnector
13+
14+ST_CONNECTORS: dict[str, type[StConnector]] = {}
15+
16+for _mod in pkgutil.iter_modules(__path__):
17+ if _mod.name in ("base", "__init__"):
18+ continue
19+ try:
20+ module = importlib.import_module(f"{__name__}.{_mod.name}")
21+ except Exception as exc: # un connecteur cassé ne bloque pas les autres
22+ print(f"[lou-ka/ct] connecteur '{_mod.name}' ignoré : {exc}",
23+ file=sys.stderr)
24+ continue
25+ for obj in vars(module).values():
26+ if (isinstance(obj, type) and issubclass(obj, StConnector)
27+ and obj is not StConnector and getattr(obj, "source_id", "")):
28+ if getattr(obj, "disabled", False):
29+ continue
30+ ST_CONNECTORS[obj.source_id] = obj
added louka/shortterm/connectors/airbnb.py +340 −0
@@ -0,0 +1,340 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Location court terme
3+# connectors/airbnb.py : Airbnb (airbnb.ca) — source vedette, toute la province.
4+#
5+# Méthode : les pages de recherche https://www.airbnb.ca/s/<Lieu>--Québec--
6+# Canada/homes embarquent un JSON complet dans <script id="data-deferred-
7+# state-0"> (niobeClientData → presentation.staysSearch.results) avec, pour
8+# chaque carte : id (base64 "DemandStayListing:<room id>"), nom, coordonnées,
9+# note/avis, chambres/lits/sdb, photos et prix détaillé ("N nights x $X CAD").
10+# On récupère ce HTML via Bright Data Web Unlocker (rapide, pas de rendu JS
11+# nécessaire), avec Scrapfly ASP en secours. Couverture provinciale par ~25
12+# recherches (une par ville/région touristique), 2 pages chacune par défaut
13+# (Airbnb plafonne à ~270 résultats / 15 pages par recherche ; le connecteur
14+# est incrémental, chaque sync rafraîchit). Pagination par les cursors
15+# fournis dans paginationInfo.pageCursors. Dédoublonnage par room id.
16+#
17+# Réglages env : LOUKA_AIRBNB_PAGES (pages par recherche, défaut 2),
18+# LOUKA_AIRBNB_LIMIT (nb de recherches, 0 = toutes — debug).
19+# -----------------------------------------------------------------------------
20+from __future__ import annotations
21+
22+import base64
23+import json
24+import os
25+import re
26+import sys
27+import time
28+import urllib.parse
29+
30+import requests
31+
32+from ..schema import StListing
33+from .base import StConnector
34+
35+BRIGHTDATA_API = "https://api.brightdata.com/request"
36+
37+# (terme de recherche géocodé par Airbnb, région touristique QC de rattachement)
38+SEARCHES: list[tuple[str, str]] = [
39+ ("Montréal", "Montréal"),
40+ ("Laval", "Laval"),
41+ ("Longueuil", "Montérégie"),
42+ ("Ville de Québec", "Québec"),
43+ ("Lévis", "Chaudière-Appalaches"),
44+ ("Mont-Tremblant", "Laurentides"),
45+ ("Saint-Sauveur", "Laurentides"),
46+ ("Sainte-Agathe-des-Monts", "Laurentides"),
47+ ("Saint-Donat", "Lanaudière"),
48+ ("Rawdon", "Lanaudière"),
49+ ("Magog", "Cantons-de-l'Est"),
50+ ("Sutton", "Cantons-de-l'Est"),
51+ ("Bromont", "Cantons-de-l'Est"),
52+ ("Baie-Saint-Paul", "Charlevoix"),
53+ ("La Malbaie", "Charlevoix"),
54+ ("Shawinigan", "Mauricie"),
55+ ("Trois-Rivières", "Mauricie"),
56+ ("Gatineau", "Outaouais"),
57+ ("Gaspé", "Gaspésie"),
58+ ("Percé", "Gaspésie"),
59+ ("Rimouski", "Bas-Saint-Laurent"),
60+ ("Saguenay", "Saguenay–Lac-Saint-Jean"),
61+ ("Alma", "Saguenay–Lac-Saint-Jean"),
62+ ("Îles-de-la-Madeleine", "Îles-de-la-Madeleine"),
63+ ("Rouyn-Noranda", "Abitibi-Témiscamingue"),
64+ ("Tadoussac", "Côte-Nord"),
65+ ("Drummondville", "Centre-du-Québec"),
66+]
67+
68+# Type de carte Airbnb ("Home in X", "Chalet in X"…) → type canonique Lou-Ka.
69+# Clés en minuscules, les plus longues testées d'abord (startswith).
70+TYPE_MAP = {
71+ "tiny home": "Mini-maison", "mini-maison": "Mini-maison",
72+ "bed and breakfast": "Gîte", "bed & breakfast": "Gîte",
73+ "boutique hotel": "Auberge", "hostel": "Auberge", "aparthotel": "Appartement",
74+ "hotel room": "Chambre", "private room": "Chambre", "shared room": "Chambre",
75+ "guest suite": "Appartement", "serviced apartment": "Appartement",
76+ "rental unit": "Appartement", "apartment": "Appartement",
77+ "appartement": "Appartement", "logement": "Appartement",
78+ "vacation home": "Maison", "guesthouse": "Maison", "farm stay": "Maison",
79+ "townhouse": "Maison", "bungalow": "Maison", "villa": "Maison",
80+ "home": "Maison", "house": "Maison", "maison": "Maison",
81+ "chalet": "Chalet", "cabin": "Chalet", "cottage": "Chalet", "cabane": "Chalet",
82+ "condo": "Condo", "loft": "Loft", "studio": "Studio",
83+ "dome": "Dôme", "dôme": "Dôme", "yurt": "Yourte", "yourte": "Yourte",
84+ "camper/rv": "Prêt-à-camper", "campsite": "Camping", "tent": "Camping",
85+ "hut": "Refuge", "room": "Chambre", "chambre": "Chambre",
86+ "hotel": "Auberge", "auberge": "Auberge",
87+}
88+_TYPE_KEYS = sorted(TYPE_MAP, key=len, reverse=True)
89+
90+_RATING_RE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*\((\d[\d\s,]*)\)")
91+_NIGHTLY_RE = re.compile(r"(\d+)\s*nights?\s*x\s*\$\s*([\d,]+(?:\.\d+)?)", re.I)
92+_MONEY_RE = re.compile(r"\$\s*([\d,]+(?:\.\d+)?)")
93+_NUM_RE = re.compile(r"(\d+(?:\.\d+)?)")
94+
95+
96+def _b64_room_id(demand_id: str) -> str:
97+ """"RGVtYW5kU3RheUxpc3Rpbmc6MTIz" → "123" (DemandStayListing:<id>)."""
98+ try:
99+ raw = base64.b64decode(demand_id + "=" * (-len(demand_id) % 4)).decode()
100+ return raw.split(":")[-1].strip()
101+ except Exception: # noqa: BLE001
102+ return ""
103+
104+
105+def _card_type_and_city(card_title: str) -> tuple[str, str]:
106+ """"Home in Les Laurentides" → ("Maison", "Les Laurentides")."""
107+ if not card_title:
108+ return "", ""
109+ kind, city = card_title, ""
110+ for sep in (" in ", " à ", " a ", " : "):
111+ if sep in card_title:
112+ kind, city = card_title.split(sep, 1)
113+ break
114+ kl = kind.strip().lower()
115+ for key in _TYPE_KEYS:
116+ if kl.startswith(key):
117+ return TYPE_MAP[key], city.strip()
118+ return ("Autre" if kind != card_title or kl else ""), city.strip()
119+
120+
121+class Airbnb(StConnector):
122+ source_id = "airbnb"
123+ request_delay = 0.4
124+
125+ # -- fetch HTML -----------------------------------------------------------
126+ def _brightdata(self, url: str) -> str:
127+ key = os.environ.get("BRIGHTDATA_API_KEY")
128+ if not key:
129+ return ""
130+ wait = self.request_delay - (time.time() - self._last_request)
131+ if wait > 0:
132+ time.sleep(wait)
133+ try:
134+ resp = requests.post(
135+ BRIGHTDATA_API,
136+ headers={"Authorization": f"Bearer {key}",
137+ "Content-Type": "application/json"},
138+ json={"zone": os.environ.get("BRIGHTDATA_ZONE", "web_unlocker1"),
139+ "url": url, "format": "raw"},
140+ timeout=150)
141+ except requests.RequestException:
142+ return ""
143+ finally:
144+ self._last_request = time.time()
145+ return resp.text if resp.status_code == 200 else ""
146+
147+ def _search_html(self, url: str) -> str:
148+ """Bright Data d'abord (pas de rendu JS requis), Scrapfly ASP sinon."""
149+ html = self._brightdata(url)
150+ if "data-deferred-state" in html:
151+ return html
152+ html = self.get_scrapfly(url, render_js=False, asp=True)
153+ if "data-deferred-state" in html:
154+ return html
155+ return self.get_scrapfly(url, render_js=True, asp=True,
156+ rendering_wait=3000)
157+
158+ # -- parse JSON embarqué ----------------------------------------------------
159+ @staticmethod
160+ def _deferred_results(html: str) -> tuple[list[dict], list[str]]:
161+ """(searchResults, pageCursors) depuis les <script data-deferred-state-*>."""
162+ for blob in re.findall(
163+ r'<script[^>]+id="data-deferred-state[^"]*"[^>]*>(.*?)</script>',
164+ html, re.S):
165+ try:
166+ data = json.loads(blob)
167+ except ValueError:
168+ continue
169+ found: list = []
170+
171+ def walk(o):
172+ if found:
173+ return
174+ if isinstance(o, dict):
175+ res = ((o.get("staysSearch") or {}).get("results")
176+ if isinstance(o.get("staysSearch"), dict) else None)
177+ if isinstance(res, dict) and isinstance(
178+ res.get("searchResults"), list):
179+ found.append(res)
180+ return
181+ for v in o.values():
182+ walk(v)
183+ elif isinstance(o, list):
184+ for v in o:
185+ walk(v)
186+
187+ walk(data)
188+ if found:
189+ res = found[0]
190+ cursors = (res.get("paginationInfo") or {}).get("pageCursors") or []
191+ return res["searchResults"], list(cursors)
192+ return [], []
193+
194+ # -- une carte → StListing --------------------------------------------------
195+ def _to_listing(self, r: dict, region: str) -> StListing | None:
196+ demand = r.get("demandStayListing") or {}
197+ room_id = _b64_room_id(demand.get("id") or "")
198+ if not room_id or not room_id.isdigit():
199+ return None
200+ name = (((demand.get("description") or {}).get("name") or {})
201+ .get("localizedStringWithTranslationPreference")
202+ or ((r.get("nameLocalized") or {})
203+ .get("localizedStringWithTranslationPreference"))
204+ or r.get("subtitle") or r.get("title") or "").strip()
205+ if not name:
206+ return None
207+
208+ card_title = (r.get("title") or "").strip()
209+ ptype, city = _card_type_and_city(card_title)
210+
211+ # note / avis : "4.92 (128)"
212+ rating = reviews = None
213+ m = _RATING_RE.match(r.get("avgRatingLocalized") or "")
214+ if m:
215+ try:
216+ rating = float(m.group(1).replace(",", "."))
217+ reviews = int(re.sub(r"[\s,]", "", m.group(2)))
218+ except ValueError:
219+ rating = reviews = None
220+ if rating is not None and not (0 < rating <= 5):
221+ rating = reviews = None
222+
223+ # chambres / lits / sdb : structuredContent.primaryLine
224+ bedrooms = beds = baths = None
225+ sc = r.get("structuredContent") or {}
226+ for line in (sc.get("primaryLine") or []):
227+ body = (line.get("body") or "").lower()
228+ mm = _NUM_RE.search(body)
229+ if not mm:
230+ continue
231+ val = float(mm.group(1))
232+ if "bedroom" in body or "chambre" in body:
233+ bedrooms = val
234+ elif re.search(r"\bbeds?\b|\blits?\b", body):
235+ beds = val
236+ elif "bath" in body or "salle" in body and "bain" in body:
237+ baths = val
238+
239+ # prix : "5 nights x $277.60 CAD" (détail), sinon ligne « per night »
240+ price_night, price_label = None, ""
241+ sdp = r.get("structuredDisplayPrice") or {}
242+ primary = sdp.get("primaryLine") or {}
243+ blob = json.dumps(sdp, ensure_ascii=False)
244+ mm = _NIGHTLY_RE.search(blob)
245+ if mm:
246+ try:
247+ price_night = float(mm.group(2).replace(",", ""))
248+ price_label = f"{mm.group(1)} nights x ${mm.group(2)} CAD"
249+ except ValueError:
250+ price_night = None
251+ if price_night is None:
252+ label = (primary.get("accessibilityLabel")
253+ or primary.get("price") or "")
254+ qual = (primary.get("qualifier") or "").lower()
255+ mv = _MONEY_RE.search(label)
256+ if mv and ("night" in qual or "night" in label.lower()
257+ or "nuit" in label.lower()):
258+ try:
259+ price_night = float(mv.group(1).replace(",", ""))
260+ price_label = label.strip()
261+ except ValueError:
262+ price_night = None
263+ if not price_label:
264+ price_label = (primary.get("accessibilityLabel") or "").strip()
265+
266+ coord = ((demand.get("location") or {}).get("coordinate") or {})
267+ lat = coord.get("latitude")
268+ lng = coord.get("longitude")
269+
270+ images = [p.get("picture") for p in (r.get("contextualPictures") or [])
271+ if isinstance(p.get("picture"), str)
272+ and p["picture"].startswith("http")]
273+
274+ details: dict = {}
275+ badges = [b.get("text") for b in (r.get("badges") or []) if b.get("text")]
276+ if badges:
277+ details["badges"] = badges
278+ if card_title:
279+ details["card_title"] = card_title
280+
281+ return StListing(
282+ source=self.source_id,
283+ external_id=room_id,
284+ url=f"https://www.airbnb.ca/rooms/{room_id}",
285+ title=name,
286+ property_type=ptype,
287+ city=city,
288+ region=region,
289+ price_night=price_night,
290+ price_label=price_label,
291+ bedrooms=bedrooms,
292+ beds=beds,
293+ bathrooms=baths,
294+ rating=rating,
295+ reviews=reviews,
296+ details=details,
297+ images=images,
298+ lat=lat if isinstance(lat, (int, float)) else None,
299+ lng=lng if isinstance(lng, (int, float)) else None,
300+ ).finalize()
301+
302+ # -- contrat ------------------------------------------------------------
303+ def fetch(self) -> list[StListing]:
304+ pages = max(1, int(os.environ.get("LOUKA_AIRBNB_PAGES", "2") or 2))
305+ limit = int(os.environ.get("LOUKA_AIRBNB_LIMIT", "0") or 0)
306+ searches = SEARCHES[:limit] if limit > 0 else SEARCHES
307+
308+ out: list[StListing] = []
309+ seen: set[str] = set()
310+ for term, region in searches:
311+ slug = urllib.parse.quote(term.replace(" ", "-"), safe="-")
312+ base = (f"https://www.airbnb.ca/s/{slug}--Qu%C3%A9bec--Canada/homes"
313+ f"?locale=en&currency=CAD")
314+ n_before = len(out)
315+ try:
316+ html = self._search_html(base)
317+ results, cursors = self._deferred_results(html)
318+ pages_html = [results]
319+ for cur in cursors[1:pages]:
320+ more, _ = self._deferred_results(
321+ self._search_html(base + "&cursor="
322+ + urllib.parse.quote(cur, safe="")))
323+ if not more:
324+ break
325+ pages_html.append(more)
326+ for page in pages_html:
327+ for card in page:
328+ lst = self._to_listing(card, region)
329+ if lst is None or lst.external_id in seen:
330+ continue
331+ seen.add(lst.external_id)
332+ out.append(lst)
333+ except Exception as exc: # noqa: BLE001 — une recherche ne bloque pas les autres
334+ print(f"[airbnb] recherche '{term}' en échec : {exc}",
335+ file=sys.stderr)
336+ continue
337+ print(f"[airbnb] {term} ({region}) : "
338+ f"{len(out) - n_before} annonces (total {len(out)})",
339+ file=sys.stderr)
340+ return out
added louka/shortterm/connectors/base.py +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/connectors/booking.py +210 −0
Large diff (210 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/connectors/chaletsauquebec.py +210 −0
Large diff (210 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/connectors/chaletsdirect.py +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/connectors/locationschalets.py +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/db.py +202 −0
Large diff (202 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/ingest.py +86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/schema.py +197 −0
Large diff (197 lines) is not loaded by default to keep the page fast.Load diff
added louka/shortterm/web.py +243 −0
Large diff (243 lines) is not loaded by default to keep the page fast.Load diff
modified louka/web.py +6 −2
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
modified run.py +9 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme