SPB Git forge

spb/lou-ka

Public

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

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

SEO — SSR léger FastAPI (louka/seo.py) : HTML complet dès la première requête ; pages programmatiques /villes et /ville/{ville}[/{type}] ; fiches avec title/description uniques + JSON-LD RealEstateListing ; robots.txt ; sitemaps dynamiques (index + villes + annonces) ; 410 annonces retirées, 404 réels (fin des soft-404) ; GZip

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 2fc73d6

6 changed files +857 −2

modified frontend/index.html +1 −1
@@ -8,7 +8,7 @@
8 8 <meta charset="UTF-8" />
9 9 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 10 <title>Lou-Ka — Logements à louer au Québec</title>
11 − <meta name="description" content="Lou-Ka agrège les appartements à louer affichés par les gestionnaires immobiliers de Québec et Lévis, toujours à jour." />
11 + <meta name="description" content="Lou-Ka agrège les appartements et logements à louer publiés par les gestionnaires immobiliers partout au Québec — Montréal, Québec, Lévis, Gatineau et plus — toujours à jour." />
12 12 <meta name="theme-color" content="#f5f3ee" />
13 13 <meta name="mobile-web-app-capable" content="yes" />
14 14 <meta name="apple-mobile-web-app-capable" content="yes" />
modified frontend/src/App.tsx +4 −0
@@ -28,6 +28,7 @@ import GestionPublicPage from "./pages/GestionPublic";
28 28 import TermsPage from "./pages/Terms";
29 29 import SourcesPage from "./pages/Sources";
30 30 import StatsPage from "./pages/Stats";
31 +import VillePage, { VillesPage } from "./pages/Ville";
31 32
32 33 function Ticker() {
33 34 const [items, setItems] = useState<string[]>([]);
@@ -301,6 +302,9 @@ export default function App() {
301 302 <Routes>
302 303 <Route path="/" element={<Home />} />
303 304 <Route path="/logement/:uid" element={<ListingPage />} />
305 + <Route path="/villes" element={<VillesPage />} />
306 + <Route path="/ville/:ville" element={<VillePage />} />
307 + <Route path="/ville/:ville/:type" element={<VillePage />} />
304 308 <Route path="/stats" element={<StatsPage />} />
305 309 <Route path="/sources" element={<SourcesPage />} />
306 310 <Route path="/confidentialite" element={<PrivacyPage />} />
added frontend/src/pages/Ville.tsx +161 −0
@@ -0,0 +1,161 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Ville.tsx : pages programmatiques SEO — /villes, /ville/:ville[/:type]
5 +// Le serveur rend le même contenu en HTML (louka/seo.py) ; ici, la version
6 +// interactive avec les cartes d'annonces de l'app.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { Link, useParams } from "react-router-dom";
10 +import { Listing } from "../api";
11 +import ListingCard from "../components/ListingCard";
12 +
13 +interface VilleRow {
14 + city: string;
15 + slug: string;
16 + n: number;
17 + avg_price: number | null;
18 +}
19 +
20 +interface VilleData {
21 + city: string;
22 + slug: string;
23 + unit_type: string | null;
24 + n: number;
25 + avg: number | null;
26 + med: number | null;
27 + types: { unit_type: string; slug: string; n: number }[];
28 + listings: Listing[];
29 + neighbors: VilleRow[];
30 +}
31 +
32 +const fmt = (p: number | null) =>
33 + p == null ? "—" : `${Math.round(p).toLocaleString("fr-CA")} $`;
34 +
35 +async function get<T>(path: string): Promise<T> {
36 + const res = await fetch(path);
37 + if (!res.ok) throw new Error(`${res.status}`);
38 + return res.json();
39 +}
40 +
41 +export function VillesPage() {
42 + const [villes, setVilles] = useState<VilleRow[] | null>(null);
43 + const [error, setError] = useState<string | null>(null);
44 +
45 + useEffect(() => {
46 + get<{ villes: VilleRow[] }>("/api/seo/villes")
47 + .then((r) => setVilles(r.villes))
48 + .catch((e) => setError(String(e)));
49 + }, []);
50 +
51 + return (
52 + <div className="container">
53 + <span className="kicker">Répertoire — logements par ville</span>
54 + <h1>Logements à louer par ville</h1>
55 + <p className="sub">
56 + Toutes les villes du Québec où Lou-Ka recense des logements à louer,
57 + avec le nombre d'annonces actives et le loyer moyen.
58 + </p>
59 + {error && <div className="notice">⚠️ {error}</div>}
60 + {!villes && !error && <div className="notice">Chargement…</div>}
61 + {villes && (
62 + <ul className="ville-list" style={{ marginTop: 24, lineHeight: 2 }}>
63 + {villes.map((v) => (
64 + <li key={v.slug}>
65 + <Link to={`/ville/${v.slug}`}>{v.city}</Link>
66 + {" — "}
67 + {v.n} annonce{v.n > 1 ? "s" : ""}
68 + {v.avg_price != null && <> · loyer moyen {fmt(v.avg_price)}</>}
69 + </li>
70 + ))}
71 + </ul>
72 + )}
73 + </div>
74 + );
75 +}
76 +
77 +export default function VillePage() {
78 + const { ville = "", type } = useParams();
79 + const [data, setData] = useState<VilleData | null>(null);
80 + const [error, setError] = useState<string | null>(null);
81 +
82 + useEffect(() => {
83 + setData(null);
84 + setError(null);
85 + const qs = type ? `?type=${encodeURIComponent(type)}` : "";
86 + get<VilleData>(`/api/seo/ville/${encodeURIComponent(ville)}${qs}`)
87 + .then(setData)
88 + .catch((e) =>
89 + setError(e.message === "404" ? "Ville ou type inconnu." : String(e)));
90 + window.scrollTo(0, 0);
91 + }, [ville, type]);
92 +
93 + if (error)
94 + return (
95 + <div className="container">
96 + <h1>Page introuvable</h1>
97 + <div className="notice">
98 + ⚠️ {error} <Link to="/villes">Voir toutes les villes</Link>
99 + </div>
100 + </div>
101 + );
102 + if (!data)
103 + return (
104 + <div className="container">
105 + <div className="notice">Chargement…</div>
106 + </div>
107 + );
108 +
109 + const what = data.unit_type ? `${data.unit_type} à louer` : "Logements à louer";
110 + return (
111 + <div className="container">
112 + <span className="kicker">
113 + <Link to="/villes">Villes</Link> — {data.city}
114 + </span>
115 + <h1>
116 + {what} à {data.city}
117 + </h1>
118 + <p className="sub">
119 + {data.n} annonce{data.n > 1 ? "s" : ""} active{data.n > 1 ? "s" : ""}
120 + {data.avg != null && <> · loyer moyen {fmt(data.avg)}</>}
121 + {data.med != null && <> · médian {fmt(data.med)}</>}
122 + </p>
123 +
124 + {!data.unit_type && data.types.length > 1 && (
125 + <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
126 + {data.types.map((t) => (
127 + <Link key={t.slug} className="pill" to={`/ville/${data.slug}/${t.slug}`}>
128 + {t.unit_type} ({t.n})
129 + </Link>
130 + ))}
131 + </p>
132 + )}
133 + {data.unit_type && (
134 + <p>
135 + <Link to={`/ville/${data.slug}`}>
136 + ← Tous les logements à {data.city}
137 + </Link>
138 + </p>
139 + )}
140 +
141 + <div className="grid" style={{ marginTop: 24 }}>
142 + {data.listings.map((l) => (
143 + <ListingCard key={l.uid} l={l} />
144 + ))}
145 + </div>
146 +
147 + {data.neighbors.length > 0 && (
148 + <>
149 + <h2 style={{ marginTop: 40 }}>Autres villes</h2>
150 + <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
151 + {data.neighbors.map((v) => (
152 + <Link key={v.slug} className="pill" to={`/ville/${v.slug}`}>
153 + {v.city} ({v.n})
154 + </Link>
155 + ))}
156 + </p>
157 + </>
158 + )}
159 + </div>
160 + );
161 +}
modified frontend/tsconfig.tsbuildinfo +1 −1
@@ -1 +1 @@
1 −{"root":["./src/app.tsx","./src/account.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/cookieconsent.tsx","./src/components/icons.tsx","./src/components/listingcard.tsx","./src/components/mapview.tsx","./src/components/pager.tsx","./src/components/quartierblock.tsx","./src/kamaps/adapter.ts","./src/kamaps/config.ts","./src/kamaps/theme.ts","./src/pages/bienvenue.tsx","./src/pages/bot.tsx","./src/pages/favoris.tsx","./src/pages/gestion.tsx","./src/pages/gestionpublic.tsx","./src/pages/home.tsx","./src/pages/listing.tsx","./src/pages/passerelle.tsx","./src/pages/privacy.tsx","./src/pages/profile.tsx","./src/pages/publicprofile.tsx","./src/pages/sources.tsx","./src/pages/stats.tsx","./src/pages/terms.tsx"],"version":"5.9.3"}
\ No newline at end of file
1 +{"root":["./src/app.tsx","./src/account.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/cookieconsent.tsx","./src/components/icons.tsx","./src/components/listingcard.tsx","./src/components/mapview.tsx","./src/components/pager.tsx","./src/components/quartierblock.tsx","./src/kamaps/adapter.ts","./src/kamaps/config.ts","./src/kamaps/theme.ts","./src/pages/bienvenue.tsx","./src/pages/bot.tsx","./src/pages/favoris.tsx","./src/pages/gestion.tsx","./src/pages/gestionpublic.tsx","./src/pages/home.tsx","./src/pages/listing.tsx","./src/pages/passerelle.tsx","./src/pages/privacy.tsx","./src/pages/profile.tsx","./src/pages/publicprofile.tsx","./src/pages/sources.tsx","./src/pages/stats.tsx","./src/pages/terms.tsx","./src/pages/ville.tsx"],"version":"5.9.3"}
\ No newline at end of file
added louka/seo.py +672 −0
@@ -0,0 +1,672 @@
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")
modified louka/web.py +18 −0
@@ -11,6 +11,7 @@ from pathlib import Path
11 11
12 12 from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
13 13 from fastapi.middleware.cors import CORSMiddleware
14 +from fastapi.middleware.gzip import GZipMiddleware
14 15 from fastapi.responses import FileResponse
15 16 from fastapi.staticfiles import StaticFiles
16 17
@@ -24,6 +25,7 @@ app = FastAPI(title="Lou-Ka API", version="1.0",
24 25 description="Agrégateur de logements à louer — province de Québec")
25 26 app.add_middleware(CORSMiddleware, allow_origins=["*"],
26 27 allow_methods=["*"], allow_headers=["*"])
28 +app.add_middleware(GZipMiddleware, minimum_size=1000)
27 29
28 30 _sync_lock = threading.Lock()
29 31
@@ -368,6 +370,11 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None):
368 370 if FRONTEND_DIST.exists():
369 371 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
370 372
373 + # référencement : SSR des routes publiques, robots.txt, sitemaps, 410.
374 + # Inclus AVANT le catch-all — l'ordre d'enregistrement fait foi.
375 + from . import seo # noqa: E402
376 + app.include_router(seo.router)
377 +
371 378 @app.middleware("http")
372 379 async def _cache_headers(request, call_next):
373 380 """Politique de cache : les bundles hachés (/assets/…) sont immuables,
@@ -381,10 +388,21 @@ if FRONTEND_DIST.exists():
381 388 resp.headers["Cache-Control"] = "no-cache"
382 389 return resp
383 390
391 + # routes de l'app rendues uniquement côté client (privées ou volatiles) —
392 + # tout autre chemin inconnu renvoie index.html avec un statut 404 : le
393 + # routeur React affiche sa page « introuvable », les moteurs de recherche
394 + # voient un vrai 404 (fin des soft-404).
395 + _CLIENT_ROUTES = {"profil", "favoris", "gestion", "bienvenue", "bot"}
396 + _CLIENT_PREFIXES = ("u/", "passerelle/")
397 +
384 398 @app.get("/{full_path:path}")
385 399 def spa(full_path: str):
386 400 target = FRONTEND_DIST / full_path
387 401 if full_path and target.is_file():
388 402 return FileResponse(target)
403 + known = (full_path in _CLIENT_ROUTES
404 + or any(full_path == p.rstrip("/") or full_path.startswith(p)
405 + for p in _CLIENT_PREFIXES))
389 406 return FileResponse(FRONTEND_DIST / "index.html",
407 + status_code=200 if known else 404,
390 408 headers={"Cache-Control": "no-cache"})
391 409