SPB Git forge

spb/auto-ka

Public
61commits 1branches 0releases
14.4 MBsize
maindefault branch
12 days agolast push
Python 61.6% TypeScript 20.9% CSS 11.4% JavaScript 5.1% HTML 1.1%
48.0 KB · 1,056 lines python
Raw Blame History
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