SPB Git

spb/lou-ka

Public

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

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 (Aug 13, 2026) parent 2fc73d6 Browse files

6 changed files +857 −2

modified frontend/index.html +1 −1
@@ -8,7 +8,7 @@
8 <meta charset="UTF-8" /> 8 <meta charset="UTF-8" />
9 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> 9 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 <title>Lou-Ka — Logements à louer au Québec</title> 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 <meta name="theme-color" content="#f5f3ee" /> 12 <meta name="theme-color" content="#f5f3ee" />
13 <meta name="mobile-web-app-capable" content="yes" /> 13 <meta name="mobile-web-app-capable" content="yes" />
14 <meta name="apple-mobile-web-app-capable" content="yes" /> 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 import TermsPage from "./pages/Terms"; 28 import TermsPage from "./pages/Terms";
29 import SourcesPage from "./pages/Sources"; 29 import SourcesPage from "./pages/Sources";
30 import StatsPage from "./pages/Stats"; 30 import StatsPage from "./pages/Stats";
31+import VillePage, { VillesPage } from "./pages/Ville";
31 32
32 function Ticker() { 33 function Ticker() {
33 const [items, setItems] = useState<string[]>([]); 34 const [items, setItems] = useState<string[]>([]);
@@ -301,6 +302,9 @@ export default function App() {
301 <Routes> 302 <Routes>
302 <Route path="/" element={<Home />} /> 303 <Route path="/" element={<Home />} />
303 <Route path="/logement/:uid" element={<ListingPage />} /> 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 <Route path="/stats" element={<StatsPage />} /> 308 <Route path="/stats" element={<StatsPage />} />
305 <Route path="/sources" element={<SourcesPage />} /> 309 <Route path="/sources" element={<SourcesPage />} />
306 <Route path="/confidentialite" element={<PrivacyPage />} /> 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 from fastapi import BackgroundTasks, FastAPI, HTTPException, Query 12 from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
13 from fastapi.middleware.cors import CORSMiddleware 13 from fastapi.middleware.cors import CORSMiddleware
14+from fastapi.middleware.gzip import GZipMiddleware
14 from fastapi.responses import FileResponse 15 from fastapi.responses import FileResponse
15 from fastapi.staticfiles import StaticFiles 16 from fastapi.staticfiles import StaticFiles
16 17
@@ -24,6 +25,7 @@ app = FastAPI(title="Lou-Ka API", version="1.0",
24 description="Agrégateur de logements à louer — province de Québec") 25 description="Agrégateur de logements à louer — province de Québec")
25 app.add_middleware(CORSMiddleware, allow_origins=["*"], 26 app.add_middleware(CORSMiddleware, allow_origins=["*"],
26 allow_methods=["*"], allow_headers=["*"]) 27 allow_methods=["*"], allow_headers=["*"])
28+app.add_middleware(GZipMiddleware, minimum_size=1000)
27 29
28 _sync_lock = threading.Lock() 30 _sync_lock = threading.Lock()
29 31
@@ -368,6 +370,11 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None):
368 if FRONTEND_DIST.exists(): 370 if FRONTEND_DIST.exists():
369 app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") 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 @app.middleware("http") 378 @app.middleware("http")
372 async def _cache_headers(request, call_next): 379 async def _cache_headers(request, call_next):
373 """Politique de cache : les bundles hachés (/assets/…) sont immuables, 380 """Politique de cache : les bundles hachés (/assets/…) sont immuables,
@@ -381,10 +388,21 @@ if FRONTEND_DIST.exists():
381 resp.headers["Cache-Control"] = "no-cache" 388 resp.headers["Cache-Control"] = "no-cache"
382 return resp 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 @app.get("/{full_path:path}") 398 @app.get("/{full_path:path}")
385 def spa(full_path: str): 399 def spa(full_path: str):
386 target = FRONTEND_DIST / full_path 400 target = FRONTEND_DIST / full_path
387 if full_path and target.is_file(): 401 if full_path and target.is_file():
388 return FileResponse(target) 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 return FileResponse(FRONTEND_DIST / "index.html", 406 return FileResponse(FRONTEND_DIST / "index.html",
407+ status_code=200 if known else 404,
390 headers={"Cache-Control": "no-cache"}) 408 headers={"Cache-Control": "no-cache"})
391 409
Menu

Theme