SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

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 1 mo ago (Aug 22, 2026) parent 6941d85

24 changed files +2,844 −2

modified .gitignore +3 −0
@@ -23,4 +23,7 @@ data/imgcache/
23 23 data/louka.db-shm
24 24 data/louka.db-wal
25 25 data/louka.db.backup-*
26 +data/louka_ct.db
27 +data/louka_ct.db-shm
28 +data/louka_ct.db-wal
26 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 20 import GroupeKaBadge from "./ka/GroupeKaBadge";
21 21 import KaFooter from "./ka/KaFooter";
22 22 import ContactPage from "./pages/Contact";
23 +import CourtTermePage from "./pages/CourtTerme";
24 +import CourtTermeFichePage from "./pages/CourtTermeFiche";
23 25 import Home from "./pages/Home";
24 26 import ListingPage from "./pages/Listing";
25 27 import BotPage from "./pages/Bot";
@@ -77,6 +79,7 @@ function Ticker() {
77 79 const NAV_LINKS = [
78 80 { to: "/", label: "Logements", icon: <IcoHouse size={17} />, end: true },
79 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 83 { to: "/stats", label: "Stats", icon: <IcoChart size={17} />, end: false },
81 84 { to: "/sources", label: "Sources", icon: <IcoFolder size={17} />, end: false },
82 85 { to: "/contact", label: "Contact", icon: <IcoUser size={17} />, end: false },
@@ -229,6 +232,9 @@ function Header() {
229 232 <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
230 233 Logements
231 234 </NavLink>
235 + <NavLink to="/court-terme" className={({ isActive }) => (isActive ? "active" : "")}>
236 + Court terme
237 + </NavLink>
232 238 <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
233 239 Stats
234 240 </NavLink>
@@ -290,6 +296,7 @@ function Footer() {
290 296 Agrégateur indépendant : chaque fiche renvoie vers l'annonce originale.
291 297 </span>
292 298 <NavLink to="/">Logements</NavLink>
299 + <NavLink to="/court-terme">Court terme</NavLink>
293 300 <NavLink to="/villes">Villes</NavLink>
294 301 <NavLink to="/stats">Stats</NavLink>
295 302 <NavLink to="/sources">Sources</NavLink>
@@ -343,6 +350,8 @@ export default function App() {
343 350 <Routes>
344 351 <Route path="/" element={<Home />} />
345 352 <Route path="/logement/:uid" element={<ListingPage />} />
353 + <Route path="/court-terme" element={<CourtTermePage />} />
354 + <Route path="/court-terme/:uid" element={<CourtTermeFichePage />} />
346 355 <Route path="/villes" element={<VillesPage />} />
347 356 <Route path="/ville/:ville" element={<VillePage />} />
348 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
@@ -0,0 +1,44 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/base.py : classe de base des connecteurs court terme.
4 +# Hérite des backends de fetch de BaseConnector (get direct + escalade
5 +# anti-bot Oxylabs→Scrapfly→Bright Data, get_rendered, scrapfly) mais son
6 +# cache détail vit dans la base court terme (louka_ct.db) et son contrat
7 +# retourne des StListing.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +from ...connectors.base import BaseConnector
12 +from ..schema import StListing
13 +
14 +
15 +class StConnector(BaseConnector):
16 + """Un connecteur court terme = un adaptateur propre à une plateforme
17 + d'hébergement (chalets, séjours, gîtes…).
18 +
19 + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne
20 + la liste des hébergements actuellement affichés. Le pipeline
21 + (shortterm/ingest.py) s'occupe du diff avec la base court terme.
22 + """
23 +
24 + source_id: str = ""
25 +
26 + def detail(self, external_id: str, key: str, fetch_fn) -> dict:
27 + """Cache détail dans louka_ct.db (jamais dans la base long terme)."""
28 + if not self.use_detail_cache:
29 + return fetch_fn() or {}
30 + from .. import db
31 + if self._detail_con is None:
32 + self._detail_con = db.connect()
33 + cached = db.get_cached_detail(self._detail_con, self.source_id,
34 + str(external_id), key)
35 + if cached is not None:
36 + return cached
37 + payload = fetch_fn() or {}
38 + db.put_cached_detail(self._detail_con, self.source_id,
39 + str(external_id), key, payload)
40 + return payload
41 +
42 + # -- contrat --------------------------------------------------------------
43 + def fetch(self) -> list[StListing]:
44 + raise NotImplementedError
added louka/shortterm/connectors/booking.py +210 −0
@@ -0,0 +1,210 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/booking.py : Booking.com — locations de vacances au Québec
4 +# (chalets, maisons de vacances, villas, appartements — PAS les hôtels).
5 +#
6 +# Méthode : les pages /searchresults.fr.html embarquent côté serveur le store
7 +# Apollo (<script data-capla-store-data="apollo">…</script>) avec les 25
8 +# premiers résultats structurés (prix, coordonnées, chambres, occupancy…).
9 +# Le site est derrière AWS WAF → Scrapfly ASP (sans rendu JS, le JSON est SSR).
10 +# Le paramètre `offset` n'est PAS respecté côté SSR (vérifié 2026-08-22 :
11 +# offset=25 recoupe 24/25 résultats de la page 1) → on couvre le territoire
12 +# en multipliant destinations × jeux de filtres ht_id (25 max par requête).
13 +#
14 +# ht_id (types d'hébergement Booking) : 220 = maisons de vacances,
15 +# 228 = chalets, 213 = villas, 201 = appartements, 204 = hôtels (exclu).
16 +#
17 +# Recherche AVEC dates génériques (~30 jours, 2 nuits) : sans dates, Booking
18 +# ne renvoie ni prix ni configuration des unités. Le prix est donc indicatif
19 +# → price_label « à partir de … » + price_night (le plus bas trouvé).
20 +# -----------------------------------------------------------------------------
21 +from __future__ import annotations
22 +
23 +import datetime
24 +import html as _html
25 +import json
26 +import re
27 +import sys
28 +from urllib.parse import quote
29 +
30 +from ..schema import StListing
31 +from .base import StConnector
32 +
33 +# (texte de recherche Booking, région touristique QC)
34 +DESTINATIONS = [
35 + ("Mont-Tremblant", "Laurentides"),
36 + ("Saint-Sauveur", "Laurentides"),
37 + ("Magog", "Cantons-de-l'Est"),
38 + ("Bromont", "Cantons-de-l'Est"),
39 + ("Baie-Saint-Paul", "Charlevoix"),
40 + ("La Malbaie", "Charlevoix"),
41 + ("Québec", "Québec"),
42 + ("Montréal", "Montréal"),
43 + ("Percé", "Gaspésie"),
44 + ("Rimouski", "Bas-Saint-Laurent"),
45 + ("Saguenay", "Saguenay–Lac-Saint-Jean"),
46 + ("Shawinigan", "Mauricie"),
47 + ("Gatineau", "Outaouais"),
48 +]
49 +
50 +# Jeux de filtres par destination (25 résultats max chacun, cf. en-tête)
51 +FILTER_SETS = [
52 + "ht_id=220", # maisons de vacances
53 + "ht_id=228;ht_id=213", # chalets + villas
54 + "ht_id=201", # appartements entiers
55 +]
56 +
57 +# accommodationTypeId Booking → type canonique Lou-Ka
58 +TYPE_MAP = {
59 + 201: "Appartement",
60 + 203: "Auberge",
61 + 208: "Gîte",
62 + 213: "Maison", # villa
63 + 216: "Gîte", # maison d'hôtes
64 + 220: "Maison", # maison de vacances
65 + 222: "Chambre", # séjour chez l'habitant
66 + 228: "Chalet",
67 +}
68 +
69 +_CAPLA_RE = re.compile(
70 + r'<script[^>]*data-capla-store-data="apollo"[^>]*>(.*?)</script>', re.S)
71 +_IMG_BASE = "https://cf.bstatic.com"
72 +
73 +
74 +class Booking(StConnector):
75 + source_id = "booking"
76 + request_delay = 1.0
77 +
78 + # -- extraction -----------------------------------------------------------
79 + def _search_results(self, html: str) -> list[dict]:
80 + """Résultats de recherche depuis le store Apollo SSR de la page."""
81 + m = _CAPLA_RE.search(html or "")
82 + if not m:
83 + return []
84 + try:
85 + store = json.loads(m.group(1))
86 + except ValueError:
87 + try:
88 + store = json.loads(_html.unescape(m.group(1)))
89 + except ValueError:
90 + return []
91 + queries = (store.get("ROOT_QUERY") or {}).get("searchQueries") or {}
92 + for key, val in queries.items():
93 + if key.startswith("search(") and isinstance(val, dict):
94 + return val.get("results") or []
95 + return []
96 +
97 + def _to_listing(self, card: dict, region: str) -> StListing | None:
98 + basic = card.get("basicPropertyData") or {}
99 + hotel_id = basic.get("id")
100 + page_name = basic.get("pageName") or ""
101 + title = ((card.get("displayName") or {}).get("text") or "").strip()
102 + if not hotel_id or not page_name or not title:
103 + return None
104 + loc = basic.get("location") or {}
105 + if (loc.get("countryCode") or "ca").lower() != "ca":
106 + return None # jamais hors Canada
107 +
108 + # prix : total du séjour (2 nuits, dates indicatives) → prix/nuit plancher
109 + price_night, price_label = None, ""
110 + pdi = card.get("priceDisplayInfoIrene") or {}
111 + amount = (((pdi.get("displayPrice") or {}).get("amountPerStay") or {})
112 + .get("amountUnformatted"))
113 + if isinstance(amount, (int, float)) and amount > 0:
114 + price_night = round(float(amount) / self._nights, 2)
115 + price_label = (f"à partir de {price_night:.0f} $ / nuit "
116 + f"(séjour de {self._nights} nuits)")
117 +
118 + # configuration des unités (chambres, lits, sdb, superficie)
119 + bedrooms = beds = bathrooms = None
120 + area = ""
121 + muc = card.get("matchingUnitConfigurations") or {}
122 + common = muc.get("commonConfiguration") or {}
123 + if common:
124 + bedrooms = common.get("nbBedrooms")
125 + beds = common.get("nbAllBeds")
126 + bathrooms = common.get("nbBathrooms")
127 + la = common.get("localizedArea") or {}
128 + if la.get("localizedArea"):
129 + area = f"{la['localizedArea']} {la.get('unit') or 'm²'}"
130 +
131 + # capacité : occupancy max des blocs tarifaires retournés
132 + capacity = None
133 + for blk in card.get("blocks") or []:
134 + occ = (blk.get("blockId") or {}).get("occupancy")
135 + if isinstance(occ, (int, float)):
136 + capacity = max(capacity or 0, occ)
137 +
138 + rating = reviews = None
139 + rev = basic.get("reviews") or {}
140 + if rev.get("showScore") and rev.get("totalScore"):
141 + rating = round(float(rev["totalScore"]) / 2, 2) # /10 → /5
142 + reviews = rev.get("reviewsCount")
143 +
144 + images = []
145 + rel = ((((basic.get("photos") or {}).get("main") or {})
146 + .get("highResUrl") or {}).get("relativeUrl"))
147 + if rel:
148 + images.append(_IMG_BASE + rel)
149 +
150 + type_id = basic.get("accommodationTypeId")
151 + details: dict = {"booking_type_id": type_id}
152 + if area:
153 + details["superficie"] = area
154 + main_dist = (card.get("location") or {}).get("mainDistance")
155 + if main_dist:
156 + details["distance_centre"] = main_dist
157 +
158 + return StListing(
159 + source=self.source_id,
160 + external_id=str(hotel_id),
161 + url=f"https://www.booking.com/hotel/ca/{page_name}.fr.html",
162 + title=title,
163 + property_type=TYPE_MAP.get(type_id, "Autre"),
164 + address=(loc.get("address") or "").strip(),
165 + city=(loc.get("city") or "").strip(),
166 + region=region,
167 + price_night=price_night,
168 + price_label=price_label,
169 + capacity=float(capacity) if capacity else None,
170 + bedrooms=float(bedrooms) if bedrooms else None,
171 + beds=float(beds) if beds else None,
172 + bathrooms=float(bathrooms) if bathrooms else None,
173 + rating=rating,
174 + reviews=int(reviews) if reviews else None,
175 + description=((card.get("description") or {}).get("text") or "").strip(),
176 + details=details,
177 + images=images,
178 + lat=loc.get("latitude"),
179 + lng=loc.get("longitude"),
180 + )
181 +
182 + # -- contrat ---------------------------------------------------------------
183 + def fetch(self) -> list[StListing]:
184 + today = datetime.date.today()
185 + checkin = (today + datetime.timedelta(days=30)).isoformat()
186 + checkout = (today + datetime.timedelta(days=32)).isoformat()
187 + self._nights = 2
188 +
189 + listings: dict[str, StListing] = {}
190 + for dest, region in DESTINATIONS:
191 + for nflt in FILTER_SETS:
192 + url = ("https://www.booking.com/searchresults.fr.html"
193 + f"?ss={quote(dest)}&nflt={quote(nflt)}"
194 + f"&checkin={checkin}&checkout={checkout}"
195 + "&group_adults=2&no_rooms=1"
196 + "&selected_currency=CAD&lang=fr")
197 + try:
198 + html = self.get_scrapfly(url, render_js=False, asp=True)
199 + cards = self._search_results(html)
200 + except Exception as exc: # noqa: BLE001 — une requête ratée ≠ sync ratée
201 + print(f"[booking] {dest} ({nflt}) : {exc}", file=sys.stderr)
202 + continue
203 + for card in cards:
204 + try:
205 + lst = self._to_listing(card, region)
206 + except Exception: # noqa: BLE001
207 + continue
208 + if lst and lst.external_id not in listings:
209 + listings[lst.external_id] = lst
210 + return list(listings.values())
added louka/shortterm/connectors/chaletsauquebec.py +210 −0
@@ -0,0 +1,210 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/chaletsauquebec.py : ChaletsAuQuebec.com — « le plus grand
4 +# répertoire de chalets à louer par le propriétaire au Québec » (~3 900).
5 +#
6 +# Méthode (site ASP.NET/Telerik, pagination des listes en postback → évitée) :
7 +# 1. la vue carte de chaque région (`/chalets-a-louer/quebec/<région>/
8 +# default.aspx?ar=c`) embarque TOUT l'inventaire régional dans la
9 +# variable JS `gAddressPoints = [[lat, lng, "id"], …]` → inventaire
10 +# complet + géolocalisation en 17 requêtes ;
11 +# 2. page détail `/<id>` (cache self.detail) : microdonnées schema.org
12 +# (priceRange, geo), h1, Région/Ville, icônes (capacité, chambres,
13 +# salles de bain, animaux), commodités par groupe, no CITQ, photos.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import re
18 +import sys
19 +
20 +from ..schema import StListing
21 +from .base import StConnector
22 +
23 +BASE = "https://www.chaletsauquebec.com"
24 +INDEX = f"{BASE}/chalets-a-louer/quebec"
25 +
26 +_POINTS_RE = re.compile(r"gAddressPoints\s*=\s*(\[\[.*?\]\])\s*;", re.S)
27 +_REGION_HREF_RE = re.compile(r'href="(/chalets-a-louer/quebec/[a-z0-9-]+)"')
28 +# dernier mot avant « à louer » dans <title> : « … - Chalet à louer Ville | … »
29 +_TITLE_TYPE_RE = re.compile(r"([\w’'-]+)\s+à louer\s", re.UNICODE)
30 +_REGION_RE = re.compile(r"Région\s*:\s*<a[^>]*>([^<]+)</a>")
31 +_VILLE_RE = re.compile(r"Ville\s*:\s*<a[^>]*>([^<]+)</a>")
32 +_PHOTO_RE = re.compile(r"/_photos/grand/[^\"'\s]+", re.I)
33 +
34 +# groupes de commodités = équipements de la propriété (le reste = contexte)
35 +_AMEN_GROUPS = ("cuisine", "communications", "général", "general",
36 + "équipements de loisir", "equipements de loisir",
37 + "équipements extérieur", "equipements exterieur")
38 +
39 +
40 +class ChaletsAuQuebec(StConnector):
41 + source_id = "chaletsauquebec"
42 + request_delay = 0.35 # vieux site costaud ; ~3 900 pages détail (cache)
43 +
44 + # -- inventaire (vues carte régionales) -----------------------------------
45 + def _region_slugs(self) -> list[str]:
46 + html = self.get(INDEX).text
47 + slugs = []
48 + for href in _REGION_HREF_RE.findall(html):
49 + slug = href.rsplit("/", 1)[-1]
50 + if slug not in slugs:
51 + slugs.append(slug)
52 + return slugs
53 +
54 + def _region_points(self, slug: str) -> list[tuple[str, float, float]]:
55 + """[(id, lat, lng), …] de la vue carte d'une région (inventaire complet)."""
56 + import json
57 + url = f"{BASE}/chalets-a-louer/quebec/{slug}/default.aspx?ar=c"
58 + m = _POINTS_RE.search(self.get(url).text)
59 + if not m:
60 + return []
61 + out = []
62 + for pt in json.loads(m.group(1)):
63 + try:
64 + lat, lng, ext = float(pt[0]), float(pt[1]), str(pt[2])
65 + except (TypeError, ValueError, IndexError):
66 + continue
67 + out.append((ext, lat, lng))
68 + return out
69 +
70 + # -- page détail -----------------------------------------------------------
71 + def _fetch_detail(self, ext: str) -> dict:
72 + from bs4 import BeautifulSoup
73 + html = self.get(f"{BASE}/{ext}").text
74 + soup = BeautifulSoup(html, "html.parser")
75 + d: dict = {}
76 +
77 + h1 = soup.find("h1")
78 + d["title"] = h1.get_text(" ", strip=True) if h1 else ""
79 + tt = soup.find("title")
80 + types = _TITLE_TYPE_RE.findall(tt.get_text()) if tt else []
81 + d["property_type"] = types[-1].strip() if types else ""
82 +
83 + pr = soup.find("meta", attrs={"itemprop": "priceRange"})
84 + d["price_label"] = (pr.get("content") or "").strip() if pr else ""
85 + for prop, key in (("latitude", "lat"), ("longitude", "lng")):
86 + tag = soup.find("meta", attrs={"itemprop": prop})
87 + if tag and tag.get("content"):
88 + try:
89 + d[key] = float(tag["content"])
90 + except ValueError:
91 + pass
92 +
93 + m = _REGION_RE.search(html)
94 + d["region"] = m.group(1).strip() if m else ""
95 + m = _VILLE_RE.search(html)
96 + d["city"] = m.group(1).strip() if m else ""
97 +
98 + # icônes du résumé : capacité, chambres, sdb, animaux + drapeaux
99 + amenities: list[str] = []
100 + for td in soup.select("td.styleTxtIcon"):
101 + txt = td.get_text(" ", strip=True)
102 + low = txt.lower()
103 + mn = re.search(r"(\d+)", txt)
104 + if "pers" in low and mn:
105 + d["capacity"] = float(mn.group(1))
106 + elif "chambre" in low and mn:
107 + if int(mn.group(1)) > 0: # « 0 chambre » = non renseigné
108 + d["bedrooms"] = float(mn.group(1))
109 + elif "salle" in low and "bain" in low and mn:
110 + d["bathrooms"] = float(mn.group(1))
111 + elif low.startswith("animaux"):
112 + d["pets"] = ("non" if "non permis" in low
113 + else "conditions" if "restriction" in low
114 + else "oui" if "permis" in low else None)
115 + elif low.startswith("fumeur"):
116 + d.setdefault("details", {})["fumeurs"] = txt
117 + elif txt:
118 + amenities.append(txt) # Foyer, Internet, Spa, Bord de l'eau…
119 +
120 + # commodités par groupe + no CITQ + lits
121 + details = d.setdefault("details", {})
122 + for td in soup.select("td.titreItemComm"):
123 + name = td.get_text(" ", strip=True)
124 + val_td = td.find_next_sibling("td")
125 + if not name or val_td is None:
126 + continue
127 + vals = [v.get_text(" ", strip=True)
128 + for v in val_td.select(".paddingValComm")]
129 + vals = [v for v in vals if v]
130 + low = name.lower()
131 + if "citq" in low:
132 + d["citq"] = vals[0] if vals else val_td.get_text(strip=True)
133 + elif low == "chambres": # « - 3 lits simples - 6 lits doubles … »
134 + blob = " ".join(vals)
135 + n = sum(int(x) for x in re.findall(r"(\d+)\s+lits?", blob))
136 + if n:
137 + d["beds"] = float(n)
138 + details["lits"] = blob
139 + elif low.startswith(_AMEN_GROUPS):
140 + amenities.extend(vals)
141 + elif vals: # activités été/hiver à proximité…
142 + details[name] = vals
143 + seen: set[str] = set()
144 + d["amenities"] = [a for a in amenities
145 + if not (a in seen or seen.add(a))]
146 +
147 + # description (retirer les tables d'icônes/commodités, garder le texte)
148 + sec = soup.find(id="idSectionDescription")
149 + if sec is not None:
150 + for t in sec.find_all("table"):
151 + t.decompose()
152 + lines = [ln for ln in sec.get_text("\n", strip=True).split("\n")
153 + if ln and not ln.isupper()] # titres de section en CAPS
154 + d["description"] = "\n".join(lines).strip()
155 +
156 + imgs: list[str] = []
157 + seen_img: set[str] = set() # même fichier en /Grand/ et /grand/
158 + for u in _PHOTO_RE.findall(html):
159 + u = BASE + u if u.startswith("/") else u
160 + if u.lower() not in seen_img:
161 + seen_img.add(u.lower())
162 + imgs.append(u)
163 + d["images"] = imgs
164 + return d
165 +
166 + # -- contrat ----------------------------------------------------------------
167 + def fetch(self) -> list[StListing]:
168 + points: dict[str, tuple[float, float]] = {}
169 + for slug in self._region_slugs():
170 + for ext, lat, lng in self._region_points(slug):
171 + points.setdefault(ext, (lat, lng))
172 +
173 + listings, errors = [], 0
174 + for ext, (lat, lng) in points.items():
175 + try:
176 + d = self.detail(ext, "pdp1", lambda e=ext: self._fetch_detail(e))
177 + except Exception as exc: # noqa: BLE001 — annonce retirée / réseau
178 + errors += 1
179 + if errors <= 5:
180 + print(f"[chaletsauquebec] détail {ext} : {exc}",
181 + file=sys.stderr)
182 + continue
183 + if not d.get("title"):
184 + continue # page vide / annonce retirée
185 + listings.append(StListing(
186 + source=self.source_id,
187 + external_id=ext,
188 + url=f"{BASE}/{ext}",
189 + title=d.get("title", ""),
190 + property_type=d.get("property_type", ""),
191 + city=d.get("city", ""),
192 + region=d.get("region", ""),
193 + price_label=d.get("price_label", ""),
194 + capacity=d.get("capacity"),
195 + bedrooms=d.get("bedrooms"),
196 + beds=d.get("beds"),
197 + bathrooms=d.get("bathrooms"),
198 + pets=d.get("pets"),
199 + citq=str(d.get("citq", "") or ""),
200 + description=d.get("description", ""),
201 + amenities=d.get("amenities", []),
202 + details=d.get("details", {}),
203 + images=d.get("images", []),
204 + lat=d.get("lat", lat),
205 + lng=d.get("lng", lng),
206 + ))
207 + if errors:
208 + print(f"[chaletsauquebec] {errors} pages détail en erreur (ignorées)",
209 + file=sys.stderr)
210 + return listings
added louka/shortterm/connectors/chaletsdirect.py +22 −0
@@ -0,0 +1,22 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/chaletsdirect.py : ChaletsDirect.com — SOURCE HORS SUJET.
4 +#
5 +# Vérifié le 2026-08-22 : malgré son nom, www.chaletsdirect.com est un
6 +# répertoire de chalets de SKI EN EUROPE (Morzine, Chamonix, Avoriaz,
7 +# Verbier, Andorre, Autriche… — sitemap.xml complet parcouru : zéro
8 +# occurrence de « Québec », aucune destination canadienne). Aucun
9 +# inventaire québécois à ingérer → connecteur désactivé.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +from ..schema import StListing
14 +from .base import StConnector
15 +
16 +
17 +class ChaletsDirect(StConnector):
18 + source_id = "chaletsdirect"
19 + disabled = True # site = chalets de ski européens, rien au Québec
20 +
21 + def fetch(self) -> list[StListing]:
22 + return []
added louka/shortterm/connectors/locationschalets.py +24 −0
@@ -0,0 +1,24 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# connectors/locationschalets.py : LocationsChalets.com — RÉPERTOIRE DISPARU.
4 +#
5 +# Vérifié le 2026-08-22 : le répertoire original n'existe plus.
6 +# www.locationschalets.com répond 301 → https://km42.ca/, la boutique
7 +# Shopify d'un unique exploitant (« KM-42 », 2 chalets à Val-David) dont
8 +# le catalogue (/products.json) ne contient que 3 produits : un produit
9 +# de test Stripe et 2 fiches chalet à 0,00 $ (réservation hors Shopify,
10 +# aucun prix ni disponibilité exploitables). /sitemap.xml de l'ancien
11 +# domaine : 404. Pas d'inventaire de répertoire à ingérer → désactivé.
12 +# -----------------------------------------------------------------------------
13 +from __future__ import annotations
14 +
15 +from ..schema import StListing
16 +from .base import StConnector
17 +
18 +
19 +class LocationsChalets(StConnector):
20 + source_id = "locationschalets"
21 + disabled = True # domaine redirigé vers km42.ca (1 exploitant, 2 chalets, 0 prix)
22 +
23 + def fetch(self) -> list[StListing]:
24 + return []
added louka/shortterm/db.py +202 −0
@@ -0,0 +1,202 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# db.py : persistance SQLite SÉPARÉE (data/louka_ct.db) — upsert avec détection
4 +# de changements, cycle de vie avec délai de grâce, cache détail.
5 +# -----------------------------------------------------------------------------
6 +from __future__ import annotations
7 +
8 +import json
9 +import sqlite3
10 +import time
11 +from pathlib import Path
12 +
13 +from .schema import StListing
14 +
15 +DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "louka_ct.db"
16 +
17 +MISS_GRACE = 2 # absences consécutives tolérées avant désactivation
18 +
19 +_SCHEMA = """
20 +CREATE TABLE IF NOT EXISTS st_listings (
21 + uid TEXT PRIMARY KEY,
22 + source TEXT NOT NULL,
23 + external_id TEXT NOT NULL,
24 + url TEXT,
25 + title TEXT,
26 + property_type TEXT,
27 + address TEXT,
28 + city TEXT,
29 + region TEXT,
30 + price_night REAL,
31 + price_label TEXT,
32 + capacity REAL,
33 + bedrooms REAL,
34 + beds REAL,
35 + bathrooms REAL,
36 + pets TEXT,
37 + citq TEXT,
38 + rating REAL,
39 + reviews INTEGER,
40 + description TEXT,
41 + amenities TEXT, -- JSON
42 + details TEXT, -- JSON
43 + images TEXT, -- JSON
44 + lat REAL,
45 + lng REAL,
46 + content_hash TEXT,
47 + first_seen REAL,
48 + last_seen REAL,
49 + updated_at REAL,
50 + miss_count INTEGER DEFAULT 0,
51 + active INTEGER DEFAULT 1
52 +);
53 +CREATE INDEX IF NOT EXISTS idx_st_source ON st_listings(source);
54 +CREATE INDEX IF NOT EXISTS idx_st_region ON st_listings(region);
55 +CREATE INDEX IF NOT EXISTS idx_st_active ON st_listings(active);
56 +CREATE INDEX IF NOT EXISTS idx_st_geo ON st_listings(lat, lng);
57 +
58 +CREATE TABLE IF NOT EXISTS st_sync_log (
59 + id INTEGER PRIMARY KEY AUTOINCREMENT,
60 + source TEXT,
61 + ts REAL,
62 + found INTEGER,
63 + added INTEGER,
64 + updated INTEGER,
65 + removed INTEGER,
66 + ok INTEGER,
67 + message TEXT
68 +);
69 +
70 +CREATE TABLE IF NOT EXISTS st_detail_cache (
71 + source TEXT NOT NULL,
72 + external_id TEXT NOT NULL,
73 + key TEXT,
74 + payload TEXT,
75 + fetched_at REAL,
76 + PRIMARY KEY (source, external_id)
77 +);
78 +"""
79 +
80 +
81 +def connect() -> sqlite3.Connection:
82 + DB_PATH.parent.mkdir(parents=True, exist_ok=True)
83 + con = sqlite3.connect(DB_PATH)
84 + con.row_factory = sqlite3.Row
85 + con.executescript(_SCHEMA)
86 + con.commit()
87 + con.execute("PRAGMA journal_mode=WAL")
88 + con.execute("PRAGMA busy_timeout=30000")
89 + return con
90 +
91 +
92 +def sync_source(con: sqlite3.Connection, source: str,
93 + listings: list[StListing]) -> dict:
94 + """Synchronise les hébergements d'une source (ajout / màj / retrait)."""
95 + now = time.time()
96 + added = updated = 0
97 + seen = set()
98 + for lst in listings:
99 + seen.add(lst.uid)
100 + h = lst.content_hash()
101 + row = con.execute("SELECT content_hash FROM st_listings WHERE uid=?",
102 + (lst.uid,)).fetchone()
103 + params = dict(
104 + uid=lst.uid, source=lst.source, external_id=str(lst.external_id),
105 + url=lst.url, title=lst.title, property_type=lst.property_type,
106 + address=lst.address, city=lst.city, region=lst.region,
107 + price_night=lst.price_night, price_label=lst.price_label,
108 + capacity=lst.capacity, bedrooms=lst.bedrooms, beds=lst.beds,
109 + bathrooms=lst.bathrooms, pets=lst.pets, citq=lst.citq,
110 + rating=lst.rating, reviews=lst.reviews,
111 + description=lst.description,
112 + amenities=json.dumps(lst.amenities, ensure_ascii=False),
113 + details=json.dumps(lst.details, ensure_ascii=False),
114 + images=json.dumps(lst.images, ensure_ascii=False),
115 + lat=lst.lat, lng=lst.lng, content_hash=h, now=now,
116 + )
117 + if row is None:
118 + con.execute(
119 + """INSERT INTO st_listings (uid, source, external_id, url,
120 + title, property_type, address, city, region, price_night,
121 + price_label, capacity, bedrooms, beds, bathrooms, pets,
122 + citq, rating, reviews, description, amenities, details,
123 + images, lat, lng, content_hash, first_seen, last_seen,
124 + updated_at, miss_count, active)
125 + VALUES (:uid,:source,:external_id,:url,:title,
126 + :property_type,:address,:city,:region,:price_night,
127 + :price_label,:capacity,:bedrooms,:beds,:bathrooms,:pets,
128 + :citq,:rating,:reviews,:description,:amenities,:details,
129 + :images,:lat,:lng,:content_hash,:now,:now,:now,0,1)""",
130 + params)
131 + added += 1
132 + elif row["content_hash"] != h:
133 + con.execute(
134 + """UPDATE st_listings SET url=:url, title=:title,
135 + property_type=:property_type, address=:address, city=:city,
136 + region=:region, price_night=:price_night,
137 + price_label=:price_label, capacity=:capacity,
138 + bedrooms=:bedrooms, beds=:beds, bathrooms=:bathrooms,
139 + pets=:pets, citq=:citq, rating=:rating, reviews=:reviews,
140 + description=:description, amenities=:amenities,
141 + details=:details, images=:images,
142 + lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
143 + content_hash=:content_hash, last_seen=:now,
144 + updated_at=:now, miss_count=0, active=1
145 + WHERE uid=:uid""", params)
146 + updated += 1
147 + else:
148 + con.execute("UPDATE st_listings SET last_seen=?, miss_count=0,"
149 + " active=1 WHERE uid=?", (now, lst.uid))
150 +
151 + removed = 0
152 + for r in con.execute("SELECT uid, miss_count FROM st_listings"
153 + " WHERE source=? AND active=1", (source,)).fetchall():
154 + if r["uid"] in seen:
155 + continue
156 + if r["miss_count"] + 1 >= MISS_GRACE:
157 + con.execute("UPDATE st_listings SET active=0, miss_count=?,"
158 + " updated_at=? WHERE uid=?",
159 + (r["miss_count"] + 1, now, r["uid"]))
160 + removed += 1
161 + else:
162 + con.execute("UPDATE st_listings SET miss_count=miss_count+1"
163 + " WHERE uid=?", (r["uid"],))
164 +
165 + con.execute("INSERT INTO st_sync_log (source, ts, found, added, updated,"
166 + " removed, ok, message) VALUES (?,?,?,?,?,?,1,'ok')",
167 + (source, now, len(listings), added, updated, removed))
168 + con.commit()
169 + return {"source": source, "found": len(listings), "added": added,
170 + "updated": updated, "removed": removed}
171 +
172 +
173 +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
174 + con.execute("INSERT INTO st_sync_log (source, ts, found, added, updated,"
175 + " removed, ok, message) VALUES (?,?,0,0,0,0,0,?)",
176 + (source, time.time(), message))
177 + con.commit()
178 +
179 +
180 +def get_cached_detail(con: sqlite3.Connection, source: str,
181 + external_id: str, key: str) -> dict | None:
182 + row = con.execute("SELECT key, payload FROM st_detail_cache"
183 + " WHERE source=? AND external_id=?",
184 + (source, external_id)).fetchone()
185 + if row and row["key"] == key and row["payload"]:
186 + try:
187 + return json.loads(row["payload"])
188 + except ValueError:
189 + return None
190 + return None
191 +
192 +
193 +def put_cached_detail(con: sqlite3.Connection, source: str,
194 + external_id: str, key: str, payload: dict) -> None:
195 + con.execute("INSERT INTO st_detail_cache (source, external_id, key,"
196 + " payload, fetched_at) VALUES (?,?,?,?,?)"
197 + " ON CONFLICT(source, external_id) DO UPDATE SET"
198 + " key=excluded.key, payload=excluded.payload,"
199 + " fetched_at=excluded.fetched_at",
200 + (source, external_id, key,
201 + json.dumps(payload, ensure_ascii=False), time.time()))
202 + con.commit()
added louka/shortterm/ingest.py +86 −0
@@ -0,0 +1,86 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# ingest.py : pipeline d'ingestion court terme — exécute les connecteurs
4 +# court terme et synchronise la base séparée (louka_ct.db).
5 +# -----------------------------------------------------------------------------
6 +from __future__ import annotations
7 +
8 +import sys
9 +import time
10 +import traceback
11 +
12 +from . import db
13 +from .connectors import ST_CONNECTORS
14 +
15 +BREAKER_FAILS = 5
16 +BREAKER_RETRY_EVERY = 5
17 +
18 +
19 +def _consecutive_failures(con, source: str) -> int:
20 + rows = con.execute(
21 + "SELECT ok FROM st_sync_log WHERE source=? ORDER BY ts DESC LIMIT 60",
22 + (source,)).fetchall()
23 + n = 0
24 + for r in rows:
25 + if r["ok"]:
26 + break
27 + n += 1
28 + return n
29 +
30 +
31 +def run(sources: list[str] | None = None) -> list[dict]:
32 + """Exécute l'ingestion court terme (toutes les sources ou celles demandées)."""
33 + con = db.connect()
34 + results = []
35 + explicit = sources is not None
36 + targets = sources or list(ST_CONNECTORS.keys())
37 + for sid in targets:
38 + cls = ST_CONNECTORS.get(sid)
39 + if cls is None:
40 + print(f"[lou-ka/ct] connecteur inconnu : {sid}", file=sys.stderr)
41 + continue
42 + if not explicit:
43 + fails = _consecutive_failures(con, sid)
44 + if fails >= BREAKER_FAILS and fails % BREAKER_RETRY_EVERY != 0:
45 + print(f"[lou-ka/ct] {sid} sauté (disjoncteur : {fails} échecs)")
46 + db.log_failure(con, sid, f"disjoncteur: sauté ({fails} échecs)")
47 + results.append({"source": sid, "skipped": True, "fails": fails})
48 + continue
49 + t0 = time.time()
50 + print(f"[lou-ka/ct] sync {sid} ...")
51 + try:
52 + listings = cls().fetch()
53 + finalized, dropped = [], 0
54 + for lst in listings:
55 + try:
56 + if not (lst.url and lst.title):
57 + dropped += 1
58 + continue
59 + finalized.append(lst.finalize())
60 + except Exception:
61 + dropped += 1
62 + stats = db.sync_source(con, sid, finalized)
63 + stats["seconds"] = round(time.time() - t0, 1)
64 + if dropped:
65 + stats["dropped"] = dropped
66 + print(f"[lou-ka/ct] {stats}")
67 + results.append(stats)
68 + except Exception as exc:
69 + db.log_failure(con, sid, f"{exc}")
70 + traceback.print_exc()
71 + results.append({"source": sid, "error": str(exc)})
72 + con.close()
73 + return results
74 +
75 +
76 +def watch(interval_seconds: int = 6 * 3600) -> None:
77 + """Boucle de rafraîchissement périodique (défaut : 6 h — l'inventaire
78 + court terme bouge moins vite que les logements au mois)."""
79 + while True:
80 + run()
81 + print(f"[lou-ka/ct] prochaine synchronisation dans {interval_seconds}s")
82 + time.sleep(interval_seconds)
83 +
84 +
85 +if __name__ == "__main__":
86 + run(sys.argv[1:] or None)
added louka/shortterm/schema.py +197 −0
@@ -0,0 +1,197 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# schema.py : modèle standardisé d'un hébergement court terme (StListing)
4 +# -----------------------------------------------------------------------------
5 +"""Schéma standard d'un hébergement court terme.
6 +
7 +Chaque connecteur court terme produit des objets `StListing`. `finalize()`
8 +applique la normalisation commune (prix à la nuit, région touristique,
9 +commodités dérivées, bornes géographiques du Québec).
10 +"""
11 +from __future__ import annotations
12 +
13 +import hashlib
14 +import html as _html
15 +import json
16 +import re
17 +from dataclasses import dataclass, field, asdict
18 +
19 +from ..normalize import strip_accents
20 +
21 +# Types canoniques d'hébergement (affichage + filtre)
22 +PROPERTY_TYPES = [
23 + "Chalet", "Maison", "Condo", "Appartement", "Studio", "Loft", "Chambre",
24 + "Gîte", "Auberge", "Dôme", "Yourte", "Mini-maison", "Prêt-à-camper",
25 + "Refuge", "Camping", "Autre",
26 +]
27 +
28 +# Régions touristiques du Québec (canoniques)
29 +REGIONS = [
30 + "Abitibi-Témiscamingue", "Bas-Saint-Laurent", "Cantons-de-l'Est",
31 + "Centre-du-Québec", "Charlevoix", "Chaudière-Appalaches", "Côte-Nord",
32 + "Eeyou Istchee Baie-James", "Gaspésie", "Îles-de-la-Madeleine",
33 + "Lanaudière", "Laurentides", "Laval", "Mauricie", "Montérégie",
34 + "Montréal", "Nord-du-Québec", "Nunavik", "Outaouais", "Québec",
35 + "Saguenay–Lac-Saint-Jean",
36 +]
37 +
38 +_REGION_KEYS = {strip_accents(r).lower().replace("–", "-").replace("'", ""): r
39 + for r in REGIONS}
40 +_REGION_ALIASES = {
41 + "estrie": "Cantons-de-l'Est",
42 + "cantons de lest": "Cantons-de-l'Est",
43 + "saguenay": "Saguenay–Lac-Saint-Jean",
44 + "lac saint jean": "Saguenay–Lac-Saint-Jean",
45 + "lac-saint-jean": "Saguenay–Lac-Saint-Jean",
46 + "region de quebec": "Québec",
47 + "ville de quebec": "Québec",
48 + "capitale nationale": "Québec",
49 + "grand montreal": "Montréal",
50 + "iles de la madeleine": "Îles-de-la-Madeleine",
51 + "baie james": "Eeyou Istchee Baie-James",
52 +}
53 +
54 +
55 +def normalize_region(raw: str) -> str:
56 + """Ramène un libellé de région libre vers la forme canonique ("" si inconnu)."""
57 + if not raw:
58 + return ""
59 + key = strip_accents(raw).lower().strip()
60 + key = re.sub(r"[–—]", "-", key).replace("'", "").replace("’", "")
61 + key = re.sub(r"\s+", " ", key)
62 + if key in _REGION_ALIASES:
63 + return _REGION_ALIASES[key]
64 + k2 = key.replace(" ", "-")
65 + if k2 in _REGION_KEYS:
66 + return _REGION_KEYS[k2]
67 + if key in _REGION_KEYS:
68 + return _REGION_KEYS[key]
69 + for rk, canon in _REGION_KEYS.items():
70 + if rk.replace("-", " ") == key.replace("-", " "):
71 + return canon
72 + return raw.strip()
73 +
74 +
75 +_PRICE_RE = re.compile(r"(\d[\d\s,.  ]*)\s*\$")
76 +
77 +
78 +def parse_price_night(label: str) -> float | None:
79 + """Extrait un prix à la nuit d'un libellé (« 189 $ / nuit », « à partir de 250$ »)."""
80 + if not label:
81 + return None
82 + m = _PRICE_RE.search(label)
83 + if not m:
84 + return None
85 + raw = re.sub(r"[\s  ]", "", m.group(1))
86 + # « 1,250.00 » vs « 1 250,00 » : la dernière ponctuation est la décimale
87 + if "," in raw and "." in raw:
88 + raw = raw.replace(",", "") if raw.rfind(".") > raw.rfind(",") \
89 + else raw.replace(".", "").replace(",", ".")
90 + elif "," in raw:
91 + head, _, tail = raw.rpartition(",")
92 + raw = f"{head.replace(',', '')}.{tail}" if len(tail) == 2 else raw.replace(",", "")
93 + try:
94 + val = float(raw)
95 + except ValueError:
96 + return None
97 + lab = strip_accents(label).lower()
98 + if "sem" in lab or "/wk" in lab or "week" in lab: # prix à la semaine
99 + return round(val / 7, 2)
100 + if "mois" in lab or "month" in lab: # prix mensuel : hors sujet ici
101 + return None
102 + return val if 20 <= val <= 20000 else None
103 +
104 +
105 +_AMEN_FLAGS = {
106 + "spa": ("spa", "jacuzzi", "hot tub", "bain a remous", "bain tourbillon"),
107 + "pool": ("piscine", "pool"),
108 + "waterfront": ("bord de l'eau", "bord de leau", "bord du lac", "acces au lac",
109 + "acces lac", "waterfront", "lakefront", "riverain",
110 + "bord de riviere", "bord du fleuve", "plage privee"),
111 + "sauna": ("sauna",),
112 + "wifi": ("wifi", "wi-fi", "internet"),
113 + "fireplace": ("foyer", "fireplace", "poele a bois"),
114 + "ev_charger": ("borne", "recharge electrique", "ev charg"),
115 +}
116 +
117 +
118 +@dataclass
119 +class StListing:
120 + """Hébergement court terme standardisé Lou-Ka."""
121 +
122 + source: str # id de la source (data/sources_ct.json)
123 + external_id: str # identifiant chez la source
124 + url: str # page de l'annonce chez la source
125 + title: str = ""
126 + property_type: str = "" # voir PROPERTY_TYPES
127 + address: str = ""
128 + city: str = "" # municipalité (ex. Saint-Sauveur)
129 + region: str = "" # région touristique (ex. Laurentides)
130 + price_night: float | None = None # $ CAD / nuit (le plus bas si « à partir de »)
131 + price_label: str = "" # texte original (« à partir de 189 $/nuit »)
132 + capacity: float | None = None # nombre de personnes
133 + bedrooms: float | None = None
134 + beds: float | None = None
135 + bathrooms: float | None = None
136 + pets: str | None = None # "oui" | "non" | "conditions" | None
137 + citq: str = "" # numéro d'établissement CITQ si affiché
138 + rating: float | None = None # note (normalisée sur 5)
139 + reviews: int | None = None # nombre d'avis
140 + description: str = ""
141 + amenities: list[str] = field(default_factory=list)
142 + details: dict = field(default_factory=dict) # drapeaux (spa, pool…) + libres
143 + images: list[str] = field(default_factory=list)
144 + lat: float | None = None
145 + lng: float | None = None
146 +
147 + @property
148 + def uid(self) -> str:
149 + return f"{self.source}:{self.external_id}"
150 +
151 + def content_hash(self) -> str:
152 + blob = json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
153 + return hashlib.sha256(blob.encode("utf-8")).hexdigest()
154 +
155 + def finalize(self) -> "StListing":
156 + """Normalisation commune — idempotente, ne remplace jamais une valeur
157 + explicite du connecteur."""
158 + self.title = _html.unescape(self.title or "").strip()
159 + self.address = _html.unescape(self.address or "").strip()
160 + self.city = _html.unescape(self.city or "").strip()
161 + self.region = normalize_region(self.region)
162 + if self.property_type:
163 + pt = self.property_type.strip().capitalize()
164 + canon = {strip_accents(p).lower(): p for p in PROPERTY_TYPES}
165 + self.property_type = canon.get(strip_accents(pt).lower(), pt)
166 +
167 + if self.price_night is None:
168 + self.price_night = parse_price_night(self.price_label)
169 + if self.price_night is not None and not (20 <= self.price_night <= 20000):
170 + self.price_night = None
171 +
172 + # bornes du Québec : rejeter coordonnées aberrantes (0/0, inversées…)
173 + if self.lat is not None and self.lng is not None:
174 + if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):
175 + self.lat = self.lng = None
176 +
177 + # drapeaux dérivés des commodités + description (pour les filtres)
178 + haystack = strip_accents(" | ".join(self.amenities) + " | "
179 + + self.description[:2000]).lower()
180 + for flag, needles in _AMEN_FLAGS.items():
181 + if flag not in self.details and any(n in haystack for n in needles):
182 + self.details[flag] = True
183 + if self.pets is None:
184 + if re.search(r"animaux (acceptes|admis|bienvenus)|pet friendly|"
185 + r"chiens? (acceptes?|admis|bienvenus?)", haystack):
186 + self.pets = "oui"
187 + elif re.search(r"(pas d|aucun|non aux) animaux|animaux (non admis|"
188 + r"interdits|refuses)|no pets", haystack):
189 + self.pets = "non"
190 + if not self.citq:
191 + m = re.search(r"citq\D{0,12}(\d{6})", haystack)
192 + if m:
193 + self.citq = m.group(1)
194 + # note sur 10 (Booking) → sur 5
195 + if self.rating is not None and self.rating > 5.01:
196 + self.rating = round(self.rating / 2, 2)
197 + return self
added louka/shortterm/web.py +243 −0
@@ -0,0 +1,243 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Location court terme
3 +# web.py : routeur API /api/ct/* (liste, geojson, détail, facettes, stats)
4 +# — inclus par louka/web.py, entièrement séparé du long terme.
5 +# -----------------------------------------------------------------------------
6 +from __future__ import annotations
7 +
8 +import json
9 +import threading
10 +from pathlib import Path
11 +
12 +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
13 +
14 +from . import db
15 +
16 +router = APIRouter(prefix="/api/ct", tags=["court-terme"])
17 +
18 +SOURCES_CT_PATH = (Path(__file__).resolve().parent.parent.parent
19 + / "data" / "sources_ct.json")
20 +_sync_lock = threading.Lock()
21 +
22 +_SORTS = {
23 + "recent": " ORDER BY first_seen DESC, price_night IS NULL, price_night ASC",
24 + "prix": " ORDER BY price_night IS NULL, price_night ASC",
25 + "prix_desc": " ORDER BY price_night IS NULL, price_night DESC",
26 + "note": " ORDER BY rating IS NULL, rating DESC, reviews DESC",
27 +}
28 +
29 +
30 +def _row_to_dict(row) -> dict:
31 + d = dict(row)
32 + d["amenities"] = json.loads(d.get("amenities") or "[]")
33 + d["images"] = json.loads(d.get("images") or "[]")
34 + d["details"] = json.loads(d.get("details") or "{}")
35 + return d
36 +
37 +
38 +def _apply_filters(sql: str, args: list,
39 + region: str | None, city: str | None, type_: str | None,
40 + source: str | None, price_min: float | None,
41 + price_max: float | None, capacity_min: float | None,
42 + bedrooms_min: float | None, pets: str | None,
43 + spa: int | None, waterfront: int | None,
44 + q: str | None) -> str:
45 + if region:
46 + sql += " AND region=?"; args.append(region)
47 + if city:
48 + sql += " AND city LIKE ?"; args.append(f"%{city}%")
49 + if type_:
50 + sql += " AND property_type=?"; args.append(type_)
51 + if source:
52 + sql += " AND source=?"; args.append(source)
53 + if price_min is not None:
54 + sql += " AND price_night IS NOT NULL AND price_night>=?"
55 + args.append(price_min)
56 + if price_max is not None:
57 + sql += " AND price_night IS NOT NULL AND price_night<=?"
58 + args.append(price_max)
59 + if capacity_min is not None:
60 + sql += " AND capacity IS NOT NULL AND capacity>=?"
61 + args.append(capacity_min)
62 + if bedrooms_min is not None:
63 + sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"
64 + args.append(bedrooms_min)
65 + if pets == "oui":
66 + sql += " AND pets IN ('oui','conditions')"
67 + if spa == 1:
68 + sql += " AND json_extract(details,'$.spa')=1"
69 + if waterfront == 1:
70 + sql += " AND json_extract(details,'$.waterfront')=1"
71 + if q:
72 + sql += " AND (title LIKE ? OR city LIKE ? OR region LIKE ?)"
73 + args += [f"%{q}%"] * 3
74 + return sql
75 +
76 +
77 +@router.get("/listings")
78 +def ct_listings(
79 + region: str | None = None,
80 + city: str | None = None,
81 + type: str | None = None,
82 + source: str | None = None,
83 + price_min: float | None = None,
84 + price_max: float | None = None,
85 + capacity_min: float | None = None,
86 + bedrooms_min: float | None = None,
87 + pets: str | None = None,
88 + spa: int | None = None,
89 + waterfront: int | None = None,
90 + q: str | None = None,
91 + sort: str = "recent",
92 + limit: int = Query(24, le=200),
93 + offset: int = 0,
94 +):
95 + if sort not in _SORTS:
96 + raise HTTPException(400, f"sort inconnu : {sort}")
97 + con = db.connect()
98 + sql = "SELECT * FROM st_listings WHERE active=1"
99 + args: list = []
100 + sql = _apply_filters(sql, args, region, city, type, source, price_min,
101 + price_max, capacity_min, bedrooms_min, pets, spa,
102 + waterfront, q)
103 + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
104 + sql += _SORTS[sort] + " LIMIT ? OFFSET ?"
105 + args += [limit, offset]
106 + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]
107 + con.close()
108 + return {"total": total, "count": len(rows), "listings": rows}
109 +
110 +
111 +@router.get("/listings.geojson")
112 +def ct_geojson(
113 + region: str | None = None,
114 + city: str | None = None,
115 + type: str | None = None,
116 + source: str | None = None,
117 + price_min: float | None = None,
118 + price_max: float | None = None,
119 + capacity_min: float | None = None,
120 + bedrooms_min: float | None = None,
121 + pets: str | None = None,
122 + spa: int | None = None,
123 + waterfront: int | None = None,
124 + q: str | None = None,
125 + bbox: str | None = None,
126 + limit: int = Query(4000, le=10000),
127 +):
128 + con = db.connect()
129 + sql = ("SELECT uid, title, property_type, city, region, price_night,"
130 + " price_label, capacity, bedrooms, rating, source, images, lat, lng"
131 + " FROM st_listings WHERE active=1"
132 + " AND lat IS NOT NULL AND lng IS NOT NULL")
133 + args: list = []
134 + if bbox:
135 + try:
136 + west, south, east, north = (float(v) for v in bbox.split(","))
137 + except ValueError:
138 + raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")
139 + sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"
140 + args += [south, north, west, east]
141 + sql = _apply_filters(sql, args, region, city, type, source, price_min,
142 + price_max, capacity_min, bedrooms_min, pets, spa,
143 + waterfront, q)
144 + total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})",
145 + args).fetchone()["c"]
146 + sql += " ORDER BY first_seen DESC LIMIT ?"
147 + args.append(limit)
148 + features = []
149 + for r in con.execute(sql, args).fetchall():
150 + images = json.loads(r["images"] or "[]")
151 + features.append({
152 + "type": "Feature",
153 + "geometry": {"type": "Point",
154 + "coordinates": [r["lng"], r["lat"]]},
155 + "properties": {
156 + "uid": r["uid"], "title": r["title"],
157 + "property_type": r["property_type"], "city": r["city"],
158 + "region": r["region"], "price_night": r["price_night"],
159 + "price_label": r["price_label"], "capacity": r["capacity"],
160 + "bedrooms": r["bedrooms"], "rating": r["rating"],
161 + "source": r["source"],
162 + "image": images[0] if images else None,
163 + },
164 + })
165 + con.close()
166 + return {"type": "FeatureCollection", "features": features,
167 + "totalGeocoded": total_geo}
168 +
169 +
170 +@router.get("/facets")
171 +def ct_facets():
172 + con = db.connect()
173 + out = {
174 + "regions": [dict(r) for r in con.execute(
175 + "SELECT region, COUNT(*) n FROM st_listings WHERE active=1"
176 + " AND region<>'' GROUP BY region ORDER BY n DESC")],
177 + "types": [dict(r) for r in con.execute(
178 + "SELECT property_type AS type, COUNT(*) n FROM st_listings"
179 + " WHERE active=1 AND property_type<>''"
180 + " GROUP BY property_type ORDER BY n DESC")],
181 + "sources": [dict(r) for r in con.execute(
182 + "SELECT source, COUNT(*) n FROM st_listings WHERE active=1"
183 + " GROUP BY source ORDER BY n DESC")],
184 + }
185 + con.close()
186 + return out
187 +
188 +
189 +@router.get("/stats")
190 +def ct_stats():
191 + con = db.connect()
192 + row = con.execute(
193 + """SELECT COUNT(*) total, COUNT(DISTINCT source) sources,
194 + COUNT(DISTINCT region) regions,
195 + AVG(price_night) avg_night,
196 + SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded
197 + FROM st_listings WHERE active=1""").fetchone()
198 + log = [dict(r) for r in con.execute(
199 + "SELECT * FROM st_sync_log ORDER BY ts DESC LIMIT 20")]
200 + con.close()
201 + return {**dict(row), "recent_syncs": log}
202 +
203 +
204 +@router.get("/sources")
205 +def ct_sources():
206 + try:
207 + registry = json.loads(
208 + SOURCES_CT_PATH.read_text(encoding="utf-8"))["sources"]
209 + except (OSError, ValueError, KeyError):
210 + registry = []
211 + con = db.connect()
212 + counts = {r["source"]: r["n"] for r in con.execute(
213 + "SELECT source, COUNT(*) n FROM st_listings WHERE active=1"
214 + " GROUP BY source")}
215 + last = {r["source"]: r["ts"] for r in con.execute(
216 + "SELECT source, MAX(ts) ts FROM st_sync_log WHERE ok=1"
217 + " GROUP BY source")}
218 + con.close()
219 + for s in registry:
220 + s["active_listings"] = counts.get(s["id"], 0)
221 + s["last_sync"] = last.get(s["id"])
222 + return {"sources": registry}
223 +
224 +
225 +@router.get("/listings/{uid}")
226 +def ct_listing(uid: str):
227 + con = db.connect()
228 + row = con.execute("SELECT * FROM st_listings WHERE uid=?",
229 + (uid,)).fetchone()
230 + con.close()
231 + if row is None:
232 + raise HTTPException(404, "Hébergement introuvable")
233 + return _row_to_dict(row)
234 +
235 +
236 +@router.post("/sync")
237 +def ct_trigger_sync(background: BackgroundTasks, source: str | None = None):
238 + from . import ingest
239 + def _job():
240 + with _sync_lock:
241 + ingest.run([source] if source else None)
242 + background.add_task(_job)
243 + return {"status": "démarré", "source": source or "toutes"}
modified louka/web.py +6 −2
@@ -40,6 +40,10 @@ app.include_router(accounts.router)
40 40 from . import profile as user_profile # noqa: E402
41 41 app.include_router(user_profile.router)
42 42
43 +# section « Location court terme » — sous-système isolé (louka/shortterm/)
44 +from .shortterm import web as ct_web # noqa: E402
45 +app.include_router(ct_web.router)
46 +
43 47 # fichiers téléversés (photos de profil) — data/uploads/
44 48 _UPLOADS = ROOT / "data" / "uploads"
45 49 _UPLOADS.mkdir(parents=True, exist_ok=True)
@@ -770,8 +774,8 @@ if FRONTEND_DIST.exists():
770 774 # routeur React affiche sa page « introuvable », les moteurs de recherche
771 775 # voient un vrai 404 (fin des soft-404).
772 776 _CLIENT_ROUTES = {"profil", "favoris", "gestion", "bienvenue", "bot",
773 − "contact", "juste-valeur"}
774 − _CLIENT_PREFIXES = ("u/", "passerelle/")
777 + "contact", "juste-valeur", "court-terme"}
778 + _CLIENT_PREFIXES = ("u/", "passerelle/", "court-terme/")
775 779
776 780 @app.get("/{full_path:path}")
777 781 def spa(full_path: str):
modified run.py +9 −0
@@ -7,6 +7,8 @@
7 7 """Utilisation :
8 8 python run.py sync [source ...] # synchronise les annonces
9 9 python run.py watch [minutes] # synchronise en boucle (défaut 60 min)
10 + python run.py sync-ct [source ...] # synchronise la section court terme
11 + python run.py watch-ct [minutes] # boucle court terme (défaut 360 min)
10 12 python run.py serve [port] # démarre l'API + le frontend (défaut 8080)
11 13 python run.py record <source ...> # enregistre les fixtures de test d'une source
12 14 python run.py geocode [n] # géocode les annonces sans coordonnées (max n requêtes)
@@ -39,6 +41,13 @@ def main() -> None:
39 41 from louka import ingest
40 42 minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 60
41 43 ingest.watch(minutes * 60)
44 + elif cmd == "sync-ct":
45 + from louka.shortterm import ingest as ct_ingest
46 + ct_ingest.run(sys.argv[2:] or None)
47 + elif cmd == "watch-ct":
48 + from louka.shortterm import ingest as ct_ingest
49 + minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 360
50 + ct_ingest.watch(minutes * 60)
42 51 elif cmd == "geocode1":
43 52 from louka import geocode
44 53 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
45 54