court terme : registre des 38 sources sur la page Sources (statuts + flag connector) et SEO complet — SSR /court-terme et fiches (JSON-LD VacationRental), sitemaps sitemap-ct-N.xml, meta /sources
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5 changed files +316 −26
modified
data/sources_ct.json
+16 −16
@@ -3,16 +3,16 @@ | |||||
| 3 | { "id": "airbnb", "name": "Airbnb", "url": "https://www.airbnb.ca", "category": "internationale" }, | 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" }, | 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" }, | 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" }, | 6 | + { "id": "tripadvisor", "name": "TripAdvisor Rentals", "url": "https://www.tripadvisor.ca", "category": "internationale", "status": "fermée — produit locations arrêté (nov. 2024)" }, | ||
| 7 | { "id": "expedia", "name": "Expedia (locations de vacances)", "url": "https://www.expedia.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" }, | 8 | + { "id": "agoda", "name": "Agoda Homes", "url": "https://www.agoda.com", "category": "internationale", "status": "en attente de connecteur" }, | ||
| 9 | − { "id": "plumguide", "name": "Plum Guide", "url": "https://www.plumguide.com", "category": "internationale" }, | 9 | + { "id": "plumguide", "name": "Plum Guide", "url": "https://www.plumguide.com", "category": "internationale", "status": "en attente de connecteur" }, | ||
| 10 | { "id": "glampinghub", "name": "Glamping Hub", "url": "https://glampinghub.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" }, | 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" }, | 12 | + { "id": "marriott_hvmi", "name": "Marriott Homes & Villas", "url": "https://homes-and-villas.marriott.com", "category": "internationale", "status": "en attente de connecteur" }, | ||
| 13 | − { "id": "sonder", "name": "Sonder", "url": "https://www.sonder.com", "category": "internationale" }, | 13 | + { "id": "sonder", "name": "Sonder", "url": "https://www.sonder.com", "category": "internationale", "status": "fermée — liquidation (nov. 2025)" }, | ||
| 14 | − { "id": "blueground", "name": "Blueground", "url": "https://www.theblueground.com", "category": "internationale" }, | 14 | + { "id": "blueground", "name": "Blueground", "url": "https://www.theblueground.com", "category": "internationale", "status": "hors Québec" }, | ||
| 15 | − { "id": "furnishedfinder", "name": "Furnished Finder", "url": "https://www.furnishedfinder.com", "category": "internationale" }, | 15 | + { "id": "furnishedfinder", "name": "Furnished Finder", "url": "https://www.furnishedfinder.com", "category": "internationale", "status": "en attente de connecteur" }, | ||
| 16 | { "id": "corporatestays", "name": "Corporate Stays", "url": "https://www.corporatestays.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" }, | 17 | { "id": "monsieurchalets", "name": "MonsieurChalets", "url": "https://www.monsieurchalets.com", "category": "quebecoise" }, | ||
| 18 | { "id": "wechalet", "name": "WeChalet", "url": "https://wechalet.com", "category": "quebecoise" }, | 18 | { "id": "wechalet", "name": "WeChalet", "url": "https://wechalet.com", "category": "quebecoise" }, | ||
@@ -21,22 +21,22 @@ | |||||
| 21 | { "id": "mcal", "name": "Maisons et chalets à louer", "url": "https://maisonsetchaletsalouer.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" }, | 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" }, | 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" }, | 24 | + { "id": "chaletsquebec", "name": "Chalets Québec", "url": "https://www.chaletsquebec.com", "category": "quebecoise", "status": "site disparu" }, | ||
| 25 | { "id": "chaletsauquebec", "name": "Chalets au Québec", "url": "https://www.chaletsauquebec.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" }, | 26 | + { "id": "hebergementquebec", "name": "Hébergement Québec", "url": "https://hebergementquebec.com", "category": "quebecoise", "status": "site disparu" }, | ||
| 27 | − { "id": "chaletsdirect", "name": "ChaletsDirect", "url": "https://www.chaletsdirect.com", "category": "quebecoise" }, | 27 | + { "id": "chaletsdirect", "name": "ChaletsDirect", "url": "https://www.chaletsdirect.com", "category": "quebecoise", "status": "hors Québec" }, | ||
| 28 | − { "id": "locationschalets", "name": "LocationsChalets", "url": "https://www.locationschalets.com", "category": "quebecoise" }, | 28 | + { "id": "locationschalets", "name": "LocationsChalets", "url": "https://www.locationschalets.com", "category": "quebecoise", "status": "site disparu" }, | ||
| 29 | { "id": "tremblant_living", "name": "Tremblant Living", "url": "https://www.tremblantliving.com", "category": "agence" }, | 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" }, | 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" }, | 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" }, | 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" }, | 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" }, | 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" }, | 35 | + { "id": "louer_ca_ct", "name": "Louer.ca (court terme)", "url": "https://www.louer.ca", "category": "relogement", "status": "en attente de connecteur" }, | ||
| 36 | { "id": "kijiji_ct", "name": "Kijiji (court terme)", "url": "https://www.kijiji.ca", "category": "petites-annonces" }, | 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" }, | 37 | + { "id": "lespac_ct", "name": "LesPAC (court terme)", "url": "https://www.lespac.com", "category": "petites-annonces", "status": "en attente de connecteur" }, | ||
| 38 | − { "id": "fb_marketplace_ct", "name": "Facebook Marketplace (court terme)", "url": "https://www.facebook.com/marketplace", "category": "petites-annonces" }, | 38 | + { "id": "fb_marketplace_ct", "name": "Facebook Marketplace (court terme)", "url": "https://www.facebook.com/marketplace", "category": "petites-annonces", "status": "en attente de connecteur" }, | ||
| 39 | − { "id": "craigslist_ct", "name": "Craigslist (court terme)", "url": "https://montreal.craigslist.org", "category": "petites-annonces" }, | 39 | + { "id": "craigslist_ct", "name": "Craigslist (court terme)", "url": "https://montreal.craigslist.org", "category": "petites-annonces", "status": "en attente de connecteur" }, | ||
| 40 | − { "id": "bonjourresidences", "name": "Bonjour Résidences", "url": "https://www.bonjourresidences.com", "category": "cas-particuliers" } | 40 | + { "id": "bonjourresidences", "name": "Bonjour Résidences", "url": "https://www.bonjourresidences.com", "category": "cas-particuliers", "status": "en attente de connecteur" } | ||
| 41 | ] | 41 | ] | ||
| 42 | } | 42 | } | ||
modified
frontend/src/ctapi.ts
+2 −0
@@ -65,6 +65,8 @@ export interface CtSource { | |||||
| 65 | name: string; | 65 | name: string; | ||
| 66 | url: string; | 66 | url: string; | ||
| 67 | category: string; | 67 | category: string; | ||
| 68 | + status?: string; | ||||
| 69 | + connector: boolean; | ||||
| 68 | active_listings: number; | 70 | active_listings: number; | ||
| 69 | last_sync: number | null; | 71 | last_sync: number | null; | ||
| 70 | } | 72 | } | ||
modified
frontend/src/pages/Sources.tsx
+84 −5
@@ -1,35 +1,61 @@ | |||||
| 1 | // ----------------------------------------------------------------------------- | 1 | // ----------------------------------------------------------------------------- | ||
| 2 | // Lou-Ka — Agrégateur de logements à louer (province de Québec) | 2 | // Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||
| 3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | 3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||
| 4 | −// pages/Sources.tsx : registre des gestionnaires immobiliers agrégés | 4 | +// pages/Sources.tsx : registre des sources agrégées — gestionnaires immobiliers | ||
| 5 | +// (long terme) et plateformes de location court terme | ||||
| 5 | // ----------------------------------------------------------------------------- | 6 | // ----------------------------------------------------------------------------- | ||
| 6 | import { useEffect, useState } from "react"; | 7 | import { useEffect, useState } from "react"; | ||
| 7 | import { Source, fetchSources } from "../api"; | 8 | import { Source, fetchSources } from "../api"; | ||
| 9 | +import { CtSource, fetchCtSources } from "../ctapi"; | ||||
| 10 | + | ||||
| 11 | +const CT_CATEGORIES: Record<string, string> = { | ||||
| 12 | + internationale: "Plateforme internationale", | ||||
| 13 | + quebecoise: "Plateforme québécoise", | ||||
| 14 | + agence: "Agence de location", | ||||
| 15 | + gites: "Gîtes et auberges", | ||||
| 16 | + officielle: "Source officielle", | ||||
| 17 | + relogement: "Relogement", | ||||
| 18 | + "petites-annonces": "Petites annonces", | ||||
| 19 | + "cas-particuliers": "Cas particulier", | ||||
| 20 | +}; | ||||
| 8 | 21 | ||||
| 9 | export default function SourcesPage() { | 22 | export default function SourcesPage() { | ||
| 10 | const [sources, setSources] = useState<Source[] | null>(null); | 23 | const [sources, setSources] = useState<Source[] | null>(null); | ||
| 24 | + const [ctSources, setCtSources] = useState<CtSource[] | null>(null); | ||||
| 11 | const [error, setError] = useState<string | null>(null); | 25 | const [error, setError] = useState<string | null>(null); | ||
| 12 | 26 | ||||
| 13 | useEffect(() => { | 27 | useEffect(() => { | ||
| 14 | fetchSources() | 28 | fetchSources() | ||
| 15 | .then((r) => setSources(r.sources)) | 29 | .then((r) => setSources(r.sources)) | ||
| 16 | .catch((e) => setError(String(e))); | 30 | .catch((e) => setError(String(e))); | ||
| 31 | + fetchCtSources() | ||||
| 32 | + .then((r) => setCtSources(r.sources)) | ||||
| 33 | + .catch(() => setCtSources([])); | ||||
| 17 | }, []); | 34 | }, []); | ||
| 18 | 35 | ||||
| 36 | + const ctConnected = ctSources?.filter((s) => s.connector).length ?? 0; | ||||
| 37 | + | ||||
| 19 | return ( | 38 | return ( | ||
| 20 | <div className="container sources"> | 39 | <div className="container sources"> | ||
| 21 | − <span className="kicker">Registre — gestionnaires immobiliers</span> | 40 | + <span className="kicker">Registre — sources agrégées</span> | ||
| 22 | <h1>Sources agrégées</h1> | 41 | <h1>Sources agrégées</h1> | ||
| 23 | <p className="sub"> | 42 | <p className="sub"> | ||
| 24 | − Gestionnaires immobiliers de Québec, Lévis et du Grand Montréal recensés par Lou-Ka. Chaque source « | 43 | + Toutes les sources recensées par Lou-Ka : gestionnaires immobiliers | ||
| 25 | − active » est synchronisée périodiquement par un connecteur dédié; les autres sont en | 44 | + (logements au mois) et plateformes de location court terme (chalets et | ||
| 26 | − attente de connecteur. | 45 | + hébergements à la nuit). Chaque source « connectée » est synchronisée | ||
| 46 | + périodiquement par un connecteur dédié. | ||||
| 27 | </p> | 47 | </p> | ||
| 28 | 48 | ||||
| 29 | {error && <div className="notice">⚠️ {error}</div>} | 49 | {error && <div className="notice">⚠️ {error}</div>} | ||
| 30 | {!sources && !error && <div className="notice">Chargement…</div>} | 50 | {!sources && !error && <div className="notice">Chargement…</div>} | ||
| 31 | 51 | ||||
| 32 | {sources && ( | 52 | {sources && ( | ||
| 53 | + <> | ||||
| 54 | + <h2 style={{ marginTop: 28 }}>Logements au mois — gestionnaires immobiliers</h2> | ||||
| 55 | + <p className="sub"> | ||||
| 56 | + Gestionnaires immobiliers de Québec, Lévis et du Grand Montréal; les | ||||
| 57 | + sources sans connecteur sont en attente. | ||||
| 58 | + </p> | ||||
| 33 | <div className="src-wrap"> | 59 | <div className="src-wrap"> | ||
| 34 | <table className="src-table"> | 60 | <table className="src-table"> | ||
| 35 | <thead> | 61 | <thead> | ||
@@ -68,6 +94,59 @@ export default function SourcesPage() { | |||||
| 68 | </tbody> | 94 | </tbody> | ||
| 69 | </table> | 95 | </table> | ||
| 70 | </div> | 96 | </div> | ||
| 97 | + </> | ||||
| 98 | + )} | ||||
| 99 | + | ||||
| 100 | + {ctSources && ctSources.length > 0 && ( | ||||
| 101 | + <> | ||||
| 102 | + <h2 style={{ marginTop: 40 }}>Location court terme — plateformes</h2> | ||||
| 103 | + <p className="sub"> | ||||
| 104 | + Plateformes de chalets, séjours et hébergements à la nuit couvrant le | ||||
| 105 | + Québec — {ctConnected} connecteurs actifs sur {ctSources.length}{" "} | ||||
| 106 | + sources recensées. Les sources fermées ou sans inventaire québécois | ||||
| 107 | + sont documentées. | ||||
| 108 | + </p> | ||||
| 109 | + <div className="src-wrap"> | ||||
| 110 | + <table className="src-table"> | ||||
| 111 | + <thead> | ||||
| 112 | + <tr> | ||||
| 113 | + <th>Plateforme</th> | ||||
| 114 | + <th>Catégorie</th> | ||||
| 115 | + <th>Statut</th> | ||||
| 116 | + <th style={{ textAlign: "right" }}>Hébergements actifs</th> | ||||
| 117 | + <th>Dernière synchro</th> | ||||
| 118 | + </tr> | ||||
| 119 | + </thead> | ||||
| 120 | + <tbody> | ||||
| 121 | + {ctSources.map((s) => ( | ||||
| 122 | + <tr key={s.id}> | ||||
| 123 | + <td> | ||||
| 124 | + <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a> | ||||
| 125 | + </td> | ||||
| 126 | + <td style={{ color: "var(--ink-2)", maxWidth: 380 }}> | ||||
| 127 | + {CT_CATEGORIES[s.category] ?? s.category} | ||||
| 128 | + </td> | ||||
| 129 | + <td> | ||||
| 130 | + {s.connector ? ( | ||||
| 131 | + <span className="pill ok">connecté</span> | ||||
| 132 | + ) : ( | ||||
| 133 | + <span className="pill todo">{s.status || "en attente"}</span> | ||||
| 134 | + )} | ||||
| 135 | + </td> | ||||
| 136 | + <td style={{ textAlign: "right" }}> | ||||
| 137 | + <span className="count-pill">{s.active_listings || "—"}</span> | ||||
| 138 | + </td> | ||||
| 139 | + <td style={{ color: "var(--ink-3)" }}> | ||||
| 140 | + {s.last_sync | ||||
| 141 | + ? new Date(s.last_sync * 1000).toLocaleString("fr-CA") | ||||
| 142 | + : "—"} | ||||
| 143 | + </td> | ||||
| 144 | + </tr> | ||||
| 145 | + ))} | ||||
| 146 | + </tbody> | ||||
| 147 | + </table> | ||||
| 148 | + </div> | ||||
| 149 | + </> | ||||
| 71 | )} | 150 | )} | ||
| 72 | </div> | 151 | </div> | ||
| 73 | ); | 152 | ); | ||
modified
louka/seo.py
+212 −5
@@ -26,6 +26,7 @@ from fastapi import APIRouter, HTTPException | |||||
| 26 | from fastapi.responses import HTMLResponse, PlainTextResponse, Response | 26 | from fastapi.responses import HTMLResponse, PlainTextResponse, Response | ||
| 27 | 27 | ||||
| 28 | from . import db | 28 | from . import db | ||
| 29 | +from .shortterm import db as ctdb | ||||
| 29 | 30 | ||||
| 30 | router = APIRouter() | 31 | router = APIRouter() | ||
| 31 | 32 | ||||
@@ -295,9 +296,15 @@ def sitemap_index(): | |||||
| 295 | con = db.connect() | 296 | con = db.connect() | ||
| 296 | total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"] | 297 | total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"] | ||
| 297 | con.close() | 298 | con.close() | ||
| 299 | + ct_con = ctdb.connect() | ||||
| 300 | + ct_total = ct_con.execute( | ||||
| 301 | + "SELECT COUNT(*) c FROM st_listings WHERE active=1").fetchone()["c"] | ||||
| 302 | + ct_con.close() | ||||
| 298 | chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) | 303 | chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) | ||
| 299 | − names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [ | 304 | + ct_chunks = max(1, math.ceil(ct_total / SITEMAP_CHUNK)) | ||
| 300 | − f"sitemap-annonces-{i}.xml" for i in range(1, chunks + 1)] | 305 | + names = (["sitemap-pages.xml", "sitemap-villes.xml"] | ||
| 306 | + + [f"sitemap-annonces-{i}.xml" for i in range(1, chunks + 1)] | ||||
| 307 | + + [f"sitemap-ct-{i}.xml" for i in range(1, ct_chunks + 1)]) | ||||
| 301 | today = date.today().isoformat() | 308 | today = date.today().isoformat() | ||
| 302 | rows = "\n".join( | 309 | rows = "\n".join( | ||
| 303 | f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>" | 310 | f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>" | ||
@@ -310,6 +317,7 @@ def sitemap_index(): | |||||
| 310 | def sitemap_pages(): | 317 | def sitemap_pages(): | ||
| 311 | urls: list[tuple[str, str | None]] = [ | 318 | urls: list[tuple[str, str | None]] = [ | ||
| 312 | (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None), | 319 | (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None), | ||
| 320 | + (f"{BASE_URL}/court-terme", None), | ||||
| 313 | (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None), | 321 | (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None), | ||
| 314 | (f"{BASE_URL}/confidentialite", None), (f"{BASE_URL}/conditions", None)] | 322 | (f"{BASE_URL}/confidentialite", None), (f"{BASE_URL}/conditions", None)] | ||
| 315 | con = db.connect() | 323 | con = db.connect() | ||
@@ -353,6 +361,22 @@ def sitemap_annonces(num: int): | |||||
| 353 | for r in rows]) | 361 | for r in rows]) | ||
| 354 | 362 | ||||
| 355 | 363 | ||||
| 364 | +@router.get("/sitemap-ct-{num}.xml", include_in_schema=False) | ||||
| 365 | +def sitemap_ct(num: int): | ||||
| 366 | + if num < 1: | ||||
| 367 | + raise HTTPException(404) | ||||
| 368 | + con = ctdb.connect() | ||||
| 369 | + rows = con.execute( | ||||
| 370 | + """SELECT uid, updated_at FROM st_listings WHERE active=1 | ||||
| 371 | + ORDER BY uid LIMIT ? OFFSET ?""", | ||||
| 372 | + (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall() | ||||
| 373 | + con.close() | ||||
| 374 | + if not rows: | ||||
| 375 | + raise HTTPException(404) | ||||
| 376 | + return _urlset([(f"{BASE_URL}/court-terme/{r['uid']}", _iso(r["updated_at"])) | ||||
| 377 | + for r in rows]) | ||||
| 378 | + | ||||
| 379 | + | ||||
| 356 | # --- Pages SSR ---------------------------------------------------------------- | 380 | # --- Pages SSR ---------------------------------------------------------------- | ||
| 357 | 381 | ||||
| 358 | @router.get("/", include_in_schema=False) | 382 | @router.get("/", include_in_schema=False) | ||
@@ -638,14 +662,197 @@ def gestionnaire_ssr(source_id: str): | |||||
| 638 | jsonld=jsonld, body=body) | 662 | jsonld=jsonld, body=body) | ||
| 639 | 663 | ||||
| 640 | 664 | ||||
| 665 | +# --- Court terme (chalets & hébergements à la nuit) --------------------------- | ||||
| 666 | + | ||||
| 667 | +# bornes de plausibilité d'un prix à la nuit | ||||
| 668 | +CT_PRICE_MIN, CT_PRICE_MAX = 25, 10000 | ||||
| 669 | + | ||||
| 670 | + | ||||
| 671 | +def _ct_price_ok(p) -> bool: | ||||
| 672 | + return p is not None and CT_PRICE_MIN <= p <= CT_PRICE_MAX | ||||
| 673 | + | ||||
| 674 | + | ||||
| 675 | +def _ct_li(r) -> str: | ||||
| 676 | + bits = [b for b in ( | ||||
| 677 | + r["property_type"], | ||||
| 678 | + f"{_fmt_price(r['price_night'])}/nuit" if _ct_price_ok(r["price_night"]) else "", | ||||
| 679 | + r["city"] or r["region"]) if b] | ||||
| 680 | + return (f'<li><a href="/court-terme/{_e(r["uid"])}">{_e(r["title"] or r["uid"])}</a>' | ||||
| 681 | + f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>') | ||||
| 682 | + | ||||
| 683 | + | ||||
| 684 | +@router.get("/court-terme", include_in_schema=False) | ||||
| 685 | +def court_terme_ssr(): | ||||
| 686 | + con = ctdb.connect() | ||||
| 687 | + total = con.execute( | ||||
| 688 | + "SELECT COUNT(*) c FROM st_listings WHERE active=1").fetchone()["c"] | ||||
| 689 | + nsources = con.execute( | ||||
| 690 | + "SELECT COUNT(DISTINCT source) c FROM st_listings WHERE active=1").fetchone()["c"] | ||||
| 691 | + regions = [dict(r) for r in con.execute( | ||||
| 692 | + "SELECT region, COUNT(*) n FROM st_listings WHERE active=1" | ||||
| 693 | + " AND region<>'' GROUP BY region ORDER BY n DESC")] | ||||
| 694 | + types = [dict(r) for r in con.execute( | ||||
| 695 | + "SELECT property_type t, COUNT(*) n FROM st_listings WHERE active=1" | ||||
| 696 | + " AND property_type<>'' GROUP BY property_type ORDER BY n DESC")] | ||||
| 697 | + recents = con.execute( | ||||
| 698 | + f"""SELECT uid, title, property_type, city, region, price_night | ||||
| 699 | + FROM st_listings WHERE active=1 | ||||
| 700 | + AND price_night BETWEEN {CT_PRICE_MIN} AND {CT_PRICE_MAX} | ||||
| 701 | + ORDER BY first_seen DESC LIMIT 20""").fetchall() | ||||
| 702 | + con.close() | ||||
| 703 | + | ||||
| 704 | + title = (f"Location court terme au Québec — {total:,} chalets et hébergements" | ||||
| 705 | + " | Lou-Ka").replace(",", " ") | ||||
| 706 | + description = (f"{total:,} chalets, condos et hébergements à louer à la nuit " | ||||
| 707 | + f"partout au Québec, agrégés depuis {nsources} plateformes " | ||||
| 708 | + "(Airbnb, Vrbo, Booking, WeChalet, Sépaq et plus) : " | ||||
| 709 | + "Laurentides, Charlevoix, Cantons-de-l'Est, Lanaudière… " | ||||
| 710 | + "Prix par nuit, photos et lien direct vers l'annonce " | ||||
| 711 | + "originale.").replace(",", " ") | ||||
| 712 | + body = ( | ||||
| 713 | + f"<h1>Location court terme au Québec — {total:,} hébergements</h1>".replace(",", " ") | ||||
| 714 | + + f"<p>Lou-Ka agrège les chalets et hébergements à la nuit publiés sur " | ||||
| 715 | + f"{nsources} plateformes de location court terme couvrant le Québec : " | ||||
| 716 | + "chaque fiche avec ses photos, son prix par nuit, sa capacité et un " | ||||
| 717 | + "lien direct vers l'annonce originale.</p>" | ||||
| 718 | + + "<h2>Par région</h2><ul>" | ||||
| 719 | + + "".join(f"<li>{_e(r['region'])} — {r['n']} hébergements</li>" | ||||
| 720 | + for r in regions[:20]) | ||||
| 721 | + + "</ul><h2>Par type d'hébergement</h2><ul>" | ||||
| 722 | + + "".join(f"<li>{_e(t['t'])} — {t['n']}</li>" for t in types[:12]) | ||||
| 723 | + + "</ul><h2>Derniers hébergements recensés</h2><ul>" | ||||
| 724 | + + "".join(_ct_li(r) for r in recents) | ||||
| 725 | + + '</ul><p><a href="/">Logements à louer au mois</a> · ' | ||||
| 726 | + '<a href="/sources">Toutes les sources</a></p>') | ||||
| 727 | + jsonld = [ | ||||
| 728 | + _breadcrumb([("Accueil", "/"), ("Court terme", "/court-terme")]), | ||||
| 729 | + {"@context": "https://schema.org", "@type": "ItemList", | ||||
| 730 | + "name": "Location court terme au Québec", | ||||
| 731 | + "numberOfItems": total, | ||||
| 732 | + "itemListElement": [ | ||||
| 733 | + {"@type": "ListItem", "position": i + 1, | ||||
| 734 | + "name": r["title"] or r["uid"], | ||||
| 735 | + "url": f"{BASE_URL}/court-terme/{r['uid']}"} | ||||
| 736 | + for i, r in enumerate(recents)]}] | ||||
| 737 | + return _render(title=title, description=description, path="/court-terme", | ||||
| 738 | + jsonld=jsonld, body=body) | ||||
| 739 | + | ||||
| 740 | + | ||||
| 741 | +@router.get("/court-terme/{uid:path}", include_in_schema=False) | ||||
| 742 | +def court_terme_fiche_ssr(uid: str): | ||||
| 743 | + con = ctdb.connect() | ||||
| 744 | + row = con.execute("SELECT * FROM st_listings WHERE uid=?", (uid,)).fetchone() | ||||
| 745 | + con.close() | ||||
| 746 | + if row is None: | ||||
| 747 | + return _render(title="Hébergement introuvable | Lou-Ka", | ||||
| 748 | + description="Cet hébergement n'existe pas ou plus sur Lou-Ka.", | ||||
| 749 | + path=f"/court-terme/{uid}", | ||||
| 750 | + body="<h1>Hébergement introuvable</h1>" | ||||
| 751 | + '<p><a href="/court-terme">Voir tous les hébergements ' | ||||
| 752 | + "court terme au Québec</a></p>", | ||||
| 753 | + status=404) | ||||
| 754 | + d = dict(row) | ||||
| 755 | + d["amenities"] = json.loads(d.get("amenities") or "[]") | ||||
| 756 | + d["images"] = json.loads(d.get("images") or "[]") | ||||
| 757 | + if not d["active"]: | ||||
| 758 | + return _render( | ||||
| 759 | + title="Hébergement retiré | Lou-Ka", | ||||
| 760 | + description="Cet hébergement a été retiré de la plateforme d'origine.", | ||||
| 761 | + path=f"/court-terme/{uid}", | ||||
| 762 | + body="<h1>Cet hébergement n'est plus disponible</h1>" | ||||
| 763 | + '<p>Il a été retiré de la plateforme d\'origine. ' | ||||
| 764 | + '<a href="/court-terme">Tous les hébergements court terme</a>.</p>', | ||||
| 765 | + status=410) | ||||
| 766 | + | ||||
| 767 | + label = d["title"] or "Hébergement court terme" | ||||
| 768 | + where = ", ".join(p for p in (d["city"], d["region"]) if p and p not in label) | ||||
| 769 | + title = (f"{d['property_type'] + ' à louer — ' if d['property_type'] else ''}" | ||||
| 770 | + f"{label}" + (f", {where}" if where else "") + " | Lou-Ka") | ||||
| 771 | + price_txt = (f"{_fmt_price(d['price_night'])}/nuit" | ||||
| 772 | + if _ct_price_ok(d["price_night"]) else (d["price_label"] or "")) | ||||
| 773 | + bits = [b for b in ( | ||||
| 774 | + d["property_type"], price_txt, | ||||
| 775 | + f"{int(d['capacity'])} personnes" if d["capacity"] else "", | ||||
| 776 | + f"{int(d['bedrooms'])} chambres" if d["bedrooms"] else "", | ||||
| 777 | + d["city"], d["region"]) if b] | ||||
| 778 | + description = (" · ".join(bits) + ". " if bits else "") + \ | ||||
| 779 | + (d["description"][:150].strip() + "…" if len(d["description"] or "") > 150 | ||||
| 780 | + else (d["description"] or "")).strip() | ||||
| 781 | + description = (description[:300] | ||||
| 782 | + or f"Hébergement court terme à {d['city'] or 'louer'} sur Lou-Ka.") | ||||
| 783 | + | ||||
| 784 | + body = [f"<h1>{_e(label)}" | ||||
| 785 | + f"{' — ' + _e(d['property_type']) if d['property_type'] else ''}</h1>"] | ||||
| 786 | + facts = [("Type", d["property_type"]), ("Ville", d["city"]), | ||||
| 787 | + ("Région", d["region"]), ("Prix", price_txt), | ||||
| 788 | + ("Capacité", f"{int(d['capacity'])} personnes" if d["capacity"] else ""), | ||||
| 789 | + ("Chambres", int(d["bedrooms"]) if d["bedrooms"] else ""), | ||||
| 790 | + ("Salles de bain", d["bathrooms"] or ""), | ||||
| 791 | + ("Note", f"{d['rating']}/5 ({d['reviews']} avis)" | ||||
| 792 | + if d["rating"] and d["reviews"] else ""), | ||||
| 793 | + ("Enregistrement CITQ", d["citq"] or "")] | ||||
| 794 | + body.append("<ul>" + "".join(f"<li><strong>{k}</strong> : {_e(v)}</li>" | ||||
| 795 | + for k, v in facts if v) + "</ul>") | ||||
| 796 | + if d["description"]: | ||||
| 797 | + body.append(f"<p>{_e(d['description'][:600])}</p>") | ||||
| 798 | + if d["amenities"]: | ||||
| 799 | + body.append("<p><strong>Commodités</strong> : " | ||||
| 800 | + + _e(", ".join(d["amenities"][:15])) + "</p>") | ||||
| 801 | + if d["url"]: | ||||
| 802 | + body.append(f'<p><a href="{_e(d["url"])}" rel="nofollow">' | ||||
| 803 | + "Voir l'annonce originale sur la plateforme</a></p>") | ||||
| 804 | + body.append('<p><a href="/court-terme">Tous les hébergements court terme ' | ||||
| 805 | + "au Québec</a></p>") | ||||
| 806 | + | ||||
| 807 | + rental: dict = { | ||||
| 808 | + "@context": "https://schema.org", "@type": "VacationRental", | ||||
| 809 | + "name": label, "url": f"{BASE_URL}/court-terme/{uid}", | ||||
| 810 | + "inLanguage": "fr-CA", | ||||
| 811 | + "address": {"@type": "PostalAddress", | ||||
| 812 | + "addressLocality": d["city"] or None, | ||||
| 813 | + "addressRegion": "QC", "addressCountry": "CA"}} | ||||
| 814 | + if d["lat"] and d["lng"]: | ||||
| 815 | + rental["latitude"], rental["longitude"] = d["lat"], d["lng"] | ||||
| 816 | + if d["images"]: | ||||
| 817 | + rental["image"] = d["images"][:5] | ||||
| 818 | + if d["bedrooms"]: | ||||
| 819 | + rental["numberOfBedrooms"] = int(d["bedrooms"]) | ||||
| 820 | + if d["capacity"]: | ||||
| 821 | + rental["occupancy"] = {"@type": "QuantitativeValue", | ||||
| 822 | + "maxValue": int(d["capacity"]), | ||||
| 823 | + "unitText": "personnes"} | ||||
| 824 | + if d["rating"] and d["reviews"]: | ||||
| 825 | + rental["aggregateRating"] = {"@type": "AggregateRating", | ||||
| 826 | + "ratingValue": d["rating"], | ||||
| 827 | + "reviewCount": d["reviews"], | ||||
| 828 | + "bestRating": 5} | ||||
| 829 | + jsonld: list[dict] = [rental] | ||||
| 830 | + if _ct_price_ok(d["price_night"]): | ||||
| 831 | + jsonld.append({ | ||||
| 832 | + "@context": "https://schema.org", "@type": "Offer", | ||||
| 833 | + "itemOffered": {"@type": "VacationRental", "name": label}, | ||||
| 834 | + "price": d["price_night"], "priceCurrency": "CAD", | ||||
| 835 | + "availability": "https://schema.org/InStock", | ||||
| 836 | + "url": f"{BASE_URL}/court-terme/{uid}"}) | ||||
| 837 | + crumbs = [("Accueil", "/"), ("Court terme", "/court-terme"), | ||||
| 838 | + (label, f"/court-terme/{uid}")] | ||||
| 839 | + jsonld.append(_breadcrumb(crumbs)) | ||||
| 840 | + return _render(title=title, description=description, | ||||
| 841 | + path=f"/court-terme/{uid}", jsonld=jsonld, | ||||
| 842 | + body="".join(body), | ||||
| 843 | + og_image=d["images"][0] if d["images"] else None) | ||||
| 844 | + | ||||
| 845 | + | ||||
| 641 | # pages statiques de l'app : head unique, contenu rendu par React | 846 | # pages statiques de l'app : head unique, contenu rendu par React | ||
| 642 | _STATIC_META = { | 847 | _STATIC_META = { | ||
| 643 | "/stats": ("Statistiques du marché locatif québécois | Lou-Ka", | 848 | "/stats": ("Statistiques du marché locatif québécois | Lou-Ka", | ||
| 644 | "Loyers moyens et médians, répartition par ville et par type de logement : " | 849 | "Loyers moyens et médians, répartition par ville et par type de logement : " | ||
| 645 | "les statistiques du marché locatif québécois, calculées en continu par Lou-Ka."), | 850 | "les statistiques du marché locatif québécois, calculées en continu par Lou-Ka."), | ||
| 646 | − "/sources": ("Gestionnaires immobiliers recensés | Lou-Ka", | 851 | + "/sources": ("Sources agrégées — gestionnaires et plateformes court terme | Lou-Ka", | ||
| 647 | − "La liste des gestionnaires immobiliers du Québec dont Lou-Ka agrège les " | 852 | + "Toutes les sources agrégées par Lou-Ka : gestionnaires immobiliers du " | ||
| 648 | − "logements à louer, avec le nombre d'annonces actives de chacun."), | 853 | + "Québec (logements au mois) et plateformes de location court terme " | ||
| 854 | + "(Airbnb, Vrbo, Booking, WeChalet, Sépaq…), avec le nombre d'annonces " | ||||
| 855 | + "actives de chacune."), | ||||
| 649 | "/confidentialite": ("Politique de confidentialité | Lou-Ka", | 856 | "/confidentialite": ("Politique de confidentialité | Lou-Ka", | ||
| 650 | "Politique de confidentialité de Lou-Ka : données collectées, " | 857 | "Politique de confidentialité de Lou-Ka : données collectées, " | ||
| 651 | "témoins (cookies) et droits des utilisateurs."), | 858 | "témoins (cookies) et droits des utilisateurs."), | ||
modified
louka/shortterm/web.py
+2 −0
@@ -203,6 +203,7 @@ def ct_stats(): | |||||
| 203 | 203 | ||||
| 204 | @router.get("/sources") | 204 | @router.get("/sources") | ||
| 205 | def ct_sources(): | 205 | def ct_sources(): | ||
| 206 | + from .connectors import ST_CONNECTORS | ||||
| 206 | try: | 207 | try: | ||
| 207 | registry = json.loads( | 208 | registry = json.loads( | ||
| 208 | SOURCES_CT_PATH.read_text(encoding="utf-8"))["sources"] | 209 | SOURCES_CT_PATH.read_text(encoding="utf-8"))["sources"] | ||
@@ -217,6 +218,7 @@ def ct_sources(): | |||||
| 217 | " GROUP BY source")} | 218 | " GROUP BY source")} | ||
| 218 | con.close() | 219 | con.close() | ||
| 219 | for s in registry: | 220 | for s in registry: | ||
| 221 | + s["connector"] = s["id"] in ST_CONNECTORS | ||||
| 220 | s["active_listings"] = counts.get(s["id"], 0) | 222 | s["active_listings"] = counts.get(s["id"], 0) | ||
| 221 | s["last_sync"] = last.get(s["id"]) | 223 | s["last_sync"] = last.get(s["id"]) | ||
| 222 | return {"sources": registry} | 224 | return {"sources": registry} | ||
| 223 | 225 | ||||