| |
1 | +# ----------------------------------------------------------------------------- |
| |
2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) |
| |
3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| |
4 | +# seo.py : référencement — SSR léger, pages programmatiques, robots, sitemaps |
| |
5 | +# |
| |
6 | +# Principe : pour chaque route publique, le serveur renvoie le MÊME index.html |
| |
7 | +# que le build Vite, mais avec un <head> unique (title, description, canonical, |
| |
8 | +# og:, JSON-LD) et le contenu essentiel en HTML DANS <div id="root">. Les |
| |
9 | +# moteurs de recherche voient une page complète sans exécuter JavaScript ; |
| |
10 | +# React, en se montant, remplace ce contenu par l'application interactive. |
| |
11 | +# ----------------------------------------------------------------------------- |
| |
12 | +from __future__ import annotations |
| |
13 | + |
| |
14 | +import html |
| |
15 | +import json |
| |
16 | +import math |
| |
17 | +import re |
| |
18 | +import statistics |
| |
19 | +import time |
| |
20 | +import unicodedata |
| |
21 | +from datetime import date, datetime, timezone |
| |
22 | +from pathlib import Path |
| |
23 | +from xml.sax.saxutils import escape as xml_escape |
| |
24 | + |
| |
25 | +from fastapi import APIRouter, HTTPException |
| |
26 | +from fastapi.responses import HTMLResponse, PlainTextResponse, Response |
| |
27 | + |
| |
28 | +from . import db |
| |
29 | + |
| |
30 | +router = APIRouter() |
| |
31 | + |
| |
32 | +ROOT = Path(__file__).resolve().parent.parent |
| |
33 | +FRONTEND_DIST = ROOT / "frontend" / "dist" |
| |
34 | +SOURCES_PATH = ROOT / "data" / "sources.json" |
| |
35 | + |
| |
36 | +BASE_URL = "https://www.lou-ka.com" |
| |
37 | +SITE_NAME = "Lou-Ka" |
| |
38 | + |
| |
39 | +# bornes de plausibilité d'un loyer mensuel — hors bornes : prix exclu des |
| |
40 | +# statistiques et des données structurées (certaines sources publient 0 $) |
| |
41 | +PRICE_MIN, PRICE_MAX = 195, 15000 |
| |
42 | +PRICE_OK = f"price >= {PRICE_MIN} AND price <= {PRICE_MAX}" |
| |
43 | + |
| |
44 | +# seuil d'inclusion d'une page programmatique dans le sitemap |
| |
45 | +MIN_LISTINGS_PAGE = 3 |
| |
46 | +SITEMAP_CHUNK = 10000 |
| |
47 | + |
| |
48 | + |
| |
49 | +# --- Slugs ------------------------------------------------------------------- |
| |
50 | + |
| |
51 | +def slugify(text: str) -> str: |
| |
52 | + """« Trois-Rivières » → trois-rivieres, « 3½ » → 3-1-2, « 6½+ » → 6-1-2-plus.""" |
| |
53 | + t = text.replace("½", "-1-2").replace("+", "-plus") |
| |
54 | + t = t.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae") |
| |
55 | + t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode() |
| |
56 | + t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-") |
| |
57 | + return t |
| |
58 | + |
| |
59 | + |
| |
60 | +_maps_cache: dict = {"ts": 0.0, "cities": {}, "types": {}} |
| |
61 | + |
| |
62 | + |
| |
63 | +def _slug_maps() -> tuple[dict[str, str], dict[str, str]]: |
| |
64 | + """(slug→ville, slug→type d'unité), reconstruit au plus toutes les 10 min.""" |
| |
65 | + if time.time() - _maps_cache["ts"] > 600: |
| |
66 | + con = db.connect() |
| |
67 | + cities = [r["city"] for r in con.execute( |
| |
68 | + "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>''")] |
| |
69 | + types = [r["unit_type"] for r in con.execute( |
| |
70 | + "SELECT DISTINCT unit_type FROM listings WHERE active=1 AND unit_type<>''")] |
| |
71 | + con.close() |
| |
72 | + _maps_cache["cities"] = {slugify(c): c for c in sorted(cities)} |
| |
73 | + _maps_cache["types"] = {slugify(t): t for t in sorted(types)} |
| |
74 | + _maps_cache["ts"] = time.time() |
| |
75 | + return _maps_cache["cities"], _maps_cache["types"] |
| |
76 | + |
| |
77 | + |
| |
78 | +# --- Gabarit (index.html du build Vite) -------------------------------------- |
| |
79 | + |
| |
80 | +_shell_cache: dict = {"mtime": 0.0, "html": ""} |
| |
81 | + |
| |
82 | + |
| |
83 | +def _shell() -> str: |
| |
84 | + f = FRONTEND_DIST / "index.html" |
| |
85 | + mtime = f.stat().st_mtime |
| |
86 | + if mtime != _shell_cache["mtime"]: |
| |
87 | + _shell_cache["html"] = f.read_text(encoding="utf-8") |
| |
88 | + _shell_cache["mtime"] = mtime |
| |
89 | + return _shell_cache["html"] |
| |
90 | + |
| |
91 | + |
| |
92 | +def _render(*, title: str, description: str, path: str, jsonld: list[dict] | None = None, |
| |
93 | + body: str = "", og_image: str | None = None, status: int = 200) -> HTMLResponse: |
| |
94 | + """index.html du build + head unique + contenu HTML dans #root.""" |
| |
95 | + canonical = BASE_URL + path |
| |
96 | + page = _shell() |
| |
97 | + page = re.sub(r"<title>.*?</title>", |
| |
98 | + f"<title>{html.escape(title)}</title>", page, count=1, flags=re.S) |
| |
99 | + page = re.sub(r'<meta name="description"[^>]*/>', |
| |
100 | + f'<meta name="description" content="{html.escape(description, quote=True)}" />', |
| |
101 | + page, count=1) |
| |
102 | + extras = [ |
| |
103 | + f'<link rel="canonical" href="{canonical}" />', |
| |
104 | + f'<link rel="alternate" hreflang="fr-ca" href="{canonical}" />', |
| |
105 | + f'<link rel="alternate" hreflang="x-default" href="{canonical}" />', |
| |
106 | + f'<meta property="og:site_name" content="{SITE_NAME}" />', |
| |
107 | + '<meta property="og:locale" content="fr_CA" />', |
| |
108 | + '<meta property="og:type" content="website" />', |
| |
109 | + f'<meta property="og:title" content="{html.escape(title, quote=True)}" />', |
| |
110 | + f'<meta property="og:description" content="{html.escape(description, quote=True)}" />', |
| |
111 | + f'<meta property="og:url" content="{canonical}" />', |
| |
112 | + f'<meta name="twitter:card" content="{"summary_large_image" if og_image else "summary"}" />', |
| |
113 | + f'<meta name="twitter:title" content="{html.escape(title, quote=True)}" />', |
| |
114 | + ] |
| |
115 | + if og_image: |
| |
116 | + extras.append(f'<meta property="og:image" content="{html.escape(og_image, quote=True)}" />') |
| |
117 | + for obj in (jsonld or []): |
| |
118 | + blob = json.dumps(obj, ensure_ascii=False).replace("</", "<\\/") |
| |
119 | + extras.append(f'<script type="application/ld+json">{blob}</script>') |
| |
120 | + page = page.replace("</head>", " " + "\n ".join(extras) + "\n</head>", 1) |
| |
121 | + if body: |
| |
122 | + seo_div = ('<div style="max-width:960px;margin:0 auto;padding:24px;' |
| |
123 | + 'font-family:system-ui,sans-serif;color:#141814">' + body + "</div>") |
| |
124 | + page = page.replace('<div id="root">', '<div id="root">' + seo_div, 1) |
| |
125 | + return HTMLResponse(page, status_code=status, |
| |
126 | + headers={"Cache-Control": "no-cache"}) |
| |
127 | + |
| |
128 | + |
| |
129 | +def _e(t) -> str: |
| |
130 | + return html.escape(str(t or "")) |
| |
131 | + |
| |
132 | + |
| |
133 | +def _fmt_price(p) -> str: |
| |
134 | + return f"{int(round(p)):,} $".replace(",", " ") if p else "" |
| |
135 | + |
| |
136 | + |
| |
137 | +def _price_ok(p) -> bool: |
| |
138 | + return p is not None and PRICE_MIN <= p <= PRICE_MAX |
| |
139 | + |
| |
140 | + |
| |
141 | +def _iso(ts) -> str: |
| |
142 | + if not ts: |
| |
143 | + return date.today().isoformat() |
| |
144 | + return datetime.fromtimestamp(ts, tz=timezone.utc).date().isoformat() |
| |
145 | + |
| |
146 | + |
| |
147 | +def _listing_li(r) -> str: |
| |
148 | + """Une annonce dans une liste HTML serveur.""" |
| |
149 | + label = r["title"] or r["address"] or r["uid"] |
| |
150 | + bits = [b for b in (r["unit_type"], _fmt_price(r["price"]) + "/mois" if _price_ok(r["price"]) else "", |
| |
151 | + r["sector"] or r["city"]) if b] |
| |
152 | + return (f'<li><a href="/logement/{_e(r["uid"])}">{_e(label)}</a>' |
| |
153 | + f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>') |
| |
154 | + |
| |
155 | + |
| |
156 | +def _not_found(message: str, path: str) -> HTMLResponse: |
| |
157 | + """404 HTML : le shell React est servi (la SPA affichera sa page), mais le |
| |
158 | + statut et le contenu serveur disent clairement « introuvable » aux bots.""" |
| |
159 | + return _render(title="Page introuvable | Lou-Ka", |
| |
160 | + description="Cette page n'existe pas sur Lou-Ka.", |
| |
161 | + path=path, |
| |
162 | + body=f"<h1>{_e(message)}</h1>" |
| |
163 | + '<p><a href="/">Voir tous les logements à louer au Québec</a> · ' |
| |
164 | + '<a href="/villes">Logements par ville</a></p>', |
| |
165 | + status=404) |
| |
166 | + |
| |
167 | + |
| |
168 | +def _breadcrumb(items: list[tuple[str, str]]) -> dict: |
| |
169 | + return {"@context": "https://schema.org", "@type": "BreadcrumbList", |
| |
170 | + "itemListElement": [ |
| |
171 | + {"@type": "ListItem", "position": i + 1, "name": name, |
| |
172 | + "item": BASE_URL + path} |
| |
173 | + for i, (name, path) in enumerate(items)]} |
| |
174 | + |
| |
175 | + |
| |
176 | +# --- Données ----------------------------------------------------------------- |
| |
177 | + |
| |
178 | +def _city_stats(con, city: str) -> dict: |
| |
179 | + n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND city=?", |
| |
180 | + (city,)).fetchone()["c"] |
| |
181 | + prices = [r["price"] for r in con.execute( |
| |
182 | + f"SELECT price FROM listings WHERE active=1 AND city=? AND {PRICE_OK}", (city,))] |
| |
183 | + types = [dict(r) for r in con.execute( |
| |
184 | + """SELECT unit_type, COUNT(*) n FROM listings |
| |
185 | + WHERE active=1 AND city=? AND unit_type<>'' |
| |
186 | + GROUP BY unit_type ORDER BY n DESC""", (city,))] |
| |
187 | + for t in types: |
| |
188 | + t["slug"] = slugify(t["unit_type"]) |
| |
189 | + return {"n": n, |
| |
190 | + "avg": round(statistics.mean(prices)) if prices else None, |
| |
191 | + "med": round(statistics.median(prices)) if prices else None, |
| |
192 | + "types": types} |
| |
193 | + |
| |
194 | + |
| |
195 | +def _villes_rows(con, minimum: int = 1) -> list[dict]: |
| |
196 | + rows = [dict(r) for r in con.execute( |
| |
197 | + f"""SELECT city, COUNT(*) n, |
| |
198 | + AVG(CASE WHEN {PRICE_OK} THEN price END) avg_price, |
| |
199 | + MAX(updated_at) last |
| |
200 | + FROM listings WHERE active=1 AND city<>'' |
| |
201 | + GROUP BY city HAVING n>=? ORDER BY n DESC""", (minimum,))] |
| |
202 | + for r in rows: |
| |
203 | + r["slug"] = slugify(r["city"]) |
| |
204 | + r["avg_price"] = round(r["avg_price"]) if r["avg_price"] else None |
| |
205 | + return rows |
| |
206 | + |
| |
207 | + |
| |
208 | +def _parse_row(r) -> dict: |
| |
209 | + d = dict(r) |
| |
210 | + for k in ("amenities", "images"): |
| |
211 | + d[k] = json.loads(d.get(k) or "[]") |
| |
212 | + d["details"] = json.loads(d.get("details") or "{}") |
| |
213 | + return d |
| |
214 | + |
| |
215 | + |
| |
216 | +# --- API JSON pour les pages villes du frontend ------------------------------ |
| |
217 | + |
| |
218 | +@router.get("/api/seo/villes") |
| |
219 | +def api_villes(): |
| |
220 | + con = db.connect() |
| |
221 | + rows = _villes_rows(con) |
| |
222 | + con.close() |
| |
223 | + return {"villes": rows} |
| |
224 | + |
| |
225 | + |
| |
226 | +@router.get("/api/seo/ville/{slug}") |
| |
227 | +def api_ville(slug: str, type: str | None = None): |
| |
228 | + cities, types = _slug_maps() |
| |
229 | + city = cities.get(slug) |
| |
230 | + if not city: |
| |
231 | + raise HTTPException(404, "Ville inconnue") |
| |
232 | + unit_type = None |
| |
233 | + if type: |
| |
234 | + unit_type = types.get(type) |
| |
235 | + if not unit_type: |
| |
236 | + raise HTTPException(404, "Type de logement inconnu") |
| |
237 | + con = db.connect() |
| |
238 | + stats = _city_stats(con, city) |
| |
239 | + sql = "SELECT * FROM listings WHERE active=1 AND city=?" |
| |
240 | + args: list = [city] |
| |
241 | + if unit_type: |
| |
242 | + sql += " AND unit_type=?" |
| |
243 | + args.append(unit_type) |
| |
244 | + listings = [_parse_row(r) for r in con.execute( |
| |
245 | + sql + " ORDER BY updated_at DESC LIMIT 100", args)] |
| |
246 | + neighbors = [v for v in _villes_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12] |
| |
247 | + con.close() |
| |
248 | + return {"city": city, "slug": slug, "unit_type": unit_type, |
| |
249 | + **stats, "listings": listings, "neighbors": neighbors} |
| |
250 | + |
| |
251 | + |
| |
252 | +# --- robots.txt & sitemaps ---------------------------------------------------- |
| |
253 | + |
| |
254 | +@router.get("/robots.txt", include_in_schema=False) |
| |
255 | +def robots() -> PlainTextResponse: |
| |
256 | + return PlainTextResponse( |
| |
257 | + "User-agent: *\n" |
| |
258 | + "Allow: /\n" |
| |
259 | + "Disallow: /api/\n" |
| |
260 | + "Disallow: /uploads/\n" |
| |
261 | + "Disallow: /profil\n" |
| |
262 | + "Disallow: /favoris\n" |
| |
263 | + "Disallow: /gestion\n" |
| |
264 | + "Disallow: /bienvenue\n" |
| |
265 | + "Disallow: /bot\n" |
| |
266 | + "Disallow: /passerelle/\n" |
| |
267 | + f"\nSitemap: {BASE_URL}/sitemap.xml\n") |
| |
268 | + |
| |
269 | + |
| |
270 | +def _xml(content: str) -> Response: |
| |
271 | + return Response('<?xml version="1.0" encoding="UTF-8"?>\n' + content, |
| |
272 | + media_type="application/xml", |
| |
273 | + headers={"Cache-Control": "public, max-age=3600"}) |
| |
274 | + |
| |
275 | + |
| |
276 | +def _urlset(urls: list[tuple[str, str | None]]) -> Response: |
| |
277 | + rows = [] |
| |
278 | + for loc, lastmod in urls: |
| |
279 | + lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else "" |
| |
280 | + rows.append(f"<url><loc>{xml_escape(loc)}</loc>{lm}</url>") |
| |
281 | + return _xml('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' |
| |
282 | + + "\n".join(rows) + "\n</urlset>") |
| |
283 | + |
| |
284 | + |
| |
285 | +@router.get("/sitemap.xml", include_in_schema=False) |
| |
286 | +def sitemap_index(): |
| |
287 | + con = db.connect() |
| |
288 | + total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"] |
| |
289 | + con.close() |
| |
290 | + chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) |
| |
291 | + names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [ |
| |
292 | + f"sitemap-annonces-{i}.xml" for i in range(1, chunks + 1)] |
| |
293 | + today = date.today().isoformat() |
| |
294 | + rows = "\n".join( |
| |
295 | + f"<sitemap><loc>{BASE_URL}/{n}</loc><lastmod>{today}</lastmod></sitemap>" |
| |
296 | + for n in names) |
| |
297 | + return _xml('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' |
| |
298 | + + rows + "\n</sitemapindex>") |
| |
299 | + |
| |
300 | + |
| |
301 | +@router.get("/sitemap-pages.xml", include_in_schema=False) |
| |
302 | +def sitemap_pages(): |
| |
303 | + urls: list[tuple[str, str | None]] = [ |
| |
304 | + (f"{BASE_URL}/", None), (f"{BASE_URL}/villes", None), |
| |
305 | + (f"{BASE_URL}/stats", None), (f"{BASE_URL}/sources", None), |
| |
306 | + (f"{BASE_URL}/confidentialite", None), (f"{BASE_URL}/conditions", None)] |
| |
307 | + con = db.connect() |
| |
308 | + for r in con.execute( |
| |
309 | + """SELECT source, MAX(updated_at) last FROM listings |
| |
310 | + WHERE active=1 GROUP BY source"""): |
| |
311 | + urls.append((f"{BASE_URL}/g/{r['source']}", _iso(r["last"]))) |
| |
312 | + con.close() |
| |
313 | + return _urlset(urls) |
| |
314 | + |
| |
315 | + |
| |
316 | +@router.get("/sitemap-villes.xml", include_in_schema=False) |
| |
317 | +def sitemap_villes(): |
| |
318 | + con = db.connect() |
| |
319 | + urls: list[tuple[str, str | None]] = [] |
| |
320 | + for v in _villes_rows(con, MIN_LISTINGS_PAGE): |
| |
321 | + urls.append((f"{BASE_URL}/ville/{v['slug']}", _iso(v["last"]))) |
| |
322 | + for r in con.execute( |
| |
323 | + f"""SELECT city, unit_type, COUNT(*) n, MAX(updated_at) last |
| |
324 | + FROM listings WHERE active=1 AND city<>'' AND unit_type<>'' |
| |
325 | + GROUP BY city, unit_type HAVING n>=?""", (MIN_LISTINGS_PAGE,)): |
| |
326 | + urls.append((f"{BASE_URL}/ville/{slugify(r['city'])}/{slugify(r['unit_type'])}", |
| |
327 | + _iso(r["last"]))) |
| |
328 | + con.close() |
| |
329 | + return _urlset(urls) |
| |
330 | + |
| |
331 | + |
| |
332 | +@router.get("/sitemap-annonces-{num}.xml", include_in_schema=False) |
| |
333 | +def sitemap_annonces(num: int): |
| |
334 | + if num < 1: |
| |
335 | + raise HTTPException(404) |
| |
336 | + con = db.connect() |
| |
337 | + rows = con.execute( |
| |
338 | + """SELECT uid, updated_at FROM listings WHERE active=1 |
| |
339 | + ORDER BY uid LIMIT ? OFFSET ?""", |
| |
340 | + (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall() |
| |
341 | + con.close() |
| |
342 | + if not rows: |
| |
343 | + raise HTTPException(404) |
| |
344 | + return _urlset([(f"{BASE_URL}/logement/{r['uid']}", _iso(r["updated_at"])) |
| |
345 | + for r in rows]) |
| |
346 | + |
| |
347 | + |
| |
348 | +# --- Pages SSR ---------------------------------------------------------------- |
| |
349 | + |
| |
350 | +@router.get("/", include_in_schema=False) |
| |
351 | +def home_ssr(): |
| |
352 | + con = db.connect() |
| |
353 | + total = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1").fetchone()["c"] |
| |
354 | + nsources = con.execute( |
| |
355 | + "SELECT COUNT(DISTINCT source) c FROM listings WHERE active=1").fetchone()["c"] |
| |
356 | + villes = _villes_rows(con, MIN_LISTINGS_PAGE) |
| |
357 | + recents = con.execute( |
| |
358 | + f"""SELECT uid, title, address, sector, city, unit_type, price FROM listings |
| |
359 | + WHERE active=1 AND {PRICE_OK} ORDER BY first_seen DESC LIMIT 20""").fetchall() |
| |
360 | + con.close() |
| |
361 | + |
| |
362 | + title = f"Lou-Ka — {total:,} logements à louer au Québec".replace(",", " ") |
| |
363 | + description = (f"{total:,} appartements et logements à louer partout au Québec, " |
| |
364 | + f"agrégés depuis {nsources} gestionnaires immobiliers et toujours à jour : " |
| |
365 | + "Montréal, Québec, Lévis, Gatineau et plus. Photos, prix, disponibilité " |
| |
366 | + "et lien direct vers l'annonce originale.").replace(",", " ") |
| |
367 | + top = villes[:30] |
| |
368 | + body = ( |
| |
369 | + f"<h1>Logements à louer au Québec — {total:,} annonces à jour</h1>".replace(",", " ") |
| |
370 | + + f"<p>Lou-Ka agrège les appartements à louer publiés par {nsources} gestionnaires " |
| |
371 | + "immobiliers partout au Québec : chaque annonce avec ses photos, son prix, sa " |
| |
372 | + "disponibilité et un lien direct vers le site du gestionnaire.</p>" |
| |
373 | + + "<h2>Logements par ville</h2><ul>" |
| |
374 | + + "".join(f'<li><a href="/ville/{v["slug"]}">Logements à louer à {_e(v["city"])}</a>' |
| |
375 | + f' — {v["n"]} annonces' |
| |
376 | + + (f", loyer moyen {_fmt_price(v['avg_price'])}" if v["avg_price"] else "") |
| |
377 | + + "</li>" for v in top) |
| |
378 | + + f'</ul><p><a href="/villes">Toutes les villes ({len(villes)})</a></p>' |
| |
379 | + + "<h2>Dernières annonces</h2><ul>" |
| |
380 | + + "".join(_listing_li(r) for r in recents) |
| |
381 | + + "</ul>") |
| |
382 | + jsonld = [ |
| |
383 | + {"@context": "https://schema.org", "@type": "WebSite", |
| |
384 | + "name": SITE_NAME, "url": BASE_URL + "/", |
| |
385 | + "description": description, "inLanguage": "fr-CA"}, |
| |
386 | + {"@context": "https://schema.org", "@type": "Organization", |
| |
387 | + "name": "Lou-Ka (Groupe KA)", "url": BASE_URL + "/"}] |
| |
388 | + return _render(title=title, description=description, path="/", |
| |
389 | + jsonld=jsonld, body=body) |
| |
390 | + |
| |
391 | + |
| |
392 | +@router.get("/villes", include_in_schema=False) |
| |
393 | +def villes_ssr(): |
| |
394 | + con = db.connect() |
| |
395 | + villes = _villes_rows(con) |
| |
396 | + con.close() |
| |
397 | + total = sum(v["n"] for v in villes) |
| |
398 | + title = f"Logements à louer par ville au Québec ({len(villes)} villes) | Lou-Ka" |
| |
399 | + description = (f"Toutes les villes du Québec où Lou-Ka recense des logements à louer : " |
| |
400 | + f"{total:,} annonces dans {len(villes)} villes, avec loyer moyen et " |
| |
401 | + "nombre d'appartements disponibles par ville.").replace(",", " ") |
| |
402 | + body = (f"<h1>Logements à louer par ville — {len(villes)} villes au Québec</h1><ul>" |
| |
403 | + + "".join(f'<li><a href="/ville/{v["slug"]}">{_e(v["city"])}</a> — {v["n"]} annonces' |
| |
404 | + + (f", loyer moyen {_fmt_price(v['avg_price'])}" if v["avg_price"] else "") |
| |
405 | + + "</li>" for v in villes) |
| |
406 | + + "</ul>") |
| |
407 | + jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]), |
| |
408 | + {"@context": "https://schema.org", "@type": "ItemList", |
| |
409 | + "name": "Logements à louer par ville au Québec", |
| |
410 | + "numberOfItems": len(villes), |
| |
411 | + "itemListElement": [ |
| |
412 | + {"@type": "ListItem", "position": i + 1, |
| |
413 | + "name": f"Logements à louer à {v['city']}", |
| |
414 | + "url": f"{BASE_URL}/ville/{v['slug']}"} |
| |
415 | + for i, v in enumerate(villes[:100])]}] |
| |
416 | + return _render(title=title, description=description, path="/villes", |
| |
417 | + jsonld=jsonld, body=body) |
| |
418 | + |
| |
419 | + |
| |
420 | +def _ville_ssr(slug: str, type_slug: str | None = None): |
| |
421 | + cities, types = _slug_maps() |
| |
422 | + path = f"/ville/{slug}" + (f"/{type_slug}" if type_slug else "") |
| |
423 | + city = cities.get(slug) |
| |
424 | + if not city: |
| |
425 | + return _not_found("Aucun logement recensé pour cette ville", path) |
| |
426 | + unit_type = None |
| |
427 | + if type_slug is not None: |
| |
428 | + unit_type = types.get(type_slug) |
| |
429 | + if not unit_type: |
| |
430 | + return _not_found("Type de logement inconnu", path) |
| |
431 | + |
| |
432 | + con = db.connect() |
| |
433 | + stats = _city_stats(con, city) |
| |
434 | + sql = "SELECT * FROM listings WHERE active=1 AND city=?" |
| |
435 | + args: list = [city] |
| |
436 | + if unit_type: |
| |
437 | + sql += " AND unit_type=?" |
| |
438 | + args.append(unit_type) |
| |
439 | + n = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] |
| |
440 | + prices = [r["price"] for r in con.execute( |
| |
441 | + f"SELECT price FROM listings WHERE active=1 AND city=? AND unit_type=? AND {PRICE_OK}", |
| |
442 | + (city, unit_type))] |
| |
443 | + avg = round(statistics.mean(prices)) if prices else None |
| |
444 | + med = round(statistics.median(prices)) if prices else None |
| |
445 | + else: |
| |
446 | + n, avg, med = stats["n"], stats["avg"], stats["med"] |
| |
447 | + rows = con.execute(sql + " ORDER BY updated_at DESC LIMIT 100", args).fetchall() |
| |
448 | + neighbors = [v for v in _villes_rows(con, MIN_LISTINGS_PAGE) if v["city"] != city][:12] |
| |
449 | + con.close() |
| |
450 | + if n == 0: |
| |
451 | + return _not_found(f"Aucune annonce active à {city} pour ce type", path) |
| |
452 | + |
| |
453 | + what = f"{unit_type} à louer" if unit_type else "Logements à louer" |
| |
454 | + title = f"{what} à {city} — {n} annonces | Lou-Ka" |
| |
455 | + desc_stats = (f"loyer moyen {_fmt_price(avg)}, médian {_fmt_price(med)}" |
| |
456 | + if avg and med else "") |
| |
457 | + description = (f"{n} {what.lower()} à {city}" |
| |
458 | + + (f" ({desc_stats})" if desc_stats else "") |
| |
459 | + + ". Annonces à jour des gestionnaires immobiliers, avec photos, prix, " |
| |
460 | + "disponibilité et lien direct vers l'annonce originale.") |
| |
461 | + |
| |
462 | + body = [f"<h1>{_e(what)} à {_e(city)} — {n} annonces</h1>"] |
| |
463 | + if avg and med: |
| |
464 | + body.append(f"<p>Loyer moyen : <strong>{_fmt_price(avg)}/mois</strong> · " |
| |
465 | + f"loyer médian : <strong>{_fmt_price(med)}/mois</strong>.</p>") |
| |
466 | + if not unit_type and stats["types"]: |
| |
467 | + body.append("<h2>Par type de logement</h2><ul>" + "".join( |
| |
468 | + f'<li><a href="/ville/{slug}/{t["slug"]}">{_e(t["unit_type"])} à louer à ' |
| |
469 | + f'{_e(city)}</a> — {t["n"]} annonces</li>' for t in stats["types"]) + "</ul>") |
| |
470 | + body.append("<h2>Annonces</h2><ul>" |
| |
471 | + + "".join(_listing_li(r) for r in rows) + "</ul>") |
| |
472 | + if unit_type: |
| |
473 | + body.append(f'<p><a href="/ville/{slug}">Tous les logements à {_e(city)}</a></p>') |
| |
474 | + body.append("<h2>Autres villes</h2><ul>" + "".join( |
| |
475 | + f'<li><a href="/ville/{v["slug"]}">Logements à louer à {_e(v["city"])}</a>' |
| |
476 | + f' — {v["n"]}</li>' for v in neighbors) + "</ul>") |
| |
477 | + |
| |
478 | + crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, f"/ville/{slug}")] |
| |
479 | + if unit_type: |
| |
480 | + crumbs.append((f"{unit_type} à {city}", path)) |
| |
481 | + jsonld = [_breadcrumb(crumbs), |
| |
482 | + {"@context": "https://schema.org", "@type": "ItemList", |
| |
483 | + "name": f"{what} à {city}", "numberOfItems": n, |
| |
484 | + "itemListElement": [ |
| |
485 | + {"@type": "ListItem", "position": i + 1, |
| |
486 | + "name": r["title"] or r["address"] or r["uid"], |
| |
487 | + "url": f"{BASE_URL}/logement/{r['uid']}"} |
| |
488 | + for i, r in enumerate(rows[:50])]}] |
| |
489 | + return _render(title=title, description=description, path=path, |
| |
490 | + jsonld=jsonld, body="".join(body)) |
| |
491 | + |
| |
492 | + |
| |
493 | +@router.get("/ville/{slug}", include_in_schema=False) |
| |
494 | +def ville_ssr(slug: str): |
| |
495 | + return _ville_ssr(slug) |
| |
496 | + |
| |
497 | + |
| |
498 | +@router.get("/ville/{slug}/{type_slug}", include_in_schema=False) |
| |
499 | +def ville_type_ssr(slug: str, type_slug: str): |
| |
500 | + return _ville_ssr(slug, type_slug) |
| |
501 | + |
| |
502 | + |
| |
503 | +@router.get("/logement/{uid:path}", include_in_schema=False) |
| |
504 | +def listing_ssr(uid: str): |
| |
505 | + con = db.connect() |
| |
506 | + row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone() |
| |
507 | + con.close() |
| |
508 | + if row is None: |
| |
509 | + return _render(title="Annonce introuvable | Lou-Ka", |
| |
510 | + description="Cette annonce n'existe pas ou plus sur Lou-Ka.", |
| |
511 | + path=f"/logement/{uid}", |
| |
512 | + body="<h1>Annonce introuvable</h1>" |
| |
513 | + '<p><a href="/">Voir tous les logements à louer au Québec</a></p>', |
| |
514 | + status=404) |
| |
515 | + d = _parse_row(row) |
| |
516 | + city_slug = slugify(d["city"]) if d["city"] else "" |
| |
517 | + if not d["active"]: |
| |
518 | + # annonce retirée chez la source : 410 Gone + lien vers la ville parente |
| |
519 | + link = (f'<a href="/ville/{city_slug}">Logements à louer à {_e(d["city"])}</a>' |
| |
520 | + if city_slug else '<a href="/">Tous les logements</a>') |
| |
521 | + return _render( |
| |
522 | + title="Annonce retirée | Lou-Ka", |
| |
523 | + description="Cette annonce a été retirée par le gestionnaire immobilier.", |
| |
524 | + path=f"/logement/{uid}", |
| |
525 | + body=f"<h1>Cette annonce n'est plus disponible</h1>" |
| |
526 | + f"<p>Elle a été retirée par le gestionnaire. {link}.</p>", |
| |
527 | + status=410) |
| |
528 | + |
| |
529 | + label = d["title"] or d["address"] or "Logement à louer" |
| |
530 | + where = d["city"] if d["city"] and d["city"] not in label else "" |
| |
531 | + title = (f"{d['unit_type'] + ' à louer — ' if d['unit_type'] else ''}{label}" |
| |
532 | + + (f", {where}" if where else "") + " | Lou-Ka") |
| |
533 | + price_txt = f"{_fmt_price(d['price'])}/mois" if _price_ok(d["price"]) else (d["price_label"] or "") |
| |
534 | + bits = [b for b in (d["unit_type"], price_txt, d["sector"], d["city"], |
| |
535 | + d["availability"]) if b] |
| |
536 | + description = (" · ".join(bits) + ". " if bits else "") + \ |
| |
537 | + (d["description"][:150].strip() + "…" if len(d["description"] or "") > 150 |
| |
538 | + else (d["description"] or "")).strip() |
| |
539 | + description = description[:300] or f"Logement à louer à {d['city']} sur Lou-Ka." |
| |
540 | + |
| |
541 | + body = [f"<h1>{_e(label)}{' — ' + _e(d['unit_type']) if d['unit_type'] else ''}</h1>"] |
| |
542 | + facts = [("Adresse", d["address"]), ("Ville", d["city"]), ("Quartier", d["sector"]), |
| |
543 | + ("Type", d["unit_type"]), ("Loyer", price_txt), |
| |
544 | + ("Disponibilité", d["availability"]), |
| |
545 | + ("Superficie", f"{int(d['area_sqft'])} pi²" if d["area_sqft"] else "")] |
| |
546 | + body.append("<ul>" + "".join(f"<li><strong>{k}</strong> : {_e(v)}</li>" |
| |
547 | + for k, v in facts if v) + "</ul>") |
| |
548 | + if d["description"]: |
| |
549 | + body.append(f"<p>{_e(d['description'][:600])}</p>") |
| |
550 | + if d["amenities"]: |
| |
551 | + body.append("<p><strong>Commodités</strong> : " |
| |
552 | + + _e(", ".join(d["amenities"][:15])) + "</p>") |
| |
553 | + if d["url"]: |
| |
554 | + body.append(f'<p><a href="{_e(d["url"])}" rel="nofollow">' |
| |
555 | + "Voir l'annonce originale chez le gestionnaire</a></p>") |
| |
556 | + if city_slug: |
| |
557 | + body.append(f'<p><a href="/ville/{city_slug}">Autres logements à louer à ' |
| |
558 | + f'{_e(d["city"])}</a></p>') |
| |
559 | + |
| |
560 | + rooms = None |
| |
561 | + m = re.match(r"(\d+)", d["unit_type"] or "") |
| |
562 | + if m: |
| |
563 | + rooms = int(m.group(1)) |
| |
564 | + apartment: dict = { |
| |
565 | + "@type": "Apartment", "name": label, |
| |
566 | + "address": {"@type": "PostalAddress", |
| |
567 | + "streetAddress": d["address"] or None, |
| |
568 | + "addressLocality": d["city"] or None, |
| |
569 | + "addressRegion": "QC", "addressCountry": "CA"}} |
| |
570 | + if d["lat"] and d["lng"]: |
| |
571 | + apartment["geo"] = {"@type": "GeoCoordinates", |
| |
572 | + "latitude": d["lat"], "longitude": d["lng"]} |
| |
573 | + if rooms: |
| |
574 | + apartment["numberOfRooms"] = rooms |
| |
575 | + if d["area_sqft"]: |
| |
576 | + apartment["floorSize"] = {"@type": "QuantitativeValue", |
| |
577 | + "value": d["area_sqft"], "unitCode": "FTK"} |
| |
578 | + if d["images"]: |
| |
579 | + apartment["photo"] = d["images"][:5] |
| |
580 | + listing_ld: dict = { |
| |
581 | + "@context": "https://schema.org", "@type": "RealEstateListing", |
| |
582 | + "name": title.removesuffix(" | Lou-Ka"), |
| |
583 | + "url": f"{BASE_URL}/logement/{uid}", |
| |
584 | + "datePosted": _iso(d["first_seen"]), "inLanguage": "fr-CA", |
| |
585 | + "about": apartment} |
| |
586 | + if _price_ok(d["price"]): |
| |
587 | + listing_ld["offers"] = { |
| |
588 | + "@type": "Offer", "price": d["price"], "priceCurrency": "CAD", |
| |
589 | + "availability": "https://schema.org/InStock", |
| |
590 | + "businessFunction": "http://purl.org/goodrelations/v1#LeaseOut"} |
| |
591 | + crumbs = [("Accueil", "/")] |
| |
592 | + if city_slug: |
| |
593 | + crumbs.append((d["city"], f"/ville/{city_slug}")) |
| |
594 | + crumbs.append((label, f"/logement/{uid}")) |
| |
595 | + return _render(title=title, description=description, path=f"/logement/{uid}", |
| |
596 | + jsonld=[listing_ld, _breadcrumb(crumbs)], body="".join(body), |
| |
597 | + og_image=d["images"][0] if d["images"] else None) |
| |
598 | + |
| |
599 | + |
| |
600 | +@router.get("/g/{source_id}", include_in_schema=False) |
| |
601 | +def gestionnaire_ssr(source_id: str): |
| |
602 | + registry = {s["id"]: s for s in |
| |
603 | + json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]} |
| |
604 | + src = registry.get(source_id) |
| |
605 | + if not src: |
| |
606 | + return _not_found("Gestionnaire inconnu", f"/g/{source_id}") |
| |
607 | + con = db.connect() |
| |
608 | + n = con.execute("SELECT COUNT(*) c FROM listings WHERE active=1 AND source=?", |
| |
609 | + (source_id,)).fetchone()["c"] |
| |
610 | + rows = con.execute( |
| |
611 | + """SELECT uid, title, address, sector, city, unit_type, price FROM listings |
| |
612 | + WHERE active=1 AND source=? ORDER BY updated_at DESC LIMIT 60""", |
| |
613 | + (source_id,)).fetchall() |
| |
614 | + con.close() |
| |
615 | + name = src["name"] |
| |
616 | + title = f"{name} — {n} logements à louer | Lou-Ka" |
| |
617 | + description = (f"Les {n} logements à louer de {name}" |
| |
618 | + + (f" ({src['region']})" if src.get("region") else "") |
| |
619 | + + " recensés par Lou-Ka, avec prix, photos et lien direct " |
| |
620 | + "vers l'annonce originale.") |
| |
621 | + body = (f"<h1>{_e(name)} — {n} logements à louer</h1>" |
| |
622 | + + (f"<p>Secteurs : {_e(src['sectors'])}.</p>" if src.get("sectors") else "") |
| |
623 | + + "<ul>" + "".join(_listing_li(r) for r in rows) + "</ul>" |
| |
624 | + + '<p><a href="/sources">Tous les gestionnaires</a></p>') |
| |
625 | + jsonld = [_breadcrumb([("Accueil", "/"), ("Sources", "/sources"), |
| |
626 | + (name, f"/g/{source_id}")]), |
| |
627 | + {"@context": "https://schema.org", "@type": "Organization", |
| |
628 | + "name": name, "url": src.get("url") or f"{BASE_URL}/g/{source_id}"}] |
| |
629 | + return _render(title=title, description=description, path=f"/g/{source_id}", |
| |
630 | + jsonld=jsonld, body=body) |
| |
631 | + |
| |
632 | + |
| |
633 | +# pages statiques de l'app : head unique, contenu rendu par React |
| |
634 | +_STATIC_META = { |
| |
635 | + "/stats": ("Statistiques du marché locatif québécois | Lou-Ka", |
| |
636 | + "Loyers moyens et médians, répartition par ville et par type de logement : " |
| |
637 | + "les statistiques du marché locatif québécois, calculées en continu par Lou-Ka."), |
| |
638 | + "/sources": ("Gestionnaires immobiliers recensés | Lou-Ka", |
| |
639 | + "La liste des gestionnaires immobiliers du Québec dont Lou-Ka agrège les " |
| |
640 | + "logements à louer, avec le nombre d'annonces actives de chacun."), |
| |
641 | + "/confidentialite": ("Politique de confidentialité | Lou-Ka", |
| |
642 | + "Politique de confidentialité de Lou-Ka : données collectées, " |
| |
643 | + "témoins (cookies) et droits des utilisateurs."), |
| |
644 | + "/conditions": ("Conditions d'utilisation | Lou-Ka", |
| |
645 | + "Conditions d'utilisation du service Lou-Ka, agrégateur indépendant " |
| |
646 | + "de logements à louer au Québec."), |
| |
647 | +} |
| |
648 | + |
| |
649 | + |
| |
650 | +def _static_page(path: str): |
| |
651 | + title, description = _STATIC_META[path] |
| |
652 | + return _render(title=title, description=description, path=path) |
| |
653 | + |
| |
654 | + |
| |
655 | +@router.get("/stats", include_in_schema=False) |
| |
656 | +def stats_page(): |
| |
657 | + return _static_page("/stats") |
| |
658 | + |
| |
659 | + |
| |
660 | +@router.get("/sources", include_in_schema=False) |
| |
661 | +def sources_page(): |
| |
662 | + return _static_page("/sources") |
| |
663 | + |
| |
664 | + |
| |
665 | +@router.get("/confidentialite", include_in_schema=False) |
| |
666 | +def privacy_page(): |
| |
667 | + return _static_page("/confidentialite") |
| |
668 | + |
| |
669 | + |
| |
670 | +@router.get("/conditions", include_in_schema=False) |
| |
671 | +def terms_page(): |
| |
672 | + return _static_page("/conditions") |