SPB Git

spb/job-ka

Public

SEO : pages programmatiques /villes + /ville/{slug} rendues en HTML (pattern lou-ka)

- jobka/seo.py : slugify, cache slug→ville, SSR /villes et /ville/{slug}
  (contenu dans #root, ItemList + BreadcrumbList), API /api/seo/villes et
  /api/seo/ville/{slug}, sitemap-villes.xml, liens internes accueil/fiche
  vers les nouvelles pages
- frontend : pages React VillesPage/VillePage, routes, lien pied de page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 23, 2026) parent 68fb887 Browse files

4 changed files +324 −7

modified frontend/public/ka-agent.js +2 −0
@@ -454,3 +454,5 @@
454 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); } 454 if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(); }
455 }); 455 });
456 })(); 456 })();
457+
458+;/*KA_ANALYTICS*/(function(){try{if(window.__kaAn)return;window.__kaAn=1;var k="KA_VID",v=localStorage.getItem(k);if(!v){v=Date.now().toString(36)+Math.random().toString(36).slice(2,8);localStorage.setItem(k,v);}var b=function(){var i=new Image();i.src="https://www.administration-ka.com/collect?site=job-ka&vid="+v+"&p="+encodeURIComponent(location.pathname)+"&t="+Date.now();};b();setInterval(b,60000);}catch(e){}})();
modified frontend/src/App.tsx +4 −0
@@ -17,6 +17,7 @@ import Sources from "./pages/Sources";
17 import Stats from "./pages/Stats"; 17 import Stats from "./pages/Stats";
18 import Contact from "./pages/Contact"; 18 import Contact from "./pages/Contact";
19 import Employeurs from "./pages/Employeurs"; 19 import Employeurs from "./pages/Employeurs";
20+import VillePage, { VillesPage } from "./pages/Ville";
20 import GroupeKaBadge from "./ka/GroupeKaBadge"; 21 import GroupeKaBadge from "./ka/GroupeKaBadge";
21 import KaFooter from "./ka/KaFooter"; 22 import KaFooter from "./ka/KaFooter";
22 23
@@ -168,6 +169,8 @@ export default function App() {
168 </Suspense> 169 </Suspense>
169 } /> 170 } />
170 <Route path="/emploi/:uid" element={<JobPage />} /> 171 <Route path="/emploi/:uid" element={<JobPage />} />
172+ <Route path="/villes" element={<VillesPage />} />
173+ <Route path="/ville/:ville" element={<VillePage />} />
171 <Route path="/sources" element={<Sources />} /> 174 <Route path="/sources" element={<Sources />} />
172 <Route path="/stats" element={<Stats />} /> 175 <Route path="/stats" element={<Stats />} />
173 <Route path="/contact" element={<Contact />} /> 176 <Route path="/contact" element={<Contact />} />
@@ -182,6 +185,7 @@ export default function App() {
182 boîte noire. 185 boîte noire.
183 </span> 186 </span>
184 <NavLink to="/">Offres</NavLink> 187 <NavLink to="/">Offres</NavLink>
188+ <NavLink to="/villes">Villes</NavLink>
185 <NavLink to="/carte">Carte</NavLink> 189 <NavLink to="/carte">Carte</NavLink>
186 <NavLink to="/sources">Employeurs</NavLink> 190 <NavLink to="/sources">Employeurs</NavLink>
187 <NavLink to="/stats">Stats</NavLink> 191 <NavLink to="/stats">Stats</NavLink>
added frontend/src/pages/Ville.tsx +149 −0
@@ -0,0 +1,149 @@
1+/**
2+ * =============================================================================
3+ * Job·Ka — Groupe KA
4+ * Auteur : Simon-Pierre Boucher
5+ * Contact : contact@spboucher.ai
6+ * Fichier : frontend/src/pages/Ville.tsx
7+ * Rôle : Pages programmatiques SEO — /villes (répertoire) et /ville/:ville.
8+ * Le serveur rend le même contenu en HTML (jobka/seo.py) ; ici, la
9+ * version interactive avec les cartes d'offres de l'app.
10+ * Créé : 2026-08-23 Modifié : 2026-08-23
11+ * =============================================================================
12+ */
13+import { useEffect, useState } from "react";
14+import { Link, useParams } from "react-router-dom";
15+import { fetchJobs, Job } from "../api";
16+import JobCard from "../components/JobCard";
17+
18+interface VilleRow {
19+ city: string;
20+ slug: string;
21+ n: number;
22+ employers: number;
23+ with_salary: number;
24+}
25+
26+interface VilleData {
27+ city: string;
28+ slug: string;
29+ n: number;
30+ employers: number;
31+ with_salary: number;
32+ neighbors: VilleRow[];
33+}
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+ window.scrollTo(0, 0);
50+ }, []);
51+
52+ return (
53+ <div className="container">
54+ <h1>Offres d'emploi par ville</h1>
55+ <p className="sub">
56+ Toutes les villes du Québec où Job-Ka recense des offres d'emploi,
57+ avec le nombre de postes ouverts et d'employeurs qui recrutent.
58+ </p>
59+ {error && <div className="empty">⚠️ {error}</div>}
60+ {!villes && !error && <div className="empty">Chargement…</div>}
61+ {villes && (
62+ <ul style={{ marginTop: 24, lineHeight: 2, paddingLeft: 20 }}>
63+ {villes.map((v) => (
64+ <li key={v.slug}>
65+ <Link to={`/ville/${v.slug}`}>{v.city}</Link>
66+ {" — "}
67+ {v.n} offre{v.n > 1 ? "s" : ""} · {v.employers} employeur
68+ {v.employers > 1 ? "s" : ""}
69+ </li>
70+ ))}
71+ </ul>
72+ )}
73+ </div>
74+ );
75+}
76+
77+export default function VillePage() {
78+ const { ville = "" } = useParams();
79+ const [data, setData] = useState<VilleData | null>(null);
80+ const [jobs, setJobs] = useState<Job[] | null>(null);
81+ const [error, setError] = useState<string | null>(null);
82+
83+ useEffect(() => {
84+ setData(null);
85+ setJobs(null);
86+ setError(null);
87+ get<VilleData>(`/api/seo/ville/${encodeURIComponent(ville)}`)
88+ .then((d) => {
89+ setData(d);
90+ return fetchJobs({ city: d.city }, 60).then((r) => setJobs(r.jobs));
91+ })
92+ .catch((e) =>
93+ setError(e.message === "404" ? "Ville inconnue." : String(e)));
94+ window.scrollTo(0, 0);
95+ }, [ville]);
96+
97+ if (error)
98+ return (
99+ <div className="container">
100+ <h1>Page introuvable</h1>
101+ <div className="empty">
102+ ⚠️ {error} <Link to="/villes">Voir toutes les villes</Link>
103+ </div>
104+ </div>
105+ );
106+ if (!data)
107+ return (
108+ <div className="container">
109+ <div className="empty">Chargement…</div>
110+ </div>
111+ );
112+
113+ return (
114+ <div className="container">
115+ <p style={{ margin: "0 0 4px" }}>
116+ <Link to="/villes">Villes</Link> — {data.city}
117+ </p>
118+ <h1>Offres d'emploi à {data.city}</h1>
119+ <p className="sub">
120+ {data.n} poste{data.n > 1 ? "s" : ""} chez {data.employers} employeur
121+ {data.employers > 1 ? "s" : ""}
122+ {data.with_salary > 0 && (
123+ <> · {data.with_salary} avec salaire affiché</>
124+ )}
125+ </p>
126+
127+ <div className="job-list" style={{ marginTop: 24 }}>
128+ {!jobs && <div className="empty">Chargement des offres…</div>}
129+ {jobs && jobs.map((j) => <JobCard key={j.uid} job={j} />)}
130+ </div>
131+
132+ {data.neighbors.length > 0 && (
133+ <>
134+ <h2 style={{ marginTop: 40 }}>Autres villes</h2>
135+ <p style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
136+ {data.neighbors.map((v) => (
137+ <Link key={v.slug} to={`/ville/${v.slug}`}>
138+ {v.city} ({v.n})
139+ </Link>
140+ ))}
141+ </p>
142+ </>
143+ )}
144+ <p style={{ marginTop: 24 }}>
145+ <Link to="/">← Toutes les offres d'emploi au Québec</Link>
146+ </p>
147+ </div>
148+ );
149+}
modified jobka/seo.py +169 −7
@@ -19,6 +19,8 @@ import html
19 import json 19 import json
20 import math 20 import math
21 import re 21 import re
22+import time
23+import unicodedata
22 from datetime import date, datetime, timezone 24 from datetime import date, datetime, timezone
23 from pathlib import Path 25 from pathlib import Path
24 from urllib.parse import quote 26 from urllib.parse import quote
@@ -42,6 +44,9 @@ PUB = "active=1 AND dup_of IS NULL AND quarantine IS NULL"
42 44
43 SITEMAP_CHUNK = 10000 45 SITEMAP_CHUNK = 10000
44 46
47+# seuil d'inclusion d'une page ville dans le sitemap (pages trop maigres exclues)
48+MIN_JOBS_PAGE = 3
49+
45 # vocabulaires internes → valeurs schema.org (JobPosting) 50 # vocabulaires internes → valeurs schema.org (JobPosting)
46 EMPLOYMENT_TYPE_SCHEMA = { 51 EMPLOYMENT_TYPE_SCHEMA = {
47 "temps_plein": "FULL_TIME", 52 "temps_plein": "FULL_TIME",
@@ -170,6 +175,40 @@ def _job_li(r) -> str:
170 f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>') 175 f'{" — " + _e(" · ".join(bits)) if bits else ""}</li>')
171 176
172 177
178+def slugify(text: str) -> str:
179+ """« Trois-Rivières » → trois-rivieres, « L'Île-Perrot » → l-ile-perrot."""
180+ t = text.replace("œ", "oe").replace("Œ", "Oe").replace("æ", "ae").replace("Æ", "Ae")
181+ t = unicodedata.normalize("NFKD", t).encode("ascii", "ignore").decode()
182+ t = re.sub(r"[^a-z0-9]+", "-", t.lower()).strip("-")
183+ return t
184+
185+
186+_villes_cache: dict = {"ts": 0.0, "map": {}}
187+
188+
189+def _ville_map() -> dict[str, str]:
190+ """slug → ville, reconstruit au plus toutes les 10 minutes."""
191+ if time.time() - _villes_cache["ts"] > 600:
192+ con = db.connect()
193+ cities = [r["city"] for r in con.execute(
194+ f"SELECT DISTINCT city FROM jobs WHERE {PUB} AND city<>''")]
195+ con.close()
196+ _villes_cache["map"] = {slugify(c): c for c in sorted(cities)}
197+ _villes_cache["ts"] = time.time()
198+ return _villes_cache["map"]
199+
200+
201+def _villes_rows(con, minimum: int = 1) -> list[dict]:
202+ rows = [dict(r) for r in con.execute(
203+ f"""SELECT city, COUNT(*) n, COUNT(DISTINCT employer) employers,
204+ SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary
205+ FROM jobs WHERE {PUB} AND city<>''
206+ GROUP BY city HAVING n>=? ORDER BY n DESC""", (minimum,))]
207+ for r in rows:
208+ r["slug"] = slugify(r["city"])
209+ return rows
210+
211+
173 def _breadcrumb(items: list[tuple[str, str]]) -> dict: 212 def _breadcrumb(items: list[tuple[str, str]]) -> dict:
174 return {"@context": "https://schema.org", "@type": "BreadcrumbList", 213 return {"@context": "https://schema.org", "@type": "BreadcrumbList",
175 "itemListElement": [ 214 "itemListElement": [
@@ -190,6 +229,34 @@ def _not_found(message: str, path: str) -> HTMLResponse:
190 status=404) 229 status=404)
191 230
192 231
232+# --- API JSON pour les pages villes du frontend --------------------------------
233+
234+@router.get("/api/seo/villes")
235+def api_villes():
236+ con = db.connect()
237+ rows = _villes_rows(con)
238+ con.close()
239+ return {"villes": rows}
240+
241+
242+@router.get("/api/seo/ville/{slug}")
243+def api_ville(slug: str):
244+ city = _ville_map().get(slug)
245+ if not city:
246+ raise HTTPException(404, "Ville inconnue")
247+ con = db.connect()
248+ agg = con.execute(
249+ f"""SELECT COUNT(*) n, COUNT(DISTINCT employer) employers,
250+ SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary
251+ FROM jobs WHERE {PUB} AND city=?""", (city,)).fetchone()
252+ neighbors = [v for v in _villes_rows(con, MIN_JOBS_PAGE)
253+ if v["city"] != city][:12]
254+ con.close()
255+ return {"city": city, "slug": slug, "n": agg["n"],
256+ "employers": agg["employers"], "with_salary": agg["with_salary"],
257+ "neighbors": neighbors}
258+
259+
193 # --- robots.txt & sitemaps ---------------------------------------------------- 260 # --- robots.txt & sitemaps ----------------------------------------------------
194 261
195 @router.get("/robots.txt", include_in_schema=False) 262 @router.get("/robots.txt", include_in_schema=False)
@@ -223,7 +290,7 @@ def sitemap_index():
223 total = con.execute(f"SELECT COUNT(*) c FROM jobs WHERE {PUB}").fetchone()["c"] 290 total = con.execute(f"SELECT COUNT(*) c FROM jobs WHERE {PUB}").fetchone()["c"]
224 con.close() 291 con.close()
225 chunks = max(1, math.ceil(total / SITEMAP_CHUNK)) 292 chunks = max(1, math.ceil(total / SITEMAP_CHUNK))
226− names = ["sitemap-pages.xml"] + [ 293+ names = ["sitemap-pages.xml", "sitemap-villes.xml"] + [
227 f"sitemap-emplois-{i}.xml" for i in range(1, chunks + 1)] 294 f"sitemap-emplois-{i}.xml" for i in range(1, chunks + 1)]
228 today = date.today().isoformat() 295 today = date.today().isoformat()
229 rows = "\n".join( 296 rows = "\n".join(
@@ -237,6 +304,7 @@ def sitemap_index():
237 def sitemap_pages(): 304 def sitemap_pages():
238 urls: list[tuple[str, str | None]] = [ 305 urls: list[tuple[str, str | None]] = [
239 (f"{BASE_URL}/", None), 306 (f"{BASE_URL}/", None),
307+ (f"{BASE_URL}/villes", None),
240 (f"{BASE_URL}/carte", None), 308 (f"{BASE_URL}/carte", None),
241 (f"{BASE_URL}/sources", None), 309 (f"{BASE_URL}/sources", None),
242 (f"{BASE_URL}/stats", None), 310 (f"{BASE_URL}/stats", None),
@@ -245,6 +313,15 @@ def sitemap_pages():
245 return _urlset(urls) 313 return _urlset(urls)
246 314
247 315
316+@router.get("/sitemap-villes.xml", include_in_schema=False)
317+def sitemap_villes():
318+ con = db.connect()
319+ villes = _villes_rows(con, MIN_JOBS_PAGE)
320+ con.close()
321+ today = date.today().isoformat()
322+ return _urlset([(f"{BASE_URL}/ville/{v['slug']}", today) for v in villes])
323+
324+
248 @router.get("/sitemap-emplois-{num}.xml", include_in_schema=False) 325 @router.get("/sitemap-emplois-{num}.xml", include_in_schema=False)
249 def sitemap_emplois(num: int): 326 def sitemap_emplois(num: int):
250 if num < 1: 327 if num < 1:
@@ -275,9 +352,7 @@ def home_ssr():
275 COUNT(DISTINCT source) sources 352 COUNT(DISTINCT source) sources
276 FROM jobs WHERE {PUB}""").fetchone() 353 FROM jobs WHERE {PUB}""").fetchone()
277 total, employers, nsources = agg["total"], agg["employers"], agg["sources"] 354 total, employers, nsources = agg["total"], agg["employers"], agg["sources"]
278− villes = [dict(r) for r in con.execute( 355+ villes = _villes_rows(con, MIN_JOBS_PAGE)
279− f"""SELECT city, COUNT(*) n FROM jobs WHERE {PUB} AND city<>''
280− GROUP BY city ORDER BY n DESC LIMIT 25""")]
281 with_salary = con.execute( 356 with_salary = con.execute(
282 f"SELECT COUNT(*) c FROM jobs WHERE {PUB} AND salary_min IS NOT NULL" 357 f"SELECT COUNT(*) c FROM jobs WHERE {PUB} AND salary_min IS NOT NULL"
283 ).fetchone()["c"] 358 ).fetchone()["c"]
@@ -305,9 +380,10 @@ def home_ssr():
305 "avec transparence salariale).</p>" 380 "avec transparence salariale).</p>"
306 + "<h2>Emplois par ville</h2><ul>" 381 + "<h2>Emplois par ville</h2><ul>"
307 + "".join( 382 + "".join(
308− f'<li><a href="/?ville={quote(v["city"])}">Emplois à ' 383+ f'<li><a href="/ville/{v["slug"]}">Emplois à '
309− f'{_e(v["city"])}</a> — {v["n"]} offres</li>' for v in villes) 384+ f'{_e(v["city"])}</a> — {v["n"]} offres</li>' for v in villes[:30])
310 + "</ul>" 385 + "</ul>"
386+ + f'<p><a href="/villes">Toutes les villes ({len(villes)})</a></p>'
311 + "<h2>Dernières offres publiées</h2><ul>" 387 + "<h2>Dernières offres publiées</h2><ul>"
312 + "".join(_job_li(r) for r in recents) 388 + "".join(_job_li(r) for r in recents)
313 + "</ul>" 389 + "</ul>"
@@ -383,7 +459,7 @@ def job_ssr(uid: str):
383 body.append(f'<p><a href="{_e(link)}" rel="nofollow">' 459 body.append(f'<p><a href="{_e(link)}" rel="nofollow">'
384 "Postuler chez l'employeur (offre originale)</a></p>") 460 "Postuler chez l'employeur (offre originale)</a></p>")
385 if d["city"]: 461 if d["city"]:
386− body.append(f'<p><a href="/?ville={quote(d["city"])}">' 462+ body.append(f'<p><a href="/ville/{slugify(d["city"])}">'
387 f"Autres offres d'emploi à {_e(d['city'])}</a></p>") 463 f"Autres offres d'emploi à {_e(d['city'])}</a></p>")
388 body.append('<p><a href="/">Toutes les offres d\'emploi au Québec</a></p>') 464 body.append('<p><a href="/">Toutes les offres d\'emploi au Québec</a></p>')
389 465
@@ -442,6 +518,92 @@ def job_ssr(uid: str):
442 else None) 518 else None)
443 519
444 520
521+# --- Pages programmatiques villes : /villes + /ville/{slug} -------------------
522+
523+@router.get("/villes", include_in_schema=False)
524+def villes_ssr():
525+ con = db.connect()
526+ villes = _villes_rows(con)
527+ con.close()
528+ total = sum(v["n"] for v in villes)
529+ title = f"Offres d'emploi par ville au Québec ({len(villes)} villes) | Job-Ka"
530+ description = (
531+ f"Toutes les villes du Québec où Job-Ka recense des offres d'emploi : "
532+ f"{_fmt_n(total)} postes dans {len(villes)} villes, avec le nombre "
533+ "d'offres et d'employeurs par ville.")
534+ body = (f"<h1>Offres d'emploi par ville — {len(villes)} villes au Québec</h1><ul>"
535+ + "".join(
536+ f'<li><a href="/ville/{v["slug"]}">Emplois à {_e(v["city"])}</a>'
537+ f' — {v["n"]} offres de {v["employers"]} employeur'
538+ f'{"s" if v["employers"] > 1 else ""}</li>' for v in villes)
539+ + "</ul>")
540+ jsonld = [_breadcrumb([("Accueil", "/"), ("Villes", "/villes")]),
541+ {"@context": "https://schema.org", "@type": "ItemList",
542+ "name": "Offres d'emploi par ville au Québec",
543+ "numberOfItems": len(villes),
544+ "itemListElement": [
545+ {"@type": "ListItem", "position": i + 1,
546+ "name": f"Emplois à {v['city']}",
547+ "url": f"{BASE_URL}/ville/{v['slug']}"}
548+ for i, v in enumerate(villes[:100])]}]
549+ return _render(title=title, description=description, path="/villes",
550+ jsonld=jsonld, body=body)
551+
552+
553+@router.get("/ville/{slug}", include_in_schema=False)
554+def ville_ssr(slug: str):
555+ path = f"/ville/{slug}"
556+ city = _ville_map().get(slug)
557+ if not city:
558+ return _not_found("Aucune offre d'emploi recensée pour cette ville", path)
559+ con = db.connect()
560+ agg = con.execute(
561+ f"""SELECT COUNT(*) n, COUNT(DISTINCT employer) employers,
562+ SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary
563+ FROM jobs WHERE {PUB} AND city=?""", (city,)).fetchone()
564+ rows = con.execute(
565+ f"""SELECT uid, title, title_clean, employer, city, salary_min,
566+ salary_max, salary_unit, salary_label
567+ FROM jobs WHERE {PUB} AND city=?
568+ ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC
569+ LIMIT 100""", (city,)).fetchall()
570+ neighbors = [v for v in _villes_rows(con, MIN_JOBS_PAGE)
571+ if v["city"] != city][:12]
572+ con.close()
573+ n = agg["n"]
574+ if n == 0:
575+ return _not_found(f"Aucune offre d'emploi active à {city}", path)
576+
577+ title = f"Offres d'emploi à {city} — {_fmt_n(n)} postes | Job-Ka"
578+ description = (
579+ f"{_fmt_n(n)} offres d'emploi à {city} chez {_fmt_n(agg['employers'])} "
580+ f"employeurs, dont {_fmt_n(agg['with_salary'])} avec salaire affiché. "
581+ "Offres agrégées à la source, à jour, avec lien direct vers la page "
582+ "carrière de l'employeur.")
583+ body = [f"<h1>Offres d'emploi à {_e(city)} — {_fmt_n(n)} postes</h1>",
584+ f"<p>{_fmt_n(agg['employers'])} employeurs recrutent à {_e(city)}"
585+ f" ; {_fmt_n(agg['with_salary'])} offres affichent le salaire.</p>",
586+ "<h2>Offres récentes</h2><ul>"
587+ + "".join(_job_li(r) for r in rows) + "</ul>"]
588+ if neighbors:
589+ body.append("<h2>Autres villes</h2><ul>" + "".join(
590+ f'<li><a href="/ville/{v["slug"]}">Emplois à {_e(v["city"])}</a>'
591+ f' — {v["n"]}</li>' for v in neighbors) + "</ul>")
592+ body.append('<p><a href="/villes">Toutes les villes</a> · '
593+ '<a href="/">Toutes les offres d\'emploi au Québec</a></p>')
594+ crumbs = [("Accueil", "/"), ("Villes", "/villes"), (city, path)]
595+ jsonld = [_breadcrumb(crumbs),
596+ {"@context": "https://schema.org", "@type": "ItemList",
597+ "name": f"Offres d'emploi à {city}", "numberOfItems": n,
598+ "itemListElement": [
599+ {"@type": "ListItem", "position": i + 1,
600+ "name": r["title_clean"] or r["title"] or r["uid"],
601+ "url": f"{BASE_URL}/emploi/{quote(r['uid'], safe='')}"}
602+ for i, r in enumerate(rows[:50])]}]
603+ return _render(title=title, description=description, path=path,
604+ jsonld=jsonld, body="".join(body))
605+
606+
445 # --- Pages statiques de l'app : head unique, contenu rendu par React ---------- 607 # --- Pages statiques de l'app : head unique, contenu rendu par React ----------
446 608
447 _STATIC_META = { 609 _STATIC_META = {
448 610
Menu

Theme