Python 61.6%
TypeScript 20.9%
CSS 11.4%
JavaScript 5.1%
HTML 1.1%
1# -----------------------------------------------------------------------------2# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# seo.py : rendu serveur des pages publiques — le HTML complet (meta uniques,5# JSON-LD schema.org, contenu dans #root) est servi dès la première6# requête, puis React monte par-dessus et reprend la main.7# Inclut : pages programmatiques (marque, modèle, région, ville,8# carrosserie), sitemap.xml dynamique, robots.txt, 410 pour les9# véhicules retirés, 404 réels.10# -----------------------------------------------------------------------------11from __future__ import annotations1213import html as html_lib14import json15import math16import re17import time18import unicodedata19from datetime import datetime, timezone20from pathlib import Path21from urllib.parse import quote2223from fastapi import APIRouter, Response24from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse2526from . import db2728BASE_URL = "https://www.auto-ka.com"29ROOT = Path(__file__).resolve().parent.parent30INDEX_PATH = ROOT / "frontend" / "dist" / "index.html"31SOURCES_PATH = ROOT / "data" / "sources.json"3233SITEMAP_CHUNK = 10_000 # URLs par sous-sitemap véhicules34LIST_SIZE = 24 # fiches listées en HTML sur les pages de recherche35MAPS_TTL = 300 # secondes de cache des dictionnaires slug -> valeur3637router = APIRouter()3839esc = html_lib.escape404142# --- utilitaires --------------------------------------------------------------4344def slugify(text: str, max_len: int = 70) -> str:45 """Slug URL — DOIT rester synchrone avec slugify() de frontend/src/api.ts."""46 t = unicodedata.normalize("NFKD", text or "")47 t = "".join(c for c in t if not unicodedata.combining(c))48 t = re.sub(r"[^a-zA-Z0-9]+", "-", t).strip("-").lower()49 t = t[:max_len].rstrip("-")50 return t or "x"515253def fmt_price(p) -> str:54 if p is None:55 return "Prix sur demande"56 return f"{int(round(p)):,}".replace(",", " ") + " $"575859def fmt_km(km) -> str:60 if km is None:61 return ""62 return f"{int(round(km)):,}".replace(",", " ") + " km"636465def _date(ts) -> str:66 try:67 return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d")68 except (TypeError, ValueError):69 return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")707172_cache: dict[str, tuple[float, object]] = {}737475def _cached(key: str, ttl: float, fn):76 now = time.time()77 hit = _cache.get(key)78 if hit is not None and now - hit[0] < ttl:79 return hit[1]80 val = fn()81 _cache[key] = (now, val)82 return val838485# --- dictionnaires slug -> valeur (base des pages programmatiques) -------------8687# Seules les carrosseries « propres » ont droit à une page dédiée : les valeurs88# brutes des concessionnaires contiennent du bruit (« 4 P0RTES », « 2dr Car »…)89BODY_WHITELIST = {"VUS", "Berline", "Camionnette", "Fourgonnette", "Cabriolet",90 "Coupé", "Hayon", "Familiale", "Compacte", "Sport"}919293def _put(dct: dict, slug: str, info: dict) -> None:94 """Insère en fusionnant les variantes de casse (RAV4 / Rav4 → même slug)."""95 prev = dct.get(slug)96 if prev is None:97 dct[slug] = info98 return99 keep = info if info["n"] >= prev["n"] else prev100 other = prev if keep is info else info101 keep = dict(keep)102 keep["n"] += other["n"]103 keep["lm"] = max(keep["lm"] or 0, other["lm"] or 0)104 dct[slug] = keep105106107def _load_maps() -> dict:108 con = db.connect()109 m: dict = {"make": {}, "model": {}, "region": {}, "city": {}, "body": {}}110 for r in con.execute(111 "SELECT kind, make, COUNT(*) n, MAX(updated_at) lm FROM vehicles"112 " WHERE active=1 AND make<>'' GROUP BY kind, make"):113 _put(m["make"].setdefault(r["kind"], {}), slugify(r["make"]),114 {"value": r["make"], "n": r["n"], "lm": r["lm"]})115 for r in con.execute(116 "SELECT make, model, COUNT(*) n, MAX(updated_at) lm FROM vehicles"117 " WHERE active=1 AND kind='auto' AND make<>'' AND model<>''"118 " GROUP BY make, model"):119 _put(m["model"].setdefault(slugify(r["make"]), {}), slugify(r["model"]),120 {"make": r["make"], "value": r["model"], "n": r["n"], "lm": r["lm"]})121 for r in con.execute(122 "SELECT region, COUNT(*) n, MAX(updated_at) lm FROM vehicles"123 " WHERE active=1 AND kind='auto' AND region<>'' GROUP BY region"):124 m["region"][slugify(r["region"])] = {"value": r["region"], "n": r["n"], "lm": r["lm"]}125 for r in con.execute(126 "SELECT city, MAX(region) region, COUNT(*) n, MAX(updated_at) lm"127 " FROM vehicles WHERE active=1 AND kind='auto' AND city<>'' GROUP BY city"):128 m["city"][slugify(r["city"])] = {129 "value": r["city"], "region": r["region"], "n": r["n"], "lm": r["lm"]}130 for r in con.execute(131 "SELECT body_type, COUNT(*) n, MAX(updated_at) lm FROM vehicles"132 " WHERE active=1 AND kind='auto' AND body_type<>'' GROUP BY body_type"):133 if r["body_type"] in BODY_WHITELIST:134 m["body"][slugify(r["body_type"])] = {135 "value": r["body_type"], "n": r["n"], "lm": r["lm"]}136 con.close()137 return m138139140def _maps() -> dict:141 return _cached("maps", MAPS_TTL, _load_maps)142143144# --- gabarit HTML (frontend/dist/index.html) -----------------------------------145146def _shell() -> str:147 """index.html du build Vite, relu quand le fichier change (cache mtime)."""148 mtime = INDEX_PATH.stat().st_mtime149 hit = _cache.get("shell")150 if hit is not None and hit[0] == mtime:151 return hit[1] # type: ignore[return-value]152 text = INDEX_PATH.read_text(encoding="utf-8")153 _cache["shell"] = (mtime, text)154 return text155156157def render_page(*, path: str, title: str, description: str, body: str = "",158 robots: str | None = None, og_image: str | None = None,159 jsonld: list[dict] | None = None, seo_ctx: dict | None = None,160 og_type: str = "website", status: int = 200) -> HTMLResponse:161 """Injecte meta + contenu dans le index.html du build (React reprend ensuite)."""162 shell = _shell()163 canonical = BASE_URL + path164 shell = re.sub(r"<title>.*?</title>", f"<title>{esc(title)}</title>", shell,165 count=1, flags=re.S)166 shell = re.sub(r'<meta name="description" content="[^"]*" />',167 f'<meta name="description" content="{esc(description, quote=True)}" />',168 shell, count=1)169 # og:image / twitter : retirer les balises statiques du gabarit (dist/index.html)170 # pour éviter les doublons — elles sont réinjectées dynamiquement ci-dessous.171 shell = re.sub(r'\s*<meta (?:property="og:image[^"]*"|name="twitter:[^"]*")'172 r' content="[^"]*" />', "", shell)173 extra: list[str] = [f'<link rel="canonical" href="{esc(canonical, quote=True)}" />']174 if robots:175 extra.append(f'<meta name="robots" content="{robots}" />')176 extra += [177 f'<link rel="alternate" hreflang="fr-ca" href="{esc(canonical, quote=True)}" />',178 f'<link rel="alternate" hreflang="x-default" href="{esc(canonical, quote=True)}" />',179 f'<meta property="og:title" content="{esc(title, quote=True)}" />',180 f'<meta property="og:description" content="{esc(description, quote=True)}" />',181 f'<meta property="og:url" content="{esc(canonical, quote=True)}" />',182 f'<meta property="og:type" content="{og_type}" />',183 '<meta property="og:site_name" content="Auto·Ka" />',184 '<meta property="og:locale" content="fr_CA" />',185 ]186 img_url = og_image or (BASE_URL + "/og.png")187 extra.append(f'<meta property="og:image" content="{esc(img_url, quote=True)}" />')188 if not og_image:189 extra.append('<meta property="og:image:width" content="1200" />')190 extra.append('<meta property="og:image:height" content="630" />')191 extra.append('<meta name="twitter:card" content="summary_large_image" />')192 extra.append(f'<meta name="twitter:image" content="{esc(img_url, quote=True)}" />')193 for ld in (jsonld or []):194 blob = json.dumps(ld, ensure_ascii=False).replace("</", "<\\/")195 extra.append(f'<script type="application/ld+json">{blob}</script>')196 if seo_ctx is not None:197 blob = json.dumps(seo_ctx, ensure_ascii=False).replace("</", "<\\/")198 extra.append(f"<script>window.__SEO_CTX__={blob}</script>")199 shell = shell.replace("</head>", " " + "\n ".join(extra) + "\n </head>", 1)200 shell = shell.replace('<div id="root"></div>', f'<div id="root">{body}</div>', 1)201 return HTMLResponse(shell, status_code=status,202 headers={"Cache-Control": "no-cache, must-revalidate"})203204205# --- fragments HTML -------------------------------------------------------------206207def _veh_slug(v: dict) -> str:208 base = " ".join(str(x) for x in209 [v.get("year"), v.get("make"), v.get("model")]210 if x) or (v.get("title") or "vehicule")211 return slugify(base)212213214def _veh_path(v: dict) -> str:215 return f"/vehicule/{quote(v['uid'], safe='')}/{_veh_slug(v)}"216217218def _first_image(v: dict) -> str | None:219 imgs = v.get("images")220 if isinstance(imgs, str):221 imgs = json.loads(imgs or "[]")222 return imgs[0] if imgs else None223224225def _card(v: dict) -> str:226 img = _first_image(v)227 photo = (f'<img src="{esc(img, quote=True)}" alt="{esc(v.get("title") or "", quote=True)}"'228 ' loading="lazy" decoding="async" width="640" height="420" />'229 if img else '<div class="nopic" aria-hidden="true">🚗</div>')230 year = f'<span class="year-chip">{v["year"]}</span>' if v.get("year") else ""231 chip_vals = [c for c in (fmt_km(v.get("mileage_km")), v.get("transmission"),232 v.get("fuel"), v.get("body_type")) if c][:4]233 chips = "".join(f'<span class="spec-chip">{esc(c)}</span>' for c in chip_vals)234 return (235 f'<a class="vcard" href="{_veh_path(v)}">'236 f'<div class="photo">{photo}{year}</div>'237 f'<div class="body"><h3>{esc(v.get("title") or "")}</h3>'238 f'<div class="price">{fmt_price(v.get("price"))}</div>'239 f'<div class="specs">{chips}</div>'240 f'<div class="foot"><span>{esc(v.get("dealer_name") or v.get("source") or "")}</span>'241 f'<span>{esc(v.get("city") or "")}</span></div>'242 f"</div></a>")243244245def _crumbs_html(crumbs: list[tuple[str, str | None]]) -> str:246 """crumbs = [(label, path|None)] — le dernier élément n'est pas un lien."""247 items = []248 for label, path in crumbs:249 if path:250 items.append(f'<a href="{esc(path, quote=True)}">{esc(label)}</a>')251 else:252 items.append(f"<span>{esc(label)}</span>")253 return ('<nav class="breadcrumbs mono" aria-label="Fil d\'Ariane">'254 + " › ".join(items) + "</nav>")255256257def _links_block(title: str, links: list[tuple[str, str]]) -> str:258 if not links:259 return ""260 inner = "".join(f'<a class="spec-chip" href="{esc(p, quote=True)}">{esc(t)}</a>'261 for t, p in links)262 return (f'<section class="seo-block"><h2>{esc(title)}</h2>'263 f'<div class="seo-links">{inner}</div></section>')264265266def _listing_body(*, h1: str, kicker: str, intro: str, st: dict,267 vehicles: list[dict], sections: list[tuple[str, list[tuple[str, str]]]],268 crumbs: list[tuple[str, str | None]] | None = None) -> str:269 parts = ['<div class="container">']270 if crumbs:271 parts.append(_crumbs_html(crumbs))272 stats_html = ""273 if st.get("n"):274 cells = [f'<div class="hstat"><b>{st["n"]:,}</b> en vente</div>'.replace(",", " ")]275 if st.get("avg_p"):276 cells.append(f'<div class="hstat">prix moyen <b>{fmt_price(st["avg_p"])}</b></div>')277 if st.get("med_p"):278 cells.append(f'<div class="hstat">prix médian <b>{fmt_price(st["med_p"])}</b></div>')279 if st.get("ymin") and st.get("ymax"):280 cells.append(f'<div class="hstat">années <b>{st["ymin"]}–{st["ymax"]}</b></div>')281 stats_html = f'<div class="hero-stats">{"".join(cells)}</div>'282 parts.append(283 f'<section class="hero"><span class="kicker">{esc(kicker)}</span>'284 f"<h1>{esc(h1)}</h1>"285 f'<p class="sub">{esc(intro)}</p>{stats_html}</section>')286 if vehicles:287 shown = min(len(vehicles), LIST_SIZE)288 label = f'{st["n"]:,}'.replace(",", " ") if st.get("n") else str(shown)289 parts.append(f'<div class="result-bar"><h2><span class="count">{label}</span>'290 " véhicules à vendre</h2></div>")291 parts.append('<div class="vgrid">' + "".join(_card(v) for v in vehicles[:LIST_SIZE])292 + "</div>")293 for title, links in sections:294 parts.append(_links_block(title, links))295 parts.append("</div>")296 return "".join(parts)297298299# --- requêtes ------------------------------------------------------------------300301def _stats(con, where: str, args: list) -> dict:302 row = con.execute(303 f"SELECT COUNT(*) n, ROUND(AVG(price)) avg_p, MIN(year) ymin, MAX(year) ymax"304 f" FROM vehicles WHERE active=1 AND dup_of IS NULL AND {where}", args).fetchone()305 st = dict(row)306 n_priced = con.execute(307 f"SELECT COUNT(*) c FROM vehicles WHERE active=1 AND dup_of IS NULL AND price IS NOT NULL"308 f" AND {where}", args).fetchone()["c"]309 st["med_p"] = None310 if n_priced:311 st["med_p"] = con.execute(312 f"SELECT price FROM vehicles WHERE active=1 AND dup_of IS NULL AND price IS NOT NULL AND {where}"313 f" ORDER BY price LIMIT 1 OFFSET ?", args + [(n_priced - 1) // 2],314 ).fetchone()["price"]315 return st316317318def _fetch(con, where: str, args: list, limit: int = LIST_SIZE) -> list[dict]:319 rows = con.execute(320 f"SELECT * FROM vehicles WHERE active=1 AND dup_of IS NULL AND {where}"321 f" ORDER BY price IS NULL, price ASC LIMIT ?", args + [limit]).fetchall()322 return [dict(r) for r in rows]323324325# --- JSON-LD --------------------------------------------------------------------326327def _ld_itemlist(name: str, n: int, vehicles: list[dict]) -> dict:328 return {329 "@context": "https://schema.org", "@type": "ItemList", "name": name,330 "numberOfItems": n,331 "itemListElement": [332 {"@type": "ListItem", "position": i + 1, "url": BASE_URL + _veh_path(v)}333 for i, v in enumerate(vehicles[:LIST_SIZE])],334 }335336337def _ld_crumbs(crumbs: list[tuple[str, str | None]]) -> dict:338 return {339 "@context": "https://schema.org", "@type": "BreadcrumbList",340 "itemListElement": [341 {"@type": "ListItem", "position": i + 1, "name": label,342 **({"item": BASE_URL + path} if path else {})}343 for i, (label, path) in enumerate(crumbs)],344 }345346347def _ld_vehicle(v: dict, canonical: str) -> dict:348 vtype = {"auto": "Car", "moto": "Motorcycle", "scooter": "Motorcycle"}.get(349 v.get("kind") or "auto", "Vehicle")350 imgs = v.get("images")351 if isinstance(imgs, str):352 imgs = json.loads(imgs or "[]")353 ld: dict = {354 "@context": "https://schema.org", "@type": vtype,355 "name": v.get("title") or "", "url": canonical,356 "itemCondition": "https://schema.org/UsedCondition",357 }358 if v.get("make"):359 ld["brand"] = {"@type": "Brand", "name": v["make"]}360 if v.get("model"):361 ld["model"] = v["model"]362 if v.get("year"):363 ld["vehicleModelDate"] = str(v["year"])364 if v.get("mileage_km") is not None:365 ld["mileageFromOdometer"] = {"@type": "QuantitativeValue",366 "value": int(v["mileage_km"]), "unitCode": "KMT"}367 if v.get("transmission"):368 ld["vehicleTransmission"] = v["transmission"]369 if v.get("fuel"):370 ld["fuelType"] = v["fuel"]371 if v.get("drivetrain"):372 ld["driveWheelConfiguration"] = v["drivetrain"]373 if v.get("body_type"):374 ld["bodyType"] = v["body_type"]375 if v.get("exterior_color"):376 ld["color"] = v["exterior_color"]377 if v.get("doors"):378 ld["numberOfDoors"] = v["doors"]379 if v.get("seats"):380 ld["seatingCapacity"] = v["seats"]381 if v.get("vin"):382 ld["vehicleIdentificationNumber"] = v["vin"]383 if v.get("description"):384 ld["description"] = (v["description"] or "")[:400]385 if imgs:386 ld["image"] = imgs[:5]387 if v.get("price") is not None:388 ld["offers"] = {389 "@type": "Offer", "price": int(round(v["price"])), "priceCurrency": "CAD",390 "availability": "https://schema.org/InStock",391 "itemCondition": "https://schema.org/UsedCondition", "url": canonical,392 "seller": {"@type": "AutoDealer",393 "name": v.get("dealer_name") or v.get("source") or "",394 "address": {"@type": "PostalAddress",395 "addressLocality": v.get("city") or "",396 "addressRegion": "QC", "addressCountry": "CA"}},397 }398 return ld399400401# --- blocs de liens partagés -----------------------------------------------------402403def _links_makes(kind: str = "auto", limit: int = 30, skip: str | None = None) -> list[tuple[str, str]]:404 prefix = {"auto": "/usagees/", "moto": "/motos/", "scooter": "/scooters/"}[kind]405 makes = _maps()["make"].get(kind, {})406 out = []407 for slug, info in sorted(makes.items(), key=lambda kv: -kv[1]["n"])[: limit + 1]:408 if skip and info["value"] == skip:409 continue410 out.append((f'{info["value"]} ({info["n"]})', prefix + slug))411 return out[:limit]412413414def _links_regions(skip: str | None = None) -> list[tuple[str, str]]:415 return [(f'{i["value"]} ({i["n"]})', "/region/" + s)416 for s, i in sorted(_maps()["region"].items(), key=lambda kv: -kv[1]["n"])417 if i["value"] != skip]418419420def _links_cities(region: str | None = None, limit: int = 30,421 skip: str | None = None) -> list[tuple[str, str]]:422 out = []423 for s, i in sorted(_maps()["city"].items(), key=lambda kv: -kv[1]["n"]):424 if region and i["region"] != region:425 continue426 if skip and i["value"] == skip:427 continue428 out.append((f'{i["value"]} ({i["n"]})', "/ville/" + s))429 return out[:limit]430431432def _links_bodies(skip: str | None = None) -> list[tuple[str, str]]:433 return [(f'{i["value"]} ({i["n"]})', "/carrosserie/" + s)434 for s, i in sorted(_maps()["body"].items(), key=lambda kv: -kv[1]["n"])435 if i["value"] != skip]436437438def _links_models(make_slug: str, limit: int = 40,439 skip: str | None = None) -> list[tuple[str, str]]:440 models = _maps()["model"].get(make_slug, {})441 out = []442 for s, i in sorted(models.items(), key=lambda kv: -kv[1]["n"]):443 if skip and i["value"] == skip:444 continue445 out.append((f'{i["value"]} ({i["n"]})', f"/usagees/{make_slug}/{s}"))446 return out[:limit]447448449# --- page 404 ---------------------------------------------------------------------450451def render_404(path: str = "/introuvable") -> HTMLResponse:452 body = ('<div class="container"><div class="notice"><div class="big">🧭</div>'453 "<h1>Page introuvable</h1><p>Le lien demandé n'existe pas ou plus.</p>"454 '<p><a class="btn ghost" href="/">← Toutes les voitures usagées</a></p>'455 "</div></div>")456 return render_page(path=path, title="Page introuvable | Auto·Ka",457 description="Cette page n'existe pas. Retrouvez toutes les "458 "voitures usagées à vendre au Québec sur Auto-Ka.",459 body=body, robots="noindex, follow", status=404)460461462# --- routes : accueils ---------------------------------------------------------------463464_KIND_HOME = {465 "auto": {466 "path": "/", "who": "voitures usagées",467 "title": "Voitures usagées à vendre au Québec — {n} véhicules | Auto·Ka · Un service Groupe KA",468 "h1": "Toutes les voitures usagées à vendre au Québec. Un seul endroit.",469 "intro": ("Auto-Ka visite les sites des concessionnaires et marchands "470 "d'occasion de toutes les régions du Québec, normalise chaque "471 "annonce et détecte les nouveautés, les ventes et les baisses "472 "de prix — automatiquement."),473 },474 "moto": {475 "path": "/motos", "who": "motos usagées",476 "title": "Motos usagées à vendre au Québec — {n} motos | Auto·Ka",477 "h1": "Toutes les motos usagées à vendre au Québec. Un seul endroit.",478 "intro": ("Les inventaires des concessionnaires moto du Québec — "479 "Harley-Davidson, Honda, Yamaha, Kawasaki, BMW et plus — "480 "agrégés à la source et tenus à jour automatiquement."),481 },482 "scooter": {483 "path": "/scooters", "who": "scooters usagés",484 "title": "Scooters usagés à vendre au Québec — {n} scooters | Auto·Ka",485 "h1": "Tous les scooters usagés à vendre au Québec. Un seul endroit.",486 "intro": ("Les scooters usagés des concessionnaires du Québec — Vespa, "487 "Honda, Yamaha, Kymco et plus — agrégés à la source et tenus "488 "à jour automatiquement."),489 },490}491492493def _home(kind: str) -> HTMLResponse:494 cfg = _KIND_HOME[kind]495 con = db.connect()496 st = _stats(con, "kind=?", [kind])497 n_sources = con.execute(498 "SELECT COUNT(DISTINCT source) c FROM vehicles WHERE active=1 AND kind=?",499 [kind]).fetchone()["c"]500 vehicles = _fetch(con, "kind=?", [kind])501 con.close()502 n_txt = f'{st["n"]:,}'.replace(",", " ")503 title = cfg["title"].format(n=n_txt)504 description = (f"{n_txt} {cfg['who']} à vendre chez {n_sources} concessionnaires "505 f"du Québec, agrégées directement depuis leurs sites."506 + (f" Prix moyen {fmt_price(st['avg_p'])}." if st.get("avg_p") else "")507 + " Mise à jour continue.")508 sections: list[tuple[str, list[tuple[str, str]]]] = [509 ("Par marque", _links_makes(kind)),510 ]511 if kind == "auto":512 sections += [513 ("Par région", _links_regions()),514 ("Par carrosserie", _links_bodies()),515 ("Par ville", _links_cities()),516 ("Aussi sur Auto-Ka", [("Motos usagées", "/motos"),517 ("Scooters usagés", "/scooters"),518 ("Statistiques du marché", "/stats"),519 ("Concessionnaires sources", "/sources")]),520 ]521 body = _listing_body(h1=cfg["h1"], kicker="Agrégateur indépendant — direct des concessionnaires",522 intro=cfg["intro"], st=st, vehicles=vehicles, sections=sections)523 jsonld = [_ld_itemlist(title, st["n"], vehicles)]524 if kind == "auto":525 jsonld.insert(0, {526 "@context": "https://schema.org", "@type": "WebSite",527 "name": "Auto·Ka", "url": BASE_URL + "/",528 "potentialAction": {"@type": "SearchAction",529 "target": f"{BASE_URL}/?q={{search_term_string}}",530 "query-input": "required name=search_term_string"},531 })532 return render_page(path=cfg["path"], title=title, description=description,533 body=body, jsonld=jsonld)534535536@router.get("/", response_model=None, include_in_schema=False)537def home_auto():538 return _home("auto")539540541@router.get("/motos", response_model=None, include_in_schema=False)542def home_moto():543 return _home("moto")544545546@router.get("/scooters", response_model=None, include_in_schema=False)547def home_scooter():548 return _home("scooter")549550551# --- routes : pages programmatiques ----------------------------------------------552553def _listing_page(*, path: str, kind: str, where: str, args: list, h1: str,554 title_fmt: str, desc_intro: str, filters: dict,555 sections: list[tuple[str, list[tuple[str, str]]]],556 crumbs: list[tuple[str, str | None]]) -> HTMLResponse:557 con = db.connect()558 st = _stats(con, where, args)559 vehicles = _fetch(con, where, args)560 con.close()561 if not st["n"]:562 return render_404(path)563 n_txt = f'{st["n"]:,}'.replace(",", " ")564 title = title_fmt.format(n=n_txt)565 description = (f"{desc_intro} {n_txt} en inventaire"566 + (f", prix moyen {fmt_price(st['avg_p'])}" if st.get("avg_p") else "")567 + (f", médian {fmt_price(st['med_p'])}" if st.get("med_p") else "")568 + ". Annonces agrégées directement des sites des concessionnaires,"569 " mises à jour en continu.")570 intro = (f"{n_txt} annonces vérifiées chez les concessionnaires du Québec."571 + (f" Prix moyen {fmt_price(st['avg_p'])}, médian {fmt_price(st['med_p'])}."572 if st.get("avg_p") and st.get("med_p") else "")573 + " Chaque fiche renvoie vers l'annonce originale du commerçant.")574 seo_ctx = {"path": path, "kind": kind, "h1": h1, "intro": intro, "filters": filters}575 body = _listing_body(h1=h1, kicker="Voitures usagées — direct des concessionnaires"576 if kind == "auto" else "Direct des concessionnaires",577 intro=intro, st=st, vehicles=vehicles,578 sections=sections, crumbs=crumbs)579 return render_page(path=path, title=title, description=description, body=body,580 jsonld=[_ld_crumbs(crumbs), _ld_itemlist(h1, st["n"], vehicles)],581 seo_ctx=seo_ctx)582583584@router.get("/usagees/{make_slug}", response_model=None, include_in_schema=False)585def page_make(make_slug: str):586 info = _maps()["make"].get("auto", {}).get(make_slug)587 if not info:588 return render_404(f"/usagees/{make_slug}")589 make = info["value"]590 return _listing_page(591 path=f"/usagees/{make_slug}", kind="auto",592 where="kind='auto' AND make=? COLLATE NOCASE", args=[make],593 h1=f"{make} usagées à vendre au Québec",594 title_fmt=f"{make} usagées à vendre au Québec — {{n}} en inventaire | Auto·Ka",595 desc_intro=f"Toutes les {make} usagées à vendre chez les concessionnaires du Québec.",596 filters={"make": make},597 sections=[(f"Modèles {make}", _links_models(make_slug)),598 ("Autres marques", _links_makes("auto", 20, skip=make)),599 ("Par région", _links_regions())],600 crumbs=[("Accueil", "/"), (f"{make} usagées", None)])601602603@router.get("/usagees/{make_slug}/{model_slug}", response_model=None, include_in_schema=False)604def page_model(make_slug: str, model_slug: str):605 info = _maps()["model"].get(make_slug, {}).get(model_slug)606 if not info:607 return render_404(f"/usagees/{make_slug}/{model_slug}")608 make, model = info["make"], info["value"]609 return _listing_page(610 path=f"/usagees/{make_slug}/{model_slug}", kind="auto",611 where="kind='auto' AND make=? COLLATE NOCASE AND model LIKE ?", args=[make, f"{model}%"],612 h1=f"{make} {model} usagés à vendre au Québec",613 title_fmt=f"{make} {model} usagés à vendre au Québec — {{n}} en inventaire | Auto·Ka",614 desc_intro=f"Tous les {make} {model} usagés à vendre chez les concessionnaires du Québec.",615 filters={"make": make, "model": model},616 sections=[(f"Autres modèles {make}", _links_models(make_slug, skip=model)),617 ("Autres marques", _links_makes("auto", 15, skip=make))],618 crumbs=[("Accueil", "/"), (f"{make} usagées", f"/usagees/{make_slug}"),619 (f"{make} {model}", None)])620621622@router.get("/motos/{make_slug}", response_model=None, include_in_schema=False)623def page_moto_make(make_slug: str):624 info = _maps()["make"].get("moto", {}).get(make_slug)625 if not info:626 return render_404(f"/motos/{make_slug}")627 make = info["value"]628 return _listing_page(629 path=f"/motos/{make_slug}", kind="moto",630 where="kind='moto' AND make=? COLLATE NOCASE", args=[make],631 h1=f"Motos {make} usagées à vendre au Québec",632 title_fmt=f"Motos {make} usagées à vendre au Québec — {{n}} en inventaire | Auto·Ka",633 desc_intro=f"Toutes les motos {make} usagées chez les concessionnaires du Québec.",634 filters={"make": make},635 sections=[("Autres marques de motos", _links_makes("moto", 20, skip=make)),636 ("Aussi sur Auto-Ka", [("Voitures usagées", "/"),637 ("Scooters usagés", "/scooters")])],638 crumbs=[("Accueil", "/"), ("Motos", "/motos"), (make, None)])639640641@router.get("/scooters/{make_slug}", response_model=None, include_in_schema=False)642def page_scooter_make(make_slug: str):643 info = _maps()["make"].get("scooter", {}).get(make_slug)644 if not info:645 return render_404(f"/scooters/{make_slug}")646 make = info["value"]647 return _listing_page(648 path=f"/scooters/{make_slug}", kind="scooter",649 where="kind='scooter' AND make=? COLLATE NOCASE", args=[make],650 h1=f"Scooters {make} usagés à vendre au Québec",651 title_fmt=f"Scooters {make} usagés à vendre au Québec — {{n}} en inventaire | Auto·Ka",652 desc_intro=f"Tous les scooters {make} usagés chez les concessionnaires du Québec.",653 filters={"make": make},654 sections=[("Autres marques de scooters", _links_makes("scooter", 20, skip=make)),655 ("Aussi sur Auto-Ka", [("Voitures usagées", "/"), ("Motos usagées", "/motos")])],656 crumbs=[("Accueil", "/"), ("Scooters", "/scooters"), (make, None)])657658659@router.get("/region/{slug}", response_model=None, include_in_schema=False)660def page_region(slug: str):661 info = _maps()["region"].get(slug)662 if not info:663 return render_404(f"/region/{slug}")664 region = info["value"]665 return _listing_page(666 path=f"/region/{slug}", kind="auto",667 where="kind='auto' AND region=?", args=[region],668 h1=f"Voitures usagées à vendre — {region}",669 title_fmt=f"Voitures usagées à vendre — {region} ({{n}}) | Auto·Ka",670 desc_intro=f"Toutes les voitures usagées à vendre dans la région {region}.",671 filters={"region": region},672 sections=[(f"Villes — {region}", _links_cities(region=region)),673 ("Autres régions", _links_regions(skip=region)),674 ("Marques populaires", _links_makes("auto", 15))],675 crumbs=[("Accueil", "/"), (region, None)])676677678@router.get("/ville/{slug}", response_model=None, include_in_schema=False)679def page_city(slug: str):680 info = _maps()["city"].get(slug)681 if not info:682 return render_404(f"/ville/{slug}")683 city, region = info["value"], info["region"]684 region_slug = slugify(region) if region else None685 crumbs: list[tuple[str, str | None]] = [("Accueil", "/")]686 if region:687 crumbs.append((region, f"/region/{region_slug}"))688 crumbs.append((city, None))689 return _listing_page(690 path=f"/ville/{slug}", kind="auto",691 where="kind='auto' AND city=?", args=[city],692 h1=f"Voitures usagées à vendre à {city}",693 title_fmt=f"Voitures usagées à vendre à {city} ({{n}}) | Auto·Ka",694 desc_intro=f"Toutes les voitures usagées à vendre chez les concessionnaires de {city}.",695 filters={"city": city},696 sections=([(f"Autres villes — {region}", _links_cities(region=region, skip=city))]697 if region else [])698 + [("Marques populaires", _links_makes("auto", 15)),699 ("Par région", _links_regions())],700 crumbs=crumbs)701702703@router.get("/carrosserie/{slug}", response_model=None, include_in_schema=False)704def page_body(slug: str):705 info = _maps()["body"].get(slug)706 if not info:707 return render_404(f"/carrosserie/{slug}")708 body_type = info["value"]709 return _listing_page(710 path=f"/carrosserie/{slug}", kind="auto",711 where="kind='auto' AND body_type=?", args=[body_type],712 h1=f"{body_type} usagés à vendre au Québec",713 title_fmt=f"{body_type} usagés à vendre au Québec ({{n}}) | Auto·Ka",714 desc_intro=f"Tous les {body_type} usagés à vendre chez les concessionnaires du Québec.",715 filters={"body_type": body_type},716 sections=[("Autres carrosseries", _links_bodies(skip=body_type)),717 ("Marques populaires", _links_makes("auto", 15))],718 crumbs=[("Accueil", "/"), (body_type, None)])719720721# --- routes : fiches véhicule ------------------------------------------------------722723def _get_vehicle(uid: str) -> dict | None:724 con = db.connect()725 row = con.execute("SELECT * FROM vehicles WHERE uid=?", (uid,)).fetchone()726 v = dict(row) if row is not None else None727 if v is not None:728 v["images"] = json.loads(v.get("images") or "[]")729 v["features"] = json.loads(v.get("features") or "[]")730 if v.get("make") and v.get("model"):731 v["similar"] = [dict(r) for r in con.execute(732 "SELECT * FROM vehicles WHERE active=1 AND make=? AND model LIKE ?"733 " AND uid<>? ORDER BY price IS NULL, price ASC LIMIT 6",734 (v["make"], f'{v["model"].split()[0]}%', uid)).fetchall()]735 else:736 v["similar"] = []737 con.close()738 return v739740741def _fiche_crumbs(v: dict) -> list[tuple[str, str | None]]:742 crumbs: list[tuple[str, str | None]] = [("Accueil", "/")]743 if v.get("kind") == "moto":744 crumbs = [("Accueil", "/"), ("Motos", "/motos")]745 if v.get("make"):746 crumbs.append((v["make"], f'/motos/{slugify(v["make"])}'))747 elif v.get("kind") == "scooter":748 crumbs = [("Accueil", "/"), ("Scooters", "/scooters")]749 if v.get("make"):750 crumbs.append((v["make"], f'/scooters/{slugify(v["make"])}'))751 else:752 if v.get("make"):753 crumbs.append((f'{v["make"]} usagées', f'/usagees/{slugify(v["make"])}'))754 if v.get("model"):755 crumbs.append((f'{v["make"]} {v["model"]}',756 f'/usagees/{slugify(v["make"])}/{slugify(v["model"])}'))757 crumbs.append((v.get("title") or "Fiche", None))758 return crumbs759760761def _fiche_parent_path(v: dict) -> str:762 """Page catégorie la plus précise pour un véhicule (cible des liens 410)."""763 crumbs = _fiche_crumbs(v)764 for label, path in reversed(crumbs):765 if path:766 return path767 return "/"768769770def _render_410(v: dict, path: str) -> HTMLResponse:771 parent = _fiche_parent_path(v)772 links = [f'<a class="btn ghost" href="{esc(parent, quote=True)}">'773 f'Voir les véhicules similaires →</a>']774 if v.get("make"):775 links.append(f'<a class="btn ghost" href="/usagees/{slugify(v["make"])}">'776 f'Toutes les {esc(v["make"])} usagées</a>')777 body = ('<div class="container"><div class="notice"><div class="big">🚗</div>'778 f'<h1>{esc(v.get("title") or "Véhicule")} — vendu ou retiré</h1>'779 "<p>Cette annonce n'est plus en ligne chez le concessionnaire. "780 "L'inventaire évolue chaque jour — voici où continuer :</p>"781 f'<p>{" ".join(links)}</p></div></div>')782 return render_page(path=path,783 title=f'{v.get("title") or "Véhicule"} — vendu ou retiré | Auto·Ka',784 description="Cette annonce a été retirée. Consultez les véhicules "785 "similaires à vendre au Québec sur Auto-Ka.",786 body=body, robots="noindex, follow", status=410)787788789def _spec_rows(v: dict) -> list[tuple[str, str]]:790 rows = [791 ("Année", str(v["year"]) if v.get("year") else ""),792 ("Kilométrage", fmt_km(v.get("mileage_km"))),793 ("Transmission", v.get("transmission") or ""),794 ("Carburant", v.get("fuel") or ""),795 ("Motricité", v.get("drivetrain") or ""),796 ("Carrosserie", v.get("body_type") or ""),797 ("Moteur", v.get("engine") or ""),798 ("Couleur ext.", v.get("exterior_color") or ""),799 ("Couleur int.", v.get("interior_color") or ""),800 ("Portes", str(v["doors"]) if v.get("doors") else ""),801 ("Places", str(v["seats"]) if v.get("seats") else ""),802 ("NIV (VIN)", v.get("vin") or ""),803 ("No de stock", v.get("stock_number") or ""),804 ]805 return [(k, val) for k, val in rows if val]806807808@router.get("/vehicule/{uid}", response_model=None, include_in_schema=False)809def fiche_redirect(uid: str):810 v = _get_vehicle(uid)811 if v is None:812 return render_404(f"/vehicule/{quote(uid, safe='')}")813 return RedirectResponse(_veh_path(v), status_code=301)814815816@router.get("/vehicule/{uid}/{slug}", response_model=None, include_in_schema=False)817def fiche(uid: str, slug: str):818 v = _get_vehicle(uid)819 if v is None:820 return render_404(f"/vehicule/{quote(uid, safe='')}/{slug}")821 canonical_path = _veh_path(v)822 if slug != _veh_slug(v):823 return RedirectResponse(canonical_path, status_code=301)824 if not v.get("active"):825 return _render_410(v, canonical_path)826827 crumbs = _fiche_crumbs(v)828 where = " · ".join(x for x in [v.get("city"), v.get("region")] if x)829 dealer = v.get("dealer_name") or v.get("source") or ""830 img = _first_image(v)831 main_img = (f'<img src="{esc(img, quote=True)}" alt="{esc(v.get("title") or "", quote=True)}"'832 ' width="960" height="640" fetchpriority="high" />'833 if img else '<div class="nopic">🚗</div>')834 thumbs = "".join(835 f'<img src="{esc(u, quote=True)}" alt="{esc(v.get("title") or "", quote=True)} — photo {i + 2}"'836 ' loading="lazy" decoding="async" width="160" height="107" />'837 for i, u in enumerate(v["images"][1:8]))838 spec_html = "".join(f"<tr><td>{esc(k)}</td><td>{esc(val)}</td></tr>"839 for k, val in _spec_rows(v))840 feats = "".join(f'<span class="spec-chip">{esc(f)}</span>' for f in v["features"][:50])841 desc = ""842 if v.get("description"):843 desc = (f'<div class="panel" style="margin-top:18px"><h2>Description du concessionnaire</h2>'844 f'<div class="desc">{esc(v["description"])}</div></div>')845 similar = ""846 if v["similar"]:847 similar = (f'<section style="margin-top:40px"><h2>{esc(v.get("make") or "")} '848 f'{esc(v.get("model") or "")} similaires au Québec</h2><div class="vgrid">'849 + "".join(_card(s) for s in v["similar"]) + "</div></section>")850 body = (851 '<div class="container vdetail">'852 + _crumbs_html(crumbs)853 + '<div class="vd-head"><div>'854 f'<span class="kicker">{esc(dealer)}{" — " + esc(where) if where else ""}</span>'855 f'<h1>{esc(v.get("title") or "")}</h1></div>'856 f'<div class="vd-price"><div class="p">{fmt_price(v.get("price"))}</div></div></div>'857 '<div class="vd-cols"><div>'858 f'<div class="gallery"><div class="main">{main_img}</div>'859 + (f'<div class="thumbs">{thumbs}</div>' if thumbs else "")860 + "</div>" + desc + "</div><div>"861 '<div class="panel"><h2>Caractéristiques</h2>'862 f'<table class="spec-table"><tbody>{spec_html}</tbody></table>'863 + (f'<a class="cta-source" href="{esc(v["url"], quote=True)}" rel="nofollow noreferrer">'864 f"Voir chez {esc(dealer or 'le concessionnaire')} →</a>" if v.get("url") else "")865 + '<div class="cta-note">annonce originale — prix et disponibilité confirmés à la source</div></div>'866 + (f'<div class="panel"><h2>Équipements ({len(v["features"])})</h2>'867 f'<div class="feat-list">{feats}</div></div>' if feats else "")868 + "</div></div>" + similar + "</div>")869870 bits = [str(v["year"]) if v.get("year") else None,871 fmt_km(v.get("mileage_km")) or None,872 v.get("transmission") or None, v.get("fuel") or None]873 description = (f'{v.get("title") or ""} à vendre : {", ".join(b for b in bits if b)}. '874 f'{fmt_price(v.get("price"))} chez {dealer}'875 + (f" à {v['city']}" if v.get("city") else "")876 + (f" ({v['region']})" if v.get("region") else "")877 + ". Fiche complète, historique de prix et annonce originale sur Auto-Ka.")878 title = (f'{v.get("title") or "Véhicule"} — {fmt_price(v.get("price"))}'879 + (f' | {v["city"]}' if v.get("city") else "") + " | Auto·Ka")880 return render_page(path=canonical_path, title=title, description=description,881 body=body, og_image=img, og_type="product",882 jsonld=[_ld_crumbs(crumbs),883 _ld_vehicle(v, BASE_URL + canonical_path)])884885886# --- routes : stats, sources, profil -------------------------------------------------887888@router.get("/stats", response_model=None, include_in_schema=False)889def page_stats():890 con = db.connect()891 st = _stats(con, "kind='auto'", [])892 by_region = [dict(r) for r in con.execute(893 "SELECT region, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles"894 " WHERE active=1 AND kind='auto' AND region<>'' GROUP BY region ORDER BY n DESC")]895 by_make = [dict(r) for r in con.execute(896 "SELECT make, COUNT(*) n, ROUND(AVG(price)) avg_price FROM vehicles"897 " WHERE active=1 AND kind='auto' AND make<>'' GROUP BY make ORDER BY n DESC LIMIT 20")]898 con.close()899 n_txt = f'{st["n"]:,}'.replace(",", " ")900901 def _table(rows, key, href):902 trs = "".join(903 f'<tr><td><a href="{href(r)}">{esc(r[key])}</a></td>'904 f'<td>{r["n"]}</td><td>{fmt_price(r["avg_price"])}</td></tr>' for r in rows)905 return ('<table class="spec-table"><thead><tr><th></th><th>véhicules</th>'906 f"<th>prix moyen</th></tr></thead><tbody>{trs}</tbody></table>")907908 body = (909 '<div class="container"><section class="hero">'910 '<span class="kicker">Statistiques en direct</span>'911 "<h1>Le marché des voitures usagées au Québec, en chiffres</h1>"912 f'<p class="sub">{n_txt} voitures usagées en vente en ce moment. '913 f'Prix moyen {fmt_price(st["avg_p"])}, médian {fmt_price(st["med_p"])}.</p></section>'914 '<div class="panel"><h2>Par région</h2>'915 + _table(by_region, "region", lambda r: "/region/" + slugify(r["region"]))916 + '</div><div class="panel" style="margin-top:18px"><h2>Marques les plus offertes</h2>'917 + _table(by_make, "make", lambda r: "/usagees/" + slugify(r["make"]))918 + "</div></div>")919 return render_page(920 path="/stats",921 title="Statistiques du marché des voitures usagées au Québec | Auto·Ka",922 description=f"Le marché de l'occasion au Québec en direct : {n_txt} véhicules, "923 f"prix moyen {fmt_price(st['avg_p'])}, répartition par région, marque et "924 "carrosserie. Données agrégées des concessionnaires.",925 body=body)926927928@router.get("/sources", response_model=None, include_in_schema=False)929def page_sources():930 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]931 con = db.connect()932 counts = {r["source"]: r["n"] for r in con.execute(933 "SELECT source, COUNT(*) n FROM vehicles WHERE active=1 GROUP BY source")}934 con.close()935 items = "".join(936 f'<li>{esc(s["name"])}'937 + (f' — {esc(s["city"])}' if s.get("city") else "")938 + f' <span class="mono">({counts.get(s["id"], 0)})</span></li>'939 for s in registry)940 body = (941 '<div class="container"><section class="hero">'942 '<span class="kicker">Transparence des sources</span>'943 f"<h1>{len(registry)} concessionnaires connectés à Auto-Ka</h1>"944 '<p class="sub">Les annonces proviennent directement des sites publics des '945 "concessionnaires — pas des plateformes d'annonces — et sont rafraîchies "946 "automatiquement. Chaque fiche renvoie vers l'annonce originale.</p></section>"947 f'<div class="panel"><ul class="src-list">{items}</ul></div></div>')948 return render_page(949 path="/sources",950 title=f"{len(registry)} concessionnaires sources | Auto·Ka",951 description=f"Auto-Ka agrège les inventaires de {len(registry)} concessionnaires "952 "du Québec, directement depuis leurs sites. Liste complète des sources et "953 "nombre d'annonces actives.",954 body=body)955956957@router.get("/profil", response_model=None, include_in_schema=False)958def page_profil():959 return render_page(path="/profil", title="Mon profil | Auto·Ka",960 description="Votre profil Auto-Ka.", robots="noindex, nofollow")961962963@router.get("/contact", response_model=None, include_in_schema=False)964def page_contact():965 """Page contact — coordonnées officielles du Groupe KA (rendu React)."""966 return render_page(967 path="/contact",968 title="Contact — Auto-Ka, un service Groupe KA",969 description="Joindre le Groupe KA : contact@groupe-ka.com (projets, "970 "partenariats et données), info@groupe-ka.com (médias) et "971 "admin@groupe-ka.com (légal, vie privée et Loi 25). Auto-Ka est un "972 "service Groupe KA.")973974975# --- robots.txt et sitemaps -----------------------------------------------------------976977@router.get("/robots.txt", response_model=None, include_in_schema=False)978def robots_txt():979 return PlainTextResponse(980 "User-agent: *\n"981 "Allow: /\n"982 "Disallow: /api/\n"983 "Disallow: /profil\n"984 "\n"985 f"Sitemap: {BASE_URL}/sitemap.xml\n")986987988def _xml(content: str) -> Response:989 return Response(content=content, media_type="application/xml",990 headers={"Cache-Control": "no-cache, must-revalidate"})991992993@router.get("/sitemap.xml", response_model=None, include_in_schema=False)994def sitemap_index():995 con = db.connect()996 row = con.execute("SELECT COUNT(*) n, MAX(updated_at) lm FROM vehicles"997 " WHERE active=1").fetchone()998 con.close()999 lastmod = _date(row["lm"])1000 chunks = max(1, math.ceil(row["n"] / SITEMAP_CHUNK))1001 entries = [f"{BASE_URL}/sitemaps/pages.xml"] + [1002 f"{BASE_URL}/sitemaps/vehicules-{i}.xml" for i in range(1, chunks + 1)]1003 body = "".join(f"<sitemap><loc>{esc(u)}</loc><lastmod>{lastmod}</lastmod></sitemap>"1004 for u in entries)1005 return _xml('<?xml version="1.0" encoding="UTF-8"?>'1006 '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'1007 + body + "</sitemapindex>")100810091010@router.get("/sitemaps/pages.xml", response_model=None, include_in_schema=False)1011def sitemap_pages():1012 m = _maps()1013 today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")1014 urls: list[tuple[str, str]] = [("/", today), ("/motos", today), ("/scooters", today),1015 ("/stats", today), ("/sources", today),1016 ("/contact", today)]1017 for kind, prefix in (("auto", "/usagees/"), ("moto", "/motos/"), ("scooter", "/scooters/")):1018 for slug, info in m["make"].get(kind, {}).items():1019 urls.append((prefix + slug, _date(info["lm"])))1020 for make_slug, models in m["model"].items():1021 for slug, info in models.items():1022 if info["n"] >= 2:1023 urls.append((f"/usagees/{make_slug}/{slug}", _date(info["lm"])))1024 for slug, info in m["region"].items():1025 urls.append((f"/region/{slug}", _date(info["lm"])))1026 for slug, info in m["city"].items():1027 if info["n"] >= 2:1028 urls.append((f"/ville/{slug}", _date(info["lm"])))1029 for slug, info in m["body"].items():1030 urls.append((f"/carrosserie/{slug}", _date(info["lm"])))1031 body = "".join(f"<url><loc>{esc(BASE_URL + p)}</loc><lastmod>{lm}</lastmod></url>"1032 for p, lm in urls)1033 return _xml('<?xml version="1.0" encoding="UTF-8"?>'1034 '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'1035 + body + "</urlset>")103610371038@router.get("/sitemaps/vehicules-{num}.xml", response_model=None, include_in_schema=False)1039def sitemap_vehicles(num: int):1040 if num < 1:1041 return render_404(f"/sitemaps/vehicules-{num}.xml")1042 con = db.connect()1043 rows = con.execute(1044 "SELECT uid, year, make, model, trim, title, updated_at FROM vehicles"1045 " WHERE active=1 ORDER BY uid LIMIT ? OFFSET ?",1046 (SITEMAP_CHUNK, (num - 1) * SITEMAP_CHUNK)).fetchall()1047 con.close()1048 if not rows:1049 return render_404(f"/sitemaps/vehicules-{num}.xml")1050 body = "".join(1051 f"<url><loc>{esc(BASE_URL + _veh_path(dict(r)))}</loc>"1052 f"<lastmod>{_date(r['updated_at'])}</lastmod></url>" for r in rows)1053 return _xml('<?xml version="1.0" encoding="UTF-8"?>'1054 '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'1055 + body + "</urlset>")1056