|
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") |