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 | 3 | { "id": "airbnb", "name": "Airbnb", "url": "https://www.airbnb.ca", "category": "internationale" }, |
| 4 | 4 | { "id": "booking", "name": "Booking.com", "url": "https://www.booking.com", "category": "internationale" }, |
| 5 | 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 | 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" }, | |
| 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", "status": "en attente de connecteur" }, | |
| 10 | 10 | { "id": "glampinghub", "name": "Glamping Hub", "url": "https://glampinghub.com", "category": "internationale" }, |
| 11 | 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" }, | |
| 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", "status": "fermée — liquidation (nov. 2025)" }, | |
| 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", "status": "en attente de connecteur" }, | |
| 16 | 16 | { "id": "corporatestays", "name": "Corporate Stays", "url": "https://www.corporatestays.com", "category": "internationale" }, |
| 17 | 17 | { "id": "monsieurchalets", "name": "MonsieurChalets", "url": "https://www.monsieurchalets.com", "category": "quebecoise" }, |
| 18 | 18 | { "id": "wechalet", "name": "WeChalet", "url": "https://wechalet.com", "category": "quebecoise" }, |
@@ -21,22 +21,22 @@ | ||
| 21 | 21 | { "id": "mcal", "name": "Maisons et chalets à louer", "url": "https://maisonsetchaletsalouer.com", "category": "quebecoise" }, |
| 22 | 22 | { "id": "qldc", "name": "Québec Location de Chalets", "url": "https://www.quebeclocationdechalets.com", "category": "quebecoise" }, |
| 23 | 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 | 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" }, | |
| 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", "status": "hors Québec" }, | |
| 28 | + { "id": "locationschalets", "name": "LocationsChalets", "url": "https://www.locationschalets.com", "category": "quebecoise", "status": "site disparu" }, | |
| 29 | 29 | { "id": "tremblant_living", "name": "Tremblant Living", "url": "https://www.tremblantliving.com", "category": "agence" }, |
| 30 | 30 | { "id": "gites_passant", "name": "Gîtes et Auberges du Passant", "url": "https://www.terroiretsaveurs.com", "category": "gites" }, |
| 31 | 31 | { "id": "bonjourquebec", "name": "Bonjour Québec (CITQ)", "url": "https://www.bonjourquebec.com", "category": "officielle" }, |
| 32 | 32 | { "id": "sepaq", "name": "Sépaq", "url": "https://www.sepaq.com", "category": "officielle" }, |
| 33 | 33 | { "id": "parcscanada", "name": "Parcs Canada", "url": "https://reservation.pc.gc.ca", "category": "officielle" }, |
| 34 | 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 | 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" } | |
| 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", "status": "en attente de connecteur" }, | |
| 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", "status": "en attente de connecteur" } | |
| 41 | 41 | ] |
| 42 | 42 | } |
modified
frontend/src/ctapi.ts
+2 −0
@@ -65,6 +65,8 @@ export interface CtSource { | ||
| 65 | 65 | name: string; |
| 66 | 66 | url: string; |
| 67 | 67 | category: string; |
| 68 | + status?: string; | |
| 69 | + connector: boolean; | |
| 68 | 70 | active_listings: number; |
| 69 | 71 | last_sync: number | null; |
| 70 | 72 | } |
modified
frontend/src/pages/Sources.tsx
+84 −5
@@ -1,35 +1,61 @@ | ||
| 1 | 1 | // ----------------------------------------------------------------------------- |
| 2 | 2 | // Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| 3 | 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 | 7 | import { useEffect, useState } from "react"; |
| 7 | 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 | 22 | export default function SourcesPage() { |
| 10 | 23 | const [sources, setSources] = useState<Source[] | null>(null); |
| 24 | + const [ctSources, setCtSources] = useState<CtSource[] | null>(null); | |
| 11 | 25 | const [error, setError] = useState<string | null>(null); |
| 12 | 26 | |
| 13 | 27 | useEffect(() => { |
| 14 | 28 | fetchSources() |
| 15 | 29 | .then((r) => setSources(r.sources)) |
| 16 | 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 | 38 | return ( |
| 20 | 39 | <div className="container sources"> |
| 21 | − <span className="kicker">Registre — gestionnaires immobiliers</span> | |
| 40 | + <span className="kicker">Registre — sources agrégées</span> | |
| 22 | 41 | <h1>Sources agrégées</h1> |
| 23 | 42 | <p className="sub"> |
| 24 | − Gestionnaires immobiliers de Québec, Lévis et du Grand Montréal recensés par Lou-Ka. Chaque source « | |
| 25 | − active » est synchronisée périodiquement par un connecteur dédié; les autres sont en | |
| 26 | − attente de connecteur. | |
| 43 | + Toutes les sources recensées par Lou-Ka : gestionnaires immobiliers | |
| 44 | + (logements au mois) et plateformes de location court terme (chalets et | |
| 45 | + hébergements à la nuit). Chaque source « connectée » est synchronisée | |
| 46 | + périodiquement par un connecteur dédié. | |
| 27 | 47 | </p> |
| 28 | 48 | |
| 29 | 49 | {error && <div className="notice">⚠️ {error}</div>} |
| 30 | 50 | {!sources && !error && <div className="notice">Chargement…</div>} |
| 31 | 51 | |
| 32 | 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 | 59 | <div className="src-wrap"> |
| 34 | 60 | <table className="src-table"> |
| 35 | 61 | <thead> |
@@ -68,6 +94,59 @@ export default function SourcesPage() { | ||
| 68 | 94 | </tbody> |
| 69 | 95 | </table> |
| 70 | 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 | 151 | </div> |
| 73 | 152 | ); |
modified
louka/seo.py
+212 −5
@@ -26,6 +26,7 @@ from fastapi import APIRouter, HTTPException | ||
| 26 | 26 | from fastapi.responses import HTMLResponse, PlainTextResponse, Response |
| 27 | 27 | |
| 28 | 28 | from . import db |
| 29 | +from .shortterm import db as ctdb | |
| 29 | 30 | |
| 30 | 31 | router = APIRouter() |
| 31 | 32 | |
@@ -295,9 +296,15 @@ def sitemap_index(): | ||
| 295 | 296 | con = db.connect() |
| 296 | 297 | total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"] |
| 297 | 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 | 303 | chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) |
| 299 | − names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [ | |
| 300 | − f"sitemap-annonces-{i}.xml" for i in range(1, chunks + 1)] | |
| 304 | + ct_chunks = max(1, math.ceil(ct_total / SITEMAP_CHUNK)) | |
| 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 | 308 | today = date.today().isoformat() |
| 302 | 309 | rows = "\n".join( |
| 303 | 310 | f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>" |
@@ -310,6 +317,7 @@ def sitemap_index(): | ||
| 310 | 317 | def sitemap_pages(): |
| 311 | 318 | urls: list[tuple[str, str | None]] = [ |
| 312 | 319 | (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None), |
| 320 | + (f"{BASE_URL}/court-terme", None), | |
| 313 | 321 | (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None), |
| 314 | 322 | (f"{BASE_URL}/confidentialite", None), (f"{BASE_URL}/conditions", None)] |
| 315 | 323 | con = db.connect() |
@@ -353,6 +361,22 @@ def sitemap_annonces(num: int): | ||
| 353 | 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 | 380 | # --- Pages SSR ---------------------------------------------------------------- |
| 357 | 381 | |
| 358 | 382 | @router.get("/", include_in_schema=False) |
@@ -638,14 +662,197 @@ def gestionnaire_ssr(source_id: str): | ||
| 638 | 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 | 846 | # pages statiques de l'app : head unique, contenu rendu par React |
| 642 | 847 | _STATIC_META = { |
| 643 | 848 | "/stats": ("Statistiques du marché locatif québécois | Lou-Ka", |
| 644 | 849 | "Loyers moyens et médians, répartition par ville et par type de logement : " |
| 645 | 850 | "les statistiques du marché locatif québécois, calculées en continu par Lou-Ka."), |
| 646 | − "/sources": ("Gestionnaires immobiliers recensés | Lou-Ka", | |
| 647 | − "La liste des gestionnaires immobiliers du Québec dont Lou-Ka agrège les " | |
| 648 | − "logements à louer, avec le nombre d'annonces actives de chacun."), | |
| 851 | + "/sources": ("Sources agrégées — gestionnaires et plateformes court terme | Lou-Ka", | |
| 852 | + "Toutes les sources agrégées par Lou-Ka : gestionnaires immobiliers du " | |
| 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 | 856 | "/confidentialite": ("Politique de confidentialité | Lou-Ka", |
| 650 | 857 | "Politique de confidentialité de Lou-Ka : données collectées, " |
| 651 | 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 | 204 | @router.get("/sources") |
| 205 | 205 | def ct_sources(): |
| 206 | + from .connectors import ST_CONNECTORS | |
| 206 | 207 | try: |
| 207 | 208 | registry = json.loads( |
| 208 | 209 | SOURCES_CT_PATH.read_text(encoding="utf-8"))["sources"] |
@@ -217,6 +218,7 @@ def ct_sources(): | ||
| 217 | 218 | " GROUP BY source")} |
| 218 | 219 | con.close() |
| 219 | 220 | for s in registry: |
| 221 | + s["connector"] = s["id"] in ST_CONNECTORS | |
| 220 | 222 | s["active_listings"] = counts.get(s["id"], 0) |
| 221 | 223 | s["last_sync"] = last.get(s["id"]) |
| 222 | 224 | return {"sources": registry} |
| 223 | 225 | |