SPB Git forge

spb/lou-ka

Public

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

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

court terme : 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>
Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent ea49b68

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