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>
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¤cy=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 | |