' + seo_div, 1)
return HTMLResponse(page, status_code=status,
headers={"Cache-Control": "no-cache"})
def _e(t) -> str:
return html.escape(str(t or ""))
def _fmt_price(p) -> str:
return f"{int(round(p)):,} $".replace(",", " ") if p else ""
def _price_ok(p) -> bool:
return p is not None and PRICE_MIN <= p <= PRICE_MAX
def _iso(ts) -> str:
if not ts:
return date.today().isoformat()
return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat()
def _listing_li(r) -> str:
"""Une annonce dans une liste HTML serveur."""
label = r["title"] or r["address"] or r["uid"]
bits = [b for b in (r["unit_type"], _fmt_price(r["price"]) + "/mois" if _price_ok(r["price"]) else "",
r["sector"] or r["city"]) if b]
return (f'
{_e(label)}'
f'{" — " + _e(" · ".join(bits)) if bits else ""}')
def _not_found(message: str, path: str) -> HTMLResponse:
"""404 HTML : le shell React est servi (la SPA affichera sa page), mais le
statut et le contenu serveur disent clairement « introuvable » aux bots."""
return _render(title="Page introuvable | Lou-Ka",
description="Cette page n'existe pas sur Lou-Ka.",
path=path,
body=f"
{_e(message)}
"
'
Voir tous les logements à louer au Québec · '
'Logements par ville
',
status=404)
def _breadcrumb(items: list[tuple[str, str]]) -> dict:
return {"@context": "https://schema.org", "@type": "BreadcrumbList",
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "name": name,
"item": BASE_URL + path}
for i, (name, path) in enumerate(items)]}
# --- Données -----------------------------------------------------------------
def _city_stats(con, city: str) -> dict:
n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND city=?",
(city,)).fetchone()["c"]
prices = [r["price"] for r in con.execute(
f"SELECT price FROM listings WHERE active=1 AND city=? AND {PRICE_OK}", (city,))]
types = [dict(r) for r in con.execute(
"""SELECT unit_type, COUNT(*) n FROM listings
WHERE active=1 AND city=? AND unit_type<>''
GROUP BY unit_type ORDER BY n DESC""", (city,))]
for t in types:
t["slug"] = slugify(t["unit_type"])
return {"n": n,
"avg": round(statistics.mean(prices)) if prices else None,
"med": round(statistics.median(prices)) if prices else None,
"types": types}
def _villes_rows(con, minimum: int = 1) -> list[dict]:
rows = [dict(r) for r in con.execute(
f"""SELECT city, COUNT(*) n,
AVG(CASE WHEN {PRICE_OK} THEN price END) avg_price,
MAX(updated_at) last
FROM listings WHERE active=1 AND city<>''
GROUP BY city HAVING n>=? ORDER BY n DESC""", (minimum,))]
for r in rows:
r["slug"] = slugify(r["city"])
r["avg_price"] = round(r["avg_price"]) if r["avg_price"] else None
return rows
def _parse_row(r) -> dict:
d = dict(r)
for k in ("amenities", "images"):
d[k] = json.loads(d.get(k) or "[]")
d["details"] = json.loads(d.get("details") or "{}")
return d
# --- API JSON pour les pages villes du frontend ------------------------------
@router.get("/api/seo/villes")
def api_villes():
con = db.connect()
rows = _villes_rows(con)
con.close()
return {"villes": rows}
@router.get("/api/seo/ville/{slug}")
def api_ville(slug: str, type: str | None = None):
cities, types = _slug_maps()
city = cities.get(slug)
if not city:
raise HTTPException(404, "Ville inconnue")
unit_type = None
if type:
unit_type = types.get(type)
if not unit_type:
raise HTTPException(404, "Type de logement inconnu")
con = db.connect()
stats = _city_stats(con, city)
sql = "SELECT * FROM listings WHERE active=1 AND city=?"
args: list = [city]
if unit_type:
sql += " AND unit_type=?"
args.append(unit_type)
listings = [_parse_row(r) for r in con.execute(
sql + " ORDER BY updated_at DESC LIMIT 100", args)]
neighbors = [v for v in _villes_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12]
con.close()
return {"city": city, "slug": slug, "unit_type": unit_type,
**stats, "listings": listings, "neighbors": neighbors}
# --- robots.txt & sitemaps ----------------------------------------------------
@router.get("/robots.txt", include_in_schema=False)
def robots() -> PlainTextResponse:
return PlainTextResponse(
"User-agent: *\n"
"Allow: /\n"
"Disallow: /api/\n"
"Disallow: /uploads/\n"
"Disallow: /profil\n"
"Disallow: /favoris\n"
"Disallow: /gestion\n"
"Disallow: /bienvenue\n"
"Disallow: /bot\n"
"Disallow: /passerelle/\n"
f"\nSitemap: {BASE_URL}/sitemap.xml\n")
def _xml(content: str) -> Response:
return Response('\n' + content,
media_type="application/xml",
headers={"Cache-Control": "public, max-age=3600"})
def _urlset(urls: list[tuple[str, str | None]]) -> Response:
rows = []
for loc, lastmod in urls:
lm = f"
{lastmod}" if lastmod else ""
rows.append(f"
{xml_escape(loc)}{lm}")
return _xml('
\n'
+ "\n".join(rows) + "\n")
@router.get("/sitemap.xml", include_in_schema=False)
def sitemap_index():
con = db.connect()
total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"]
con.close()
ct_con = ctdb.connect()
ct_total = ct_con.execute(
"SELECT COUNT(*) c FROM st_listings WHERE active=1").fetchone()["c"]
ct_con.close()
chunks = max(1, math.ceil(total / SITEMAP_CHUNK))
ct_chunks = max(1, math.ceil(ct_total / SITEMAP_CHUNK))
names = (["sitemap-pages.xml", "sitemap-villes.xml"]
+ [f"sitemap-annonces-{i}.xml" for i in range(1, chunks + 1)]
+ [f"sitemap-ct-{i}.xml" for i in range(1, ct_chunks + 1)])
today = date.today().isoformat()
rows = "\n".join(
f"
{BASE_URL}/{n}{today}"
for n in names)
return _xml('
\n'
+ rows + "\n")
@router.get("/sitemap-pages.xml", include_in_schema=False)
def sitemap_pages():
urls: list[tuple[str, str | None]] = [
(f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None),
(f"{BASE_URL}/court-terme", None),
(f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None),
(f"{BASE_URL}/demenageurs", None),
(f"{BASE_URL}/confidentialite", None), (f"{BASE_URL}/conditions", None)]
con = db.connect()
for r in con.execute(
"""SELECT source, MAX(updated_at) last FROM listings
WHERE active=1 GROUP BY source"""):
urls.append((f"{BASE_URL}/g/{r['source']}", _iso(r["last"])))
con.close()
return _urlset(urls)
@router.get("/sitemap-villes.xml", include_in_schema=False)
def sitemap_villes():
con = db.connect()
urls: list[tuple[str, str | None]] = []
for v in _villes_rows(con, MIN_LISTINGS_PAGE):
urls.append((f"{BASE_URL}/ville/{v['slug']}", _iso(v["last"])))
for r in con.execute(
f"""SELECT city, unit_type, COUNT(*) n, MAX(updated_at) last
FROM listings WHERE active=1 AND city<>'' AND unit_type<>''
GROUP BY city, unit_type HAVING n>=?""", (MIN_LISTINGS_PAGE,)):
urls.append((f"{BASE_URL}/ville/{slugify(r['city'])}/{slugify(r['unit_type'])}",
_iso(r["last"])))
con.close()
return _urlset(urls)
@router.get("/sitemap-annonces-{num}.xml", include_in_schema=False)
def sitemap_annonces(num: int):
if num < 1:
raise HTTPException(404)
con = db.connect()
rows = con.execute(
"""SELECT uid, updated_at FROM listings WHERE active=1
ORDER BY uid LIMIT ? OFFSET ?""",
(SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()
con.close()
if not rows:
raise HTTPException(404)
return _urlset([(f"{BASE_URL}/logement/{r['uid']}", _iso(r["updated_at"]))
for r in rows])
@router.get("/sitemap-ct-{num}.xml", include_in_schema=False)
def sitemap_ct(num: int):
if num < 1:
raise HTTPException(404)
con = ctdb.connect()
rows = con.execute(
"""SELECT uid, updated_at FROM st_listings WHERE active=1
ORDER BY uid LIMIT ? OFFSET ?""",
(SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()
con.close()
if not rows:
raise HTTPException(404)
return _urlset([(f"{BASE_URL}/court-terme/{r['uid']}", _iso(r["updated_at"]))
for r in rows])
# --- Pages SSR ----------------------------------------------------------------
@router.get("/", include_in_schema=False)
def home_ssr():
con = db.connect()
total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"]
nsources = con.execute(
"SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"]
villes = _villes_rows(con, MIN_LISTINGS_PAGE)
recents = con.execute(
f"""SELECT uid, title, address, sector, city, unit_type, price FROM listings
WHERE active=1 AND {PRICE_OK} ORDER BY first_seen DESC LIMIT 20""").fetchall()
con.close()
title = f"Lou-Ka — Un service Groupe KA · {total:,} logements à louer au Québec".replace(",", " ")
description = (f"{total:,} appartements et logements à louer partout au Québec, "
f"agrégés depuis {nsources} gestionnaires immobiliers et toujours à jour : "
"Montréal, Québec, Lévis, Gatineau et plus. Photos, prix, disponibilité "
"et lien direct vers l'annonce originale.").replace(",", " ")
top = villes[:30]
body = (
f"
Logements à louer au Québec — {total:,} annonces à jour
".replace(",", " ")
+ f"
Lou-Ka agrège les appartements à louer publiés par {nsources} gestionnaires "
"immobiliers partout au Québec : chaque annonce avec ses photos, son prix, sa "
"disponibilité et un lien direct vers le site du gestionnaire.
"
+ "
Logements par ville
Toutes les villes ({len(villes)})
'
+ "
Dernières annonces
"
+ "".join(_listing_li(r) for r in recents)
+ "
")
jsonld = [
{"@context": "https://schema.org", "@type": "WebSite",
"name": SITE_NAME, "url": BASE_URL + "/",
"description": description, "inLanguage": "fr-CA"},
{"@context": "https://schema.org", "@type": "Organization",
"name": "Lou-Ka (Groupe KA)", "url": BASE_URL + "/"}]
return _render(title=title, description=description, path="/",
jsonld=jsonld, body=body)
@router.get("/demenageurs", include_in_schema=False)
def demenageurs_ssr():
from . import demenageurs as dem
doc = dem.load()
movers = doc.get("movers", [])
n = len(movers)
title = f"Déménageurs au Québec — annuaire complet ({n} entreprises) | Lou-Ka"
description = (f"Annuaire complet des déménageurs du Québec : {n} entreprises de "
"déménagement dans les 17 régions administratives, avec téléphone, "
"site web et note Google. Trouvez un déménageur près de chez vous.")
by_region: dict[str, list] = {}
for m in movers:
by_region.setdefault(m["region"], []).append(m)
def _mover_ld(m: dict) -> dict:
d = {"@type": "MovingCompany", "name": m["name"]}
if m.get("address"):
d["address"] = m["address"]
if m.get("phone"):
d["telephone"] = m["phone"]
if m.get("website"):
d["url"] = m["website"]
return d
parts = [f"
Déménageurs du Québec — {n} entreprises
",
"
" + _e(description) + "
"]
for region in sorted(by_region, key=lambda r: -len(by_region[r])):
ms = by_region[region]
parts.append(f"
{_e(region)} ({len(ms)})
")
for m in ms:
item = f"{_e(m['name'])} — {_e(m['city'])}"
if m.get("phone"):
item += ", " + _e(m["phone"])
if m.get("website"):
item += ' — site web'
parts.append("- " + item + "
")
parts.append("
")
jsonld = [_breadcrumb([("Accueil", "/"), ("Déménageurs", "/demenageurs")]),
{"@context": "https://schema.org", "@type": "ItemList",
"name": "Déménageurs du Québec", "numberOfItems": n,
"itemListElement": [
{"@type": "ListItem", "position": i + 1, "item": _mover_ld(m)}
for i, m in enumerate(movers[:100])]}]
return _render(title=title, description=description, path="/demenageurs",
jsonld=jsonld, body="".join(parts))
@router.get("/villes", include_in_schema=False)
def villes_ssr():
con = db.connect()
villes = _villes_rows(con)
con.close()
total = sum(v["n"] for v in villes)
title = f"Logements à louer par ville au Québec ({len(villes)} villes) | Lou-Ka"
description = (f"Toutes les villes du Québec où Lou-Ka recense des logements à louer : "
f"{total:,} annonces dans {len(villes)} villes, avec loyer moyen et "
"nombre d'appartements disponibles par ville.").replace(",", " ")
body = (f"
Logements à louer par ville — {len(villes)} villes au Québec
"
+ "".join(f'- {_e(v["city"])} — {v["n"]} annonces'
+ (f", loyer moyen {_fmt_price(v['avg_price'])}" if v["avg_price"] else "")
+ "
" for v in villes)
+ "
")
jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]),
{"@context": "https://schema.org", "@type": "ItemList",
"name": "Logements à louer par ville au Québec",
"numberOfItems": len(villes),
"itemListElement": [
{"@type": "ListItem", "position": i + 1,
"name": f"Logements à louer à {v['city']}",
"url": f"{BASE_URL}/ville/{v['slug']}"}
for i, v in enumerate(villes[:100])]}]
return _render(title=title, description=description, path="/villes",
jsonld=jsonld, body=body)
def _ville_ssr(slug: str, type_slug: str | None = None):
cities, types = _slug_maps()
path = f"/ville/{slug}" + (f"/{type_slug}" if type_slug else "")
city = cities.get(slug)
if not city:
return _not_found("Aucun logement recensé pour cette ville", path)
unit_type = None
if type_slug is not None:
unit_type = types.get(type_slug)
if not unit_type:
return _not_found("Type de logement inconnu", path)
con = db.connect()
stats = _city_stats(con, city)
sql = "SELECT * FROM listings WHERE active=1 AND city=?"
args: list = [city]
if unit_type:
sql += " AND unit_type=?"
args.append(unit_type)
n = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
prices = [r["price"] for r in con.execute(
f"SELECT price FROM listings WHERE active=1 AND city=? AND unit_type=? AND {PRICE_OK}",
(city, unit_type))]
avg = round(statistics.mean(prices)) if prices else None
med = round(statistics.median(prices)) if prices else None
else:
n, avg, med = stats["n"], stats["avg"], stats["med"]
rows = con.execute(sql + " ORDER BY updated_at DESC LIMIT 100", args).fetchall()
neighbors = [v for v in _villes_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12]
con.close()
if n == 0:
return _not_found(f"Aucune annonce active à {city} pour ce type", path)
what = f"{unit_type} à louer" if unit_type else "Logements à louer"
title = f"{what} à {city} — {n} annonces | Lou-Ka"
desc_stats = (f"loyer moyen {_fmt_price(avg)}, médian {_fmt_price(med)}"
if avg and med else "")
description = (f"{n} {what.lower()} à {city}"
+ (f" ({desc_stats})" if desc_stats else "")
+ ". Annonces à jour des gestionnaires immobiliers, avec photos, prix, "
"disponibilité et lien direct vers l'annonce originale.")
body = [f"
{_e(what)} à {_e(city)} — {n} annonces
"]
if avg and med:
body.append(f"
Loyer moyen : {_fmt_price(avg)}/mois · "
f"loyer médian : {_fmt_price(med)}/mois.
")
if not unit_type and stats["types"]:
body.append("
Par type de logement
")
body.append("
Annonces
"
+ "".join(_listing_li(r) for r in rows) + "
")
if unit_type:
body.append(f'
Tous les logements à {_e(city)}
')
body.append("
Autres villes
")
crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, f"/ville/{slug}")]
if unit_type:
crumbs.append((f"{unit_type} à {city}", path))
jsonld = [_breadcrumb(crumbs),
{"@context": "https://schema.org", "@type": "ItemList",
"name": f"{what} à {city}", "numberOfItems": n,
"itemListElement": [
{"@type": "ListItem", "position": i + 1,
"name": r["title"] or r["address"] or r["uid"],
"url": f"{BASE_URL}/logement/{r['uid']}"}
for i, r in enumerate(rows[:50])]}]
return _render(title=title, description=description, path=path,
jsonld=jsonld, body="".join(body))
@router.get("/ville/{slug}", include_in_schema=False)
def ville_ssr(slug: str):
return _ville_ssr(slug)
@router.get("/ville/{slug}/{type_slug}", include_in_schema=False)
def ville_type_ssr(slug: str, type_slug: str):
return _ville_ssr(slug, type_slug)
@router.get("/logement/{uid:path}", include_in_schema=False)
def listing_ssr(uid: str):
con = db.connect()
row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
con.close()
if row is None:
return _render(title="Annonce introuvable | Lou-Ka",
description="Cette annonce n'existe pas ou plus sur Lou-Ka.",
path=f"/logement/{uid}",
body="
Annonce introuvable
"
'
Voir tous les logements à louer au Québec
',
status=404)
d = _parse_row(row)
city_slug = slugify(d["city"]) if d["city"] else ""
if not d["active"]:
# annonce retirée chez la source : 410 Gone + lien vers la ville parente
link = (f'
Logements à louer à {_e(d["city"])}'
if city_slug else '
Tous les logements')
return _render(
title="Annonce retirée | Lou-Ka",
description="Cette annonce a été retirée par le gestionnaire immobilier.",
path=f"/logement/{uid}",
body=f"
Cette annonce n'est plus disponible
"
f"
Elle a été retirée par le gestionnaire. {link}.
",
status=410)
label = d["title"] or d["address"] or "Logement à louer"
where = d["city"] if d["city"] and d["city"] not in label else ""
title = (f"{d['unit_type'] + ' à louer — ' if d['unit_type'] else ''}{label}"
+ (f", {where}" if where else "") + " | Lou-Ka")
price_txt = f"{_fmt_price(d['price'])}/mois" if _price_ok(d["price"]) else (d["price_label"] or "")
bits = [b for b in (d["unit_type"], price_txt, d["sector"], d["city"],
d["availability"]) if b]
description = (" · ".join(bits) + ". " if bits else "") + \
(d["description"][:150].strip() + "…" if len(d["description"] or "") > 150
else (d["description"] or "")).strip()
description = description[:300] or f"Logement à louer à {d['city']} sur Lou-Ka."
body = [f"
{_e(label)}{' — ' + _e(d['unit_type']) if d['unit_type'] else ''}
"]
facts = [("Adresse", d["address"]), ("Ville", d["city"]), ("Quartier", d["sector"]),
("Type", d["unit_type"]), ("Loyer", price_txt),
("Disponibilité", d["availability"]),
("Superficie", f"{int(d['area_sqft'])} pi²" if d["area_sqft"] else "")]
body.append("
" + "".join(f"- {k} : {_e(v)}
"
for k, v in facts if v) + "
")
if d["description"]:
body.append(f"
{_e(d['description'][:600])}
")
if d["amenities"]:
body.append("
Commodités : "
+ _e(", ".join(d["amenities"][:15])) + "
")
if d["url"]:
body.append(f'
'
"Voir l'annonce originale chez le gestionnaire
")
if city_slug:
body.append(f'
Autres logements à louer à '
f'{_e(d["city"])}
')
rooms = None
m = re.match(r"(\d+)", d["unit_type"] or "")
if m:
rooms = int(m.group(1))
apartment: dict = {
"@type": "Apartment", "name": label,
"address": {"@type": "PostalAddress",
"streetAddress": d["address"] or None,
"addressLocality": d["city"] or None,
"addressRegion": "QC", "addressCountry": "CA"}}
if d["lat"] and d["lng"]:
apartment["geo"] = {"@type": "GeoCoordinates",
"latitude": d["lat"], "longitude": d["lng"]}
if rooms:
apartment["numberOfRooms"] = rooms
if d["area_sqft"]:
apartment["floorSize"] = {"@type": "QuantitativeValue",
"value": d["area_sqft"], "unitCode": "FTK"}
if d["images"]:
apartment["photo"] = d["images"][:5]
listing_ld: dict = {
"@context": "https://schema.org", "@type": "RealEstateListing",
"name": title.removesuffix(" | Lou-Ka"),
"url": f"{BASE_URL}/logement/{uid}",
"datePosted": _iso(d["first_seen"]), "inLanguage": "fr-CA",
"about": apartment}
if _price_ok(d["price"]):
listing_ld["offers"] = {
"@type": "Offer", "price": d["price"], "priceCurrency": "CAD",
"availability": "https://schema.org/InStock",
"businessFunction": "http://purl.org/goodrelations/v1#LeaseOut"}
crumbs = [("Accueil", "/")]
if city_slug:
crumbs.append((d["city"], f"/ville/{city_slug}"))
crumbs.append((label, f"/logement/{uid}"))
return _render(title=title, description=description, path=f"/logement/{uid}",
jsonld=[listing_ld, _breadcrumb(crumbs)], body="".join(body),
og_image=d["images"][0] if d["images"] else None)
@router.get("/g/{source_id}", include_in_schema=False)
def gestionnaire_ssr(source_id: str):
registry = {s["id"]: s for s in
json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]}
src = registry.get(source_id)
if not src:
return _not_found("Gestionnaire inconnu", f"/g/{source_id}")
con = db.connect()
n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND source=?",
(source_id,)).fetchone()["c"]
rows = con.execute(
"""SELECT uid, title, address, sector, city, unit_type, price FROM listings
WHERE active=1 AND source=? ORDER BY updated_at DESC LIMIT 60""",
(source_id,)).fetchall()
con.close()
name = src["name"]
title = f"{name} — {n} logements à louer | Lou-Ka"
description = (f"Les {n} logements à louer de {name}"
+ (f" ({src['region']})" if src.get("region") else "")
+ " recensés par Lou-Ka, avec prix, photos et lien direct "
"vers l'annonce originale.")
body = (f"
{_e(name)} — {n} logements à louer
"
+ (f"
Secteurs : {_e(src['sectors'])}.
" if src.get("sectors") else "")
+ "
" + "".join(_listing_li(r) for r in rows) + "
"
+ '
Tous les gestionnaires
')
jsonld = [_breadcrumb([("Accueil", "/"), ("Sources", "/sources"),
(name, f"/g/{source_id}")]),
{"@context": "https://schema.org", "@type": "Organization",
"name": name, "url": src.get("url") or f"{BASE_URL}/g/{source_id}"}]
return _render(title=title, description=description, path=f"/g/{source_id}",
jsonld=jsonld, body=body)
# --- Court terme (chalets & hébergements à la nuit) ---------------------------
# bornes de plausibilité d'un prix à la nuit
CT_PRICE_MIN, CT_PRICE_MAX = 25, 10000
def _ct_price_ok(p) -> bool:
return p is not None and CT_PRICE_MIN <= p <= CT_PRICE_MAX
def _ct_li(r) -> str:
bits = [b for b in (
r["property_type"],
f"{_fmt_price(r['price_night'])}/nuit" if _ct_price_ok(r["price_night"]) else "",
r["city"] or r["region"]) if b]
return (f'
{_e(r["title"] or r["uid"])}'
f'{" — " + _e(" · ".join(bits)) if bits else ""}')
@router.get("/court-terme", include_in_schema=False)
def court_terme_ssr():
con = ctdb.connect()
total = con.execute(
"SELECT COUNT(*) c FROM st_listings WHERE active=1").fetchone()["c"]
nsources = con.execute(
"SELECT COUNT(DISTINCT source) c FROM st_listings WHERE active=1").fetchone()["c"]
regions = [dict(r) for r in con.execute(
"SELECT region, COUNT(*) n FROM st_listings WHERE active=1"
" AND region<>'' GROUP BY region ORDER BY n DESC")]
types = [dict(r) for r in con.execute(
"SELECT property_type t, COUNT(*) n FROM st_listings WHERE active=1"
" AND property_type<>'' GROUP BY property_type ORDER BY n DESC")]
recents = con.execute(
f"""SELECT uid, title, property_type, city, region, price_night
FROM st_listings WHERE active=1
AND price_night BETWEEN {CT_PRICE_MIN} AND {CT_PRICE_MAX}
ORDER BY first_seen DESC LIMIT 20""").fetchall()
con.close()
title = (f"Location court terme au Québec — {total:,} chalets et hébergements"
" | Lou-Ka").replace(",", " ")
description = (f"{total:,} chalets, condos et hébergements à louer à la nuit "
f"partout au Québec, agrégés depuis {nsources} plateformes "
"(Airbnb, Vrbo, Booking, WeChalet, Sépaq et plus) : "
"Laurentides, Charlevoix, Cantons-de-l'Est, Lanaudière… "
"Prix par nuit, photos et lien direct vers l'annonce "
"originale.").replace(",", " ")
body = (
f"
Location court terme au Québec — {total:,} hébergements
".replace(",", " ")
+ f"
Lou-Ka agrège les chalets et hébergements à la nuit publiés sur "
f"{nsources} plateformes de location court terme couvrant le Québec : "
"chaque fiche avec ses photos, son prix par nuit, sa capacité et un "
"lien direct vers l'annonce originale.
"
+ "
Par région
"
+ "".join(f"- {_e(r['region'])} — {r['n']} hébergements
"
for r in regions[:20])
+ "
Par type d'hébergement
"
+ "".join(f"- {_e(t['t'])} — {t['n']}
" for t in types[:12])
+ "
Derniers hébergements recensés
"
+ "".join(_ct_li(r) for r in recents)
+ '
Logements à louer au mois · '
'Toutes les sources
')
jsonld = [
_breadcrumb([("Accueil", "/"), ("Court terme", "/court-terme")]),
{"@context": "https://schema.org", "@type": "ItemList",
"name": "Location court terme au Québec",
"numberOfItems": total,
"itemListElement": [
{"@type": "ListItem", "position": i + 1,
"name": r["title"] or r["uid"],
"url": f"{BASE_URL}/court-terme/{r['uid']}"}
for i, r in enumerate(recents)]}]
return _render(title=title, description=description, path="/court-terme",
jsonld=jsonld, body=body)
@router.get("/court-terme/{uid:path}", include_in_schema=False)
def court_terme_fiche_ssr(uid: str):
con = ctdb.connect()
row = con.execute("SELECT * FROM st_listings WHERE uid=?", (uid,)).fetchone()
con.close()
if row is None:
return _render(title="Hébergement introuvable | Lou-Ka",
description="Cet hébergement n'existe pas ou plus sur Lou-Ka.",
path=f"/court-terme/{uid}",
body="
Hébergement introuvable
"
'
Voir tous les hébergements '
"court terme au Québec
",
status=404)
d = dict(row)
d["amenities"] = json.loads(d.get("amenities") or "[]")
d["images"] = json.loads(d.get("images") or "[]")
if not d["active"]:
return _render(
title="Hébergement retiré | Lou-Ka",
description="Cet hébergement a été retiré de la plateforme d'origine.",
path=f"/court-terme/{uid}",
body="
Cet hébergement n'est plus disponible
"
'
Il a été retiré de la plateforme d\'origine. '
'Tous les hébergements court terme.
',
status=410)
label = d["title"] or "Hébergement court terme"
where = ", ".join(p for p in (d["city"], d["region"]) if p and p not in label)
title = (f"{d['property_type'] + ' à louer — ' if d['property_type'] else ''}"
f"{label}" + (f", {where}" if where else "") + " | Lou-Ka")
price_txt = (f"{_fmt_price(d['price_night'])}/nuit"
if _ct_price_ok(d["price_night"]) else (d["price_label"] or ""))
bits = [b for b in (
d["property_type"], price_txt,
f"{int(d['capacity'])} personnes" if d["capacity"] else "",
f"{int(d['bedrooms'])} chambres" if d["bedrooms"] else "",
d["city"], d["region"]) if b]
description = (" · ".join(bits) + ". " if bits else "") + \
(d["description"][:150].strip() + "…" if len(d["description"] or "") > 150
else (d["description"] or "")).strip()
description = (description[:300]
or f"Hébergement court terme à {d['city'] or 'louer'} sur Lou-Ka.")
body = [f"
{_e(label)}"
f"{' — ' + _e(d['property_type']) if d['property_type'] else ''}
"]
facts = [("Type", d["property_type"]), ("Ville", d["city"]),
("Région", d["region"]), ("Prix", price_txt),
("Capacité", f"{int(d['capacity'])} personnes" if d["capacity"] else ""),
("Chambres", int(d["bedrooms"]) if d["bedrooms"] else ""),
("Salles de bain", d["bathrooms"] or ""),
("Note", f"{d['rating']}/5 ({d['reviews']} avis)"
if d["rating"] and d["reviews"] else ""),
("Enregistrement CITQ", d["citq"] or "")]
body.append("
" + "".join(f"- {k} : {_e(v)}
"
for k, v in facts if v) + "
")
if d["description"]:
body.append(f"
{_e(d['description'][:600])}
")
if d["amenities"]:
body.append("
Commodités : "
+ _e(", ".join(d["amenities"][:15])) + "
")
if d["url"]:
body.append(f'
'
"Voir l'annonce originale sur la plateforme
")
body.append('
Tous les hébergements court terme '
"au Québec
")
rental: dict = {
"@context": "https://schema.org", "@type": "VacationRental",
"name": label, "url": f"{BASE_URL}/court-terme/{uid}",
"inLanguage": "fr-CA",
"address": {"@type": "PostalAddress",
"addressLocality": d["city"] or None,
"addressRegion": "QC", "addressCountry": "CA"}}
if d["lat"] and d["lng"]:
rental["latitude"], rental["longitude"] = d["lat"], d["lng"]
if d["images"]:
rental["image"] = d["images"][:5]
if d["bedrooms"]:
rental["numberOfBedrooms"] = int(d["bedrooms"])
if d["capacity"]:
rental["occupancy"] = {"@type": "QuantitativeValue",
"maxValue": int(d["capacity"]),
"unitText": "personnes"}
if d["rating"] and d["reviews"]:
rental["aggregateRating"] = {"@type": "AggregateRating",
"ratingValue": d["rating"],
"reviewCount": d["reviews"],
"bestRating": 5}
jsonld: list[dict] = [rental]
if _ct_price_ok(d["price_night"]):
jsonld.append({
"@context": "https://schema.org", "@type": "Offer",
"itemOffered": {"@type": "VacationRental", "name": label},
"price": d["price_night"], "priceCurrency": "CAD",
"availability": "https://schema.org/InStock",
"url": f"{BASE_URL}/court-terme/{uid}"})
crumbs = [("Accueil", "/"), ("Court terme", "/court-terme"),
(label, f"/court-terme/{uid}")]
jsonld.append(_breadcrumb(crumbs))
return _render(title=title, description=description,
path=f"/court-terme/{uid}", jsonld=jsonld,
body="".join(body),
og_image=d["images"][0] if d["images"] else None)
# pages statiques de l'app : head unique, contenu rendu par React
_STATIC_META = {
"/stats": ("Statistiques du marché locatif québécois | Lou-Ka",
"Loyers moyens et médians, répartition par ville et par type de logement : "
"les statistiques du marché locatif québécois, calculées en continu par Lou-Ka."),
"/sources": ("Sources agrégées — gestionnaires et plateformes court terme | Lou-Ka",
"Toutes les sources agrégées par Lou-Ka : gestionnaires immobiliers du "
"Québec (logements au mois) et plateformes de location court terme "
"(Airbnb, Vrbo, Booking, WeChalet, Sépaq…), avec le nombre d'annonces "
"actives de chacune."),
"/confidentialite": ("Politique de confidentialité | Lou-Ka",
"Politique de confidentialité de Lou-Ka : données collectées, "
"témoins (cookies) et droits des utilisateurs."),
"/conditions": ("Conditions d'utilisation | Lou-Ka",
"Conditions d'utilisation du service Lou-Ka, agrégateur indépendant "
"de logements à louer au Québec."),
}
def _static_page(path: str):
title, description = _STATIC_META[path]
return _render(title=title, description=description, path=path)
@router.get("/stats", include_in_schema=False)
def stats_page():
return _static_page("/stats")
@router.get("/sources", include_in_schema=False)
def sources_page():
return _static_page("/sources")
@router.get("/confidentialite", include_in_schema=False)
def privacy_page():
return _static_page("/confidentialite")
@router.get("/conditions", include_in_schema=False)
def terms_page():
return _static_page("/conditions")