# ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # seo.py : rendu serveur pour l'indexation — chaque route livre son contenu # essentiel en HTML (title/meta/canonical/og/JSON-LD + corps de page), # robots.txt, sitemaps, redirections 301 et statuts 404/410. # React ré-hydrate par-dessus (createRoot remplace #root au montage). # ----------------------------------------------------------------------------- from __future__ import annotations import html import json import os import re import sqlite3 import threading import time import unicodedata from datetime import date, datetime, timezone from pathlib import Path from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response from . import db from .schema import CATEGORIES, REGIONS BASE = os.environ.get("FABRIKA_BASE_URL", "https://www.fabri-ka.com").rstrip("/") SITE = "Fabri-Ka" DEFAULT_TITLE = ("Fabri-Ka — Tous les produits québécois. Un seul endroit." " · Un service Groupe KA") FRONT_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist" PER_PAGE = 24 SITEMAP_CHUNK = 40_000 ORIGIN_LABELS = {"A": "Fabriqué au Québec", "B": "Conçu au Québec", "C": "Détaillant québécois", "D": "Mixte", "E": "À vérifier"} # ---- slugs ------------------------------------------------------------------- def slugify(text: str) -> str: text = unicodedata.normalize("NFKD", text or "") text = "".join(c for c in text if not unicodedata.combining(c)) return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") CAT_SLUG = {key: slugify(key) for key in CATEGORIES} # cafe_the -> cafe-the SLUG_CAT = {v: k for k, v in CAT_SLUG.items()} REGION_SLUG = {r: slugify(r) for r in REGIONS} # Montérégie -> monteregie SLUG_REGION = {v: k for k, v in REGION_SLUG.items()} def cat_label(key: str) -> str: return CATEGORIES.get(key, (key or "Autres", []))[0] def product_path(uid: str, title: str) -> str: slug = slugify(title or "")[:70].rstrip("-") return f"/produits/{slug}-{uid}" if slug else f"/produits/{uid}" # ---- helpers ----------------------------------------------------------------- def esc(s) -> str: return html.escape(str(s or ""), quote=True) def fmt_int(n) -> str: return f"{int(n or 0):,}".replace(",", " ") def fmt_price(v) -> str: if v is None: return "" return f"{v:,.2f}".replace(",", " ").replace(".", ",") + " $" def clean_text(s: str, limit: int = 160) -> str: s = re.sub(r"<[^>]+>", " ", s or "") s = re.sub(r"\s+", " ", s).strip() if len(s) > limit: s = s[: limit - 1].rsplit(" ", 1)[0] + "…" return s def iso_date(epoch) -> str: try: return datetime.fromtimestamp(float(epoch), tz=timezone.utc).strftime("%Y-%m-%d") except (TypeError, ValueError): return date.today().isoformat() _cache: dict[str, tuple[float, object]] = {} _cache_lock = threading.Lock() def cached(key: str, ttl: float, fn): now = time.time() with _cache_lock: hit = _cache.get(key) if hit and now - hit[0] < ttl: return hit[1] val = fn() with _cache_lock: _cache[key] = (time.time(), val) return val def q(con: sqlite3.Connection, sql: str, args=()) -> list[dict]: return [dict(r) for r in con.execute(sql, args)] # ---- gabarit HTML ------------------------------------------------------------ _tpl = {"mtime": 0.0, "text": ""} _FALLBACK_TPL = """ Fabri-Ka
""" def template() -> str: p = FRONT_DIST / "index.html" try: m = p.stat().st_mtime except OSError: return _FALLBACK_TPL if m != _tpl["mtime"]: _tpl.update(mtime=m, text=p.read_text(encoding="utf-8")) return _tpl["text"] def render(*, title: str, description: str, canonical: str | None = None, body: str = "", jsonld: list[dict] | None = None, og: dict | None = None, robots: str | None = None, status: int = 200) -> HTMLResponse: tpl = template() tpl = re.sub(r"[^<]*", f"{esc(title)}", tpl, count=1) tpl = re.sub(r']*name="description"[^>]*>', f'', tpl, count=1) extra: list[str] = [] if canonical: url = BASE + canonical extra.append(f'') extra.append(f'') extra.append(f'') if robots: extra.append(f'') og_all = {"og:site_name": SITE, "og:locale": "fr_CA", "og:type": "website", "og:title": title, "og:description": description} if canonical: og_all["og:url"] = BASE + canonical custom = og or {} if not custom.get("og:image"): og_all["og:image"] = f"{BASE}/og.png" og_all["og:image:width"] = "1200" og_all["og:image:height"] = "630" og_all.update(custom) # les balises og/twitter statiques du gabarit sont remplacées par celles-ci tpl = re.sub(r'\s*]*/?>', "", tpl) for k, v in og_all.items(): if v: extra.append(f'') extra.append('') extra.append(f'') for obj in jsonld or []: payload = json.dumps(obj, ensure_ascii=False).replace("{payload}') tpl = tpl.replace("", "\n".join(extra) + "\n", 1) tpl = tpl.replace('
', f'
{body}
', 1) return HTMLResponse(tpl, status_code=status) def breadcrumb_ld(items: list[tuple[str, str]]) -> dict: return {"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ {"@type": "ListItem", "position": i + 1, "name": name, "item": BASE + path} for i, (name, path) in enumerate(items)]} # ---- fragments HTML ---------------------------------------------------------- def _img(src: str, alt: str, eager: bool = False) -> str: return (f'{esc(alt)}') def product_li(p: dict) -> str: href = product_path(p["uid"], p.get("title") or "") img = "" images = p.get("images") if isinstance(images, str): images = json.loads(images or "[]") if images: img = f'{_img(images[0], p.get("title") or "")}' price = fmt_price(p.get("price")) or "Prix en boutique" store = " · ".join(x for x in [p.get("store_name"), p.get("store_region")] if x) return (f'
  • {img}' f'{esc(p.get("title"))}' f'{esc(price)}' f'{esc(store)}
  • ') def product_grid(products: list[dict]) -> str: return '" def link_list(links: list[tuple[str, str, str]]) -> str: """[(href, label, suffix)] -> nav en liste.""" lis = "".join(f'
  • {esc(t)}' + (f' {esc(s)}' if s else "") + "
  • " for h, t, s in links) return f'' def pagination_html(path: str, page: int, total: int, per_page: int = PER_PAGE) -> str: pages = max(1, (total + per_page - 1) // per_page) if pages <= 1: return "" parts = ['") return "".join(parts) # ---- requêtes ---------------------------------------------------------------- def list_products(con, category: str | None, region: str | None, page: int, per_page: int = PER_PAGE) -> tuple[int, list[dict]]: where, args = ["p.active=1 AND p.listing_status='published'"], [] if category: where.append("p.category=?"); args.append(category) if region: where.append("s.region=?"); args.append(region) wsql = " AND ".join(where) total = con.execute(f"""SELECT COUNT(*) FROM products p JOIN stores s ON s.id=p.store_id WHERE {wsql}""", args).fetchone()[0] rows = q(con, f"""SELECT p.uid, p.title, p.price, p.images, s.name AS store_name, s.region AS store_region FROM products p JOIN stores s ON s.id=p.store_id WHERE {wsql} ORDER BY p.first_seen DESC LIMIT ? OFFSET ?""", args + [per_page, (page - 1) * per_page]) return total, rows def listing_stats(category: str | None, region: str | None) -> dict: def _run(): con = db.connect() try: where, args = ["p.active=1 AND p.listing_status='published'", "p.price>0", "p.price<=500000"], [] if category: where.append("p.category=?"); args.append(category) if region: where.append("s.region=?"); args.append(region) wsql = " AND ".join(where) base = f"FROM products p JOIN stores s ON s.id=p.store_id WHERE {wsql}" row = con.execute(f"""SELECT COUNT(*), MIN(p.price), MAX(p.price), ROUND(AVG(p.price),2) {base}""", args).fetchone() n, pmin, pmax, pavg = row median = None if n: r = con.execute(f"SELECT p.price {base} ORDER BY p.price LIMIT 1 OFFSET ?", args + [n // 2]).fetchone() median = r[0] if r else None return {"priced": n, "min": pmin, "max": pmax, "avg": pavg, "median": median} finally: con.close() return cached(f"lstats:{category}:{region}", 600, _run) def combo_counts() -> list[dict]: """(catégorie, région) -> nb produits + lastmod. Sert au maillage et aux sitemaps.""" def _run(): con = db.connect() try: return q(con, """SELECT p.category AS cat, s.region AS region, COUNT(*) AS n, MAX(p.last_seen) AS m FROM products p JOIN stores s ON s.id=p.store_id WHERE p.active=1 AND p.listing_status='published' GROUP BY p.category, s.region""") finally: con.close() return cached("combos", 3600, _run) def stats_line(total: int, st: dict) -> str: bits = [f"{fmt_int(total)} produits"] if st.get("avg") is not None: bits.append(f"prix moyen {fmt_price(st['avg'])}") if st.get("median") is not None: bits.append(f"prix médian {fmt_price(st['median'])}") if st.get("min") is not None and st.get("max") is not None: bits.append(f"de {fmt_price(st['min'])} à {fmt_price(st['max'])}") return f'

    {" · ".join(bits)}.

    ' # ---- pages ------------------------------------------------------------------- def page_home() -> HTMLResponse: def _data(): con = db.connect() try: totals = q(con, """SELECT (SELECT COUNT(*) FROM products WHERE active=1 AND listing_status='published') AS products, (SELECT COUNT(*) FROM stores WHERE product_count>0) AS stores, (SELECT COUNT(DISTINCT region) FROM stores WHERE region<>'' AND product_count>0) AS regions """)[0] cats = q(con, """SELECT category AS key, COUNT(*) AS n FROM products WHERE active=1 AND listing_status='published' GROUP BY category ORDER BY n DESC""") regions = q(con, """SELECT s.region AS key, COUNT(*) AS n FROM products p JOIN stores s ON s.id=p.store_id WHERE p.active=1 AND p.listing_status='published' AND s.region<>'' GROUP BY s.region ORDER BY n DESC""") top_stores = q(con, """SELECT id, name, product_count AS n FROM stores WHERE product_count>0 ORDER BY n DESC LIMIT 20""") newest = q(con, """SELECT p.uid, p.title, p.price, p.images, s.name AS store_name, s.region AS store_region FROM products p JOIN stores s ON s.id=p.store_id WHERE p.active=1 AND p.listing_status='published' ORDER BY p.first_seen DESC LIMIT 12""") return totals, cats, regions, top_stores, newest finally: con.close() totals, cats, regions, top_stores, newest = cached("home", 600, _data) desc = (f"Fabri-Ka agrège {fmt_int(totals['products'])} produits de " f"{fmt_int(totals['stores'])} boutiques en ligne québécoises, dans les " f"{totals['regions']} régions du Québec. Produits fabriqués, conçus ou " f"vendus au Québec, tous au même endroit.") body = f"""

    Tous les produits québécois. Un seul endroit.

    {esc(desc)}

    Voir les {fmt_int(totals["products"])} produits · {fmt_int(totals["stores"])} boutiques · Statistiques · À propos · Contact · Un service Groupe KA

    Parcourir par catégorie

    {link_list([(f"/categorie/{CAT_SLUG.get(c['key'], slugify(c['key'] or 'autre'))}", cat_label(c["key"]), f"{fmt_int(c['n'])} produits") for c in cats if c["key"] in CAT_SLUG])}

    Parcourir par région

    {link_list([(f"/region/{REGION_SLUG[r['key']]}", r["key"], f"{fmt_int(r['n'])} produits") for r in regions if r["key"] in REGION_SLUG])}

    Boutiques populaires

    {link_list([(f"/boutiques/{s['id']}", s["name"], f"{fmt_int(s['n'])} produits") for s in top_stores])}

    Nouveaux produits

    {product_grid(newest)}
    """ jsonld = [ {"@context": "https://schema.org", "@type": "WebSite", "url": BASE + "/", "name": SITE, "inLanguage": "fr-CA", "potentialAction": {"@type": "SearchAction", "target": {"@type": "EntryPoint", "urlTemplate": BASE + "/produits?q={search_term_string}"}, "query-input": "required name=search_term_string"}}, {"@context": "https://schema.org", "@type": "Organization", "name": SITE, "url": BASE + "/", "email": "contact@spboucher.ai"}, ] return render(title=DEFAULT_TITLE, description=desc, canonical="/", body=body, jsonld=jsonld) def page_listing(category: str | None, region: str | None, params) -> Response: try: page = max(1, int(params.get("page", "1"))) except ValueError: page = 1 if category: path = f"/categorie/{CAT_SLUG[category]}" if region: path += f"/region/{REGION_SLUG[region]}" elif region: path = f"/region/{REGION_SLUG[region]}" else: path = "/produits" con = db.connect() try: total, rows = list_products(con, category, region, page) finally: con.close() pages = max(1, (total + PER_PAGE - 1) // PER_PAGE) if path != "/produits" and total == 0: return page_404() if page > pages: return page_404() label = cat_label(category) if category else None if category and region: h1 = f"{label} — {region}" title = f"{label} — {region} | {SITE}" desc = (f"{fmt_int(total)} produits « {label} » offerts par des boutiques de la région " f"{region}. Comparez les produits québécois sur Fabri-Ka.") elif category: h1 = f"{label} du Québec" title = f"{label} du Québec — {fmt_int(total)} produits | {SITE}" desc = (f"{fmt_int(total)} produits « {label} » de boutiques en ligne québécoises, " f"réunis sur Fabri-Ka. Prix, disponibilité et boutiques d'origine.") elif region: h1 = f"Produits québécois — {region}" title = f"Produits de la région {region} — {fmt_int(total)} produits | {SITE}" desc = (f"{fmt_int(total)} produits offerts par les boutiques en ligne de la région " f"{region}, réunis sur Fabri-Ka.") else: h1 = "Produits" title = f"Tous les produits québécois — {fmt_int(total)} produits | {SITE}" desc = (f"Parcourez {fmt_int(total)} produits de boutiques en ligne québécoises : " f"alimentation, mode, maison, art et plus. Filtrez par catégorie, région et prix.") st = listing_stats(category, region) combos = combo_counts() nav_sections: list[str] = [] if category and region: sib_r = [(f"/categorie/{CAT_SLUG[category]}/region/{REGION_SLUG[c['region']]}", c["region"], f"{fmt_int(c['n'])} produits") for c in combos if c["cat"] == category and c["region"] != region and c["region"] in REGION_SLUG and c["n"] > 0] sib_c = [(f"/categorie/{CAT_SLUG[c['cat']]}/region/{REGION_SLUG[region]}", cat_label(c["cat"]), f"{fmt_int(c['n'])} produits") for c in combos if c["region"] == region and c["cat"] != category and c["cat"] in CAT_SLUG and c["n"] > 0] if sib_r: nav_sections.append(f"

    {esc(label)} dans les autres régions

    " + link_list(sorted(sib_r, key=lambda x: x[1]))) if sib_c: nav_sections.append(f"

    Autres catégories — {esc(region)}

    " + link_list(sib_c[:20])) elif category: by_region: dict[str, int] = {} for c in combos: if c["cat"] == category and c["region"] in REGION_SLUG: by_region[c["region"]] = by_region.get(c["region"], 0) + c["n"] links = [(f"/categorie/{CAT_SLUG[category]}/region/{REGION_SLUG[r]}", r, f"{fmt_int(n)} produits") for r, n in sorted(by_region.items(), key=lambda x: -x[1]) if n > 0] if links: nav_sections.append("

    Par région

    " + link_list(links)) others = [(f"/categorie/{s}", cat_label(k), "") for k, s in CAT_SLUG.items() if k != category] nav_sections.append("

    Autres catégories

    " + link_list(others)) elif region: by_cat: dict[str, int] = {} for c in combos: if c["region"] == region and c["cat"] in CAT_SLUG: by_cat[c["cat"]] = by_cat.get(c["cat"], 0) + c["n"] links = [(f"/categorie/{CAT_SLUG[k]}/region/{REGION_SLUG[region]}", cat_label(k), f"{fmt_int(n)} produits") for k, n in sorted(by_cat.items(), key=lambda x: -x[1]) if n > 0] if links: nav_sections.append(f"

    Catégories — {esc(region)}

    " + link_list(links)) others = [(f"/region/{s}", r, "") for r, s in REGION_SLUG.items() if r != region] nav_sections.append("

    Autres régions

    " + link_list(others)) else: nav_sections.append("

    Par catégorie

    " + link_list([(f"/categorie/{s}", cat_label(k), "") for k, s in CAT_SLUG.items()])) nav_sections.append("

    Par région

    " + link_list([(f"/region/{s}", r, "") for r, s in REGION_SLUG.items()])) crumbs = [("Accueil", "/"), ("Produits", "/produits")] if category: crumbs.append((label, f"/categorie/{CAT_SLUG[category]}")) if region: crumbs.append((region, path)) body = f"""

    {esc(h1)}

    {stats_line(total, st)} {product_grid(rows)} {pagination_html(path, page, total)} {"".join(nav_sections)}
    """ canonical = path if page == 1 else f"{path}?page={page}" jsonld = [breadcrumb_ld(crumbs), {"@context": "https://schema.org", "@type": "CollectionPage", "name": h1, "url": BASE + canonical, "inLanguage": "fr-CA", "mainEntity": {"@type": "ItemList", "numberOfItems": total, "itemListElement": [ {"@type": "ListItem", "position": i + 1, "url": BASE + product_path(r["uid"], r["title"] or "")} for i, r in enumerate(rows)]}}] if page > 1: title = f"{h1} — page {page} | {SITE}" return render(title=title, description=desc, canonical=canonical, body=body, jsonld=jsonld) def page_product(slug: str | None, uid: str, params) -> Response: con = db.connect() try: rows = q(con, """SELECT p.*, s.name AS store_name, s.region AS store_region, s.city AS store_city, s.origin_class, s.url AS store_url FROM products p JOIN stores s ON s.id=p.store_id WHERE p.uid=?""", (uid,)) if not rows: return page_404() p = rows[0] canonical = product_path(uid, p["title"] or "") if not p["active"]: # 410 Gone — produit retiré : jamais de soft-404 cat = p.get("category") back = f"/categorie/{CAT_SLUG[cat]}" if cat in CAT_SLUG else "/produits" back_label = cat_label(cat) if cat in CAT_SLUG else "tous les produits" body = f"""

    Ce produit n'est plus offert

    « {esc(p["title"])} » a été retiré du catalogue de {esc(p["store_name"])}.

    Voir les produits similaires : {esc(back_label)} · Tous les produits

    """ return render(title=f"Produit retiré — {SITE}", description="Ce produit n'est plus offert. Découvrez des produits québécois similaires.", body=body, robots="noindex", status=410) # URL canonique : /produits/- — 301 depuis toute variante expected_slug = slugify(p["title"] or "")[:70].rstrip("-") or None if slug != expected_slug: return RedirectResponse(canonical, status_code=301) related = q(con, """SELECT p.uid, p.title, p.price, p.images, s.name AS store_name, s.region AS store_region FROM products p JOIN stores s ON s.id=p.store_id WHERE p.store_id=? AND p.uid<>? AND p.active=1 AND p.listing_status='published' ORDER BY p.first_seen DESC LIMIT 8""", (p["store_id"], uid)) finally: con.close() images = json.loads(p.get("images") or "[]") title_txt = p["title"] or "Produit" price_txt = fmt_price(p.get("price")) store_loc = ", ".join(x for x in [p.get("store_city"), p.get("store_region")] if x) or "Québec" desc_meta = clean_text(p.get("description") or "", 150) meta_desc = " — ".join(x for x in [ title_txt if not desc_meta else desc_meta, f"{price_txt}" if price_txt else "", f"offert par {p['store_name']} ({store_loc})", ] if x) meta_desc = clean_text(meta_desc, 160) cat = p.get("category") crumbs = [("Accueil", "/"), ("Produits", "/produits")] if cat in CAT_SLUG: crumbs.append((cat_label(cat), f"/categorie/{CAT_SLUG[cat]}")) crumbs.append((title_txt, canonical)) gallery = "".join( f'
    {_img(src, title_txt if i == 0 else "", eager=(i == 0))}
    ' for i, src in enumerate(images[:6])) meta_rows = "".join( f"
    {esc(k)}
    {esc(v)}
    " for k, v in [("Marque", p.get("vendor")), ("Catégorie", cat_label(cat) if cat else None), ("Type", p.get("product_type")), ("Origine", ORIGIN_LABELS.get(p.get("origin_class") or "")), ("Région", p.get("store_region"))] if v) avail_txt = ("En stock" if p.get("available") == 1 else "Non disponible" if p.get("available") == 0 else "") body = f"""

    Produits similaires

    {product_grid(related)}
    """ availability = {1: "https://schema.org/InStock", 0: "https://schema.org/OutOfStock"}.get( p.get("available")) ld_product: dict = { "@context": "https://schema.org", "@type": "Product", "name": title_txt, "sku": uid, "url": BASE + canonical, "description": clean_text(p.get("description") or "", 500) or title_txt, } if images: ld_product["image"] = images[:6] if p.get("vendor"): ld_product["brand"] = {"@type": "Brand", "name": p["vendor"]} if p.get("price") is not None: offer = {"@type": "Offer", "url": BASE + canonical, "price": f"{p['price']:.2f}", "priceCurrency": p.get("currency") or "CAD", "seller": {"@type": "Organization", "name": p["store_name"]}, "areaServed": {"@type": "AdministrativeArea", "name": "Québec, Canada"}} if availability: offer["availability"] = availability ld_product["offers"] = offer og = {"og:type": "product"} if images: og["og:image"] = images[0] return render(title=f"{clean_text(title_txt, 70)} — {p['store_name']} | {SITE}", description=meta_desc, canonical=canonical, body=body, jsonld=[ld_product, breadcrumb_ld(crumbs)], og=og) def page_stores_index() -> HTMLResponse: def _data(): con = db.connect() try: return q(con, """SELECT id, name, region, city, product_count AS n FROM stores WHERE product_count>0 ORDER BY region, product_count DESC""") finally: con.close() stores = cached("stores_index", 600, _data) total = len(stores) by_region: dict[str, list[dict]] = {} for s in stores: by_region.setdefault(s["region"] or "Ailleurs au Québec", []).append(s) sections = "".join( f"

    {esc(region)} ({fmt_int(len(items))} boutiques)

    " + link_list([(f"/boutiques/{s['id']}", s["name"], f"{fmt_int(s['n'])} produits") for s in items]) for region, items in sorted(by_region.items(), key=lambda kv: -len(kv[1]))) desc = (f"Annuaire de {fmt_int(total)} boutiques en ligne québécoises agrégées par " f"Fabri-Ka, classées par région : alimentation, mode, maison, art et plus.") body = f"""

    Boutiques québécoises en ligne

    {fmt_int(total)} boutiques avec produits, classées par région.

    {sections}
    """ return render(title=f"Boutiques québécoises — annuaire de {fmt_int(total)} boutiques en ligne | {SITE}", description=desc, canonical="/boutiques", body=body, jsonld=[breadcrumb_ld([("Accueil", "/"), ("Boutiques", "/boutiques")])]) def page_store(store_id: str) -> Response: con = db.connect() try: rows = q(con, "SELECT * FROM stores WHERE id=?", (store_id,)) if not rows: return page_404() s = rows[0] stats = q(con, """SELECT COUNT(*) AS n, MIN(price) AS pmin, MAX(price) AS pmax, ROUND(AVG(price),2) AS pavg FROM products WHERE store_id=? AND active=1 AND listing_status='published' AND price>0""", (store_id,))[0] cats = q(con, """SELECT category AS key, COUNT(*) AS n FROM products WHERE store_id=? AND active=1 AND listing_status='published' GROUP BY category ORDER BY n DESC LIMIT 8""", (store_id,)) prods = q(con, """SELECT p.uid, p.title, p.price, p.images, s.name AS store_name, s.region AS store_region FROM products p JOIN stores s ON s.id=p.store_id WHERE p.store_id=? AND p.active=1 AND p.listing_status='published' ORDER BY p.first_seen DESC LIMIT 24""", (store_id,)) finally: con.close() loc = ", ".join(x for x in [s.get("city"), s.get("region")] if x) or "Québec" n = s.get("product_count") or 0 origin = ORIGIN_LABELS.get(s.get("origin_class") or "", "") desc = clean_text(s.get("description_meta") or "", 100) meta_desc = clean_text( f"{s['name']} ({loc}) — {fmt_int(n)} produits sur Fabri-Ka. " + (desc + " " if desc else "") + (f"{origin}. " if origin else "") + "Prix, catalogue et boutiques similaires.", 160) cat_links = link_list([(f"/categorie/{CAT_SLUG[c['key']]}", cat_label(c["key"]), f"{fmt_int(c['n'])} produits") for c in cats if c["key"] in CAT_SLUG]) region_link = (f' · Produits de la région ' f'{esc(s["region"])}') if s.get("region") in REGION_SLUG else "" body = f"""

    {esc(s["name"])}

    {fmt_int(n)} produits · {esc(loc)}{" · " + esc(origin) if origin else ""} {f" · prix moyen {fmt_price(stats['pavg'])}" if stats.get("pavg") else ""}

    Site officiel ↗{region_link}

    Catégories

    {cat_links}

    Produits récents

    {product_grid(prods)}
    """ ld: dict = {"@context": "https://schema.org", "@type": "OnlineStore", "name": s["name"], "url": s.get("url"), "address": {"@type": "PostalAddress", "addressRegion": "QC", "addressCountry": "CA"}} if s.get("city"): ld["address"]["addressLocality"] = s["city"] if s.get("logo_url"): ld["logo"] = s["logo_url"] crumbs = [("Accueil", "/"), ("Boutiques", "/boutiques"), (s["name"], f"/boutiques/{store_id}")] return render(title=f"{s['name']} — {fmt_int(n)} produits québécois | {SITE}", description=meta_desc, canonical=f"/boutiques/{store_id}", body=body, jsonld=[ld, breadcrumb_ld(crumbs)], og={"og:image": s.get("cover_url") or s.get("logo_url") or ""}) def page_stats() -> HTMLResponse: def _data(): con = db.connect() try: return q(con, """SELECT (SELECT COUNT(*) FROM products WHERE active=1 AND listing_status='published') AS products, (SELECT COUNT(*) FROM stores WHERE product_count>0) AS stores """)[0] finally: con.close() t = cached("stats_head", 600, _data) desc = (f"Statistiques du marché des produits québécois en ligne : " f"{fmt_int(t['products'])} produits, {fmt_int(t['stores'])} boutiques — " f"prix par catégorie, répartition régionale, croissance du catalogue.") body = f"""

    Statistiques — produits québécois en ligne

    {esc(desc)}

    Télécharger le rapport de marché (PDF) · Parcourir les produits

    """ return render(title=f"Statistiques du marché québécois en ligne | {SITE}", description=desc, canonical="/stats", body=body) def page_about() -> HTMLResponse: desc = ("Fabri-Ka est un agrégateur indépendant qui réunit les catalogues publics des " "boutiques en ligne québécoises : produits fabriqués, conçus ou vendus au Québec.") body = f"""

    À propos de Fabri-Ka

    {esc(desc)}

    Les prix et disponibilités appartiennent aux boutiques sources. Chaque fiche renvoie vers la boutique d'origine pour l'achat. Contact : contact@spboucher.ai

    Produits · Boutiques · Statistiques

    """ return render(title=f"À propos | {SITE}", description=desc, canonical="/a-propos", body=body) def page_inscrire() -> HTMLResponse: desc = ("Artisans et fabricants québécois : inscrivez votre atelier sur Fabri-Ka. " "Chaque fiche est validée manuellement avant publication.") body = f"""

    Inscrire mon atelier

    {esc(desc)}

    Remplissez le formulaire (nom, métier, description, coordonnées, site web) — votre demande part en file de validation ; rien n'est publié sans vérification de la fabrication québécoise et des coordonnées.

    Produits · Boutiques

    """ return render(title=f"Inscrire mon atelier | {SITE}", description=desc, canonical="/inscrire", body=body) def _ecosystem() -> dict: """ka-ui/ecosystem.json vendoré côté frontend — source unique des coordonnées et des sites du Groupe KA (partagée avec la page React).""" p = Path(__file__).resolve().parent.parent / "frontend" / "src" / "ka" / "ecosystem.json" try: return json.loads(p.read_text(encoding="utf-8")) except OSError: return {"org": {}, "hub": {"url": "https://www.groupe-ka.com"}, "contacts": [], "sites": []} def page_contact() -> HTMLResponse: eco = _ecosystem() hub = (eco.get("hub") or {}).get("url", "https://www.groupe-ka.com") contacts = "".join( f'
  • {esc(c["email"])}' f' — {esc(c["role"])}
  • ' for c in eco.get("contacts", [])) sites = "".join( f'
  • {esc(st["wordmark"])}' f' — {esc(st["tagline"])}
  • ' for st in eco.get("sites", [])) disclaimer = (eco.get("org") or {}).get("disclaimer", "") desc = ("Joindre le Groupe KA — Fabri-Ka est un service Groupe KA : " "projets et partenariats, médias, légal et vie privée (Loi 25).") body = f"""

    Contact

    Fabri-Ka est un service du Groupe KA. Les coordonnées ci-dessous sont communes à toutes les plateformes de l'écosystème.

      {contacts}

    {esc(disclaimer)}

    Les sites de l'écosystème

      {sites}
    """ return render(title=f"Contact | {SITE} — Un service Groupe KA", description=desc, canonical="/contact", body=body) def page_404() -> HTMLResponse: body = """

    Page introuvable

    Cette page n'existe pas ou n'existe plus.

    Accueil · Tous les produits · Boutiques

    """ return render(title=f"Page introuvable — {SITE}", description="Cette page n'existe pas. Découvrez les produits québécois sur Fabri-Ka.", body=body, robots="noindex", status=404) # ---- robots + sitemaps ------------------------------------------------------- def robots_txt() -> PlainTextResponse: return PlainTextResponse( "User-agent: *\n" "Allow: /\n" "Disallow: /api/\n" "Disallow: /profil\n" "\n" f"Sitemap: {BASE}/sitemap.xml\n") def _urlset(urls: list[tuple[str, str | None]]) -> str: ents = "".join( "" + esc(BASE + path) + "" + (f"{lastmod}" if lastmod else "") + "" for path, lastmod in urls) return ('' '' + ents + "") def _product_chunks() -> int: def _run(): con = db.connect() try: n = con.execute("SELECT COUNT(*) FROM products WHERE active=1 AND listing_status='published'").fetchone()[0] finally: con.close() return max(1, -(-n // SITEMAP_CHUNK)) return cached("chunks", 3600, _run) def sitemap_index() -> Response: def _run(): today = date.today().isoformat() names = ["pages", "boutiques"] + [f"produits-{i}" for i in range(1, _product_chunks() + 1)] ents = "".join(f"{esc(BASE)}/sitemap-{n}.xml" f"{today}" for n in names) return ('' '' + ents + "") return Response(cached("sm:index", 3600, _run), media_type="application/xml") def sitemap(name: str) -> Response: def _pages(): combos = combo_counts() today = date.today().isoformat() urls: list[tuple[str, str | None]] = [ ("/", today), ("/produits", today), ("/boutiques", today), ("/stats", today), ("/a-propos", None), ("/contact", None), ("/inscrire", None)] cat_last: dict[str, float] = {} reg_last: dict[str, float] = {} for c in combos: if c["cat"] in CAT_SLUG: cat_last[c["cat"]] = max(cat_last.get(c["cat"], 0), c["m"] or 0) if c["region"] in REGION_SLUG: reg_last[c["region"]] = max(reg_last.get(c["region"], 0), c["m"] or 0) urls += [(f"/categorie/{CAT_SLUG[k]}", iso_date(m)) for k, m in cat_last.items()] urls += [(f"/region/{REGION_SLUG[r]}", iso_date(m)) for r, m in reg_last.items()] urls += [(f"/categorie/{CAT_SLUG[c['cat']]}/region/{REGION_SLUG[c['region']]}", iso_date(c["m"])) for c in combos if c["cat"] in CAT_SLUG and c["region"] in REGION_SLUG and c["n"] > 0] return _urlset(urls) def _stores(): con = db.connect() try: rows = q(con, """SELECT id, last_sync FROM stores WHERE product_count>0 ORDER BY id""") finally: con.close() return _urlset([(f"/boutiques/{r['id']}", iso_date(r["last_sync"]) if r["last_sync"] else None) for r in rows]) def _products(idx: int): def _run(): con = db.connect() try: rows = q(con, """SELECT uid, title, last_seen FROM products WHERE active=1 AND listing_status='published' ORDER BY uid LIMIT ? OFFSET ?""", (SITEMAP_CHUNK, (idx - 1) * SITEMAP_CHUNK)) finally: con.close() return _urlset([(product_path(r["uid"], r["title"] or ""), iso_date(r["last_seen"])) for r in rows]) return _run if name == "pages": xml = cached("sm:pages", 3600, _pages) elif name == "boutiques": xml = cached("sm:boutiques", 3600, _stores) else: m = re.fullmatch(r"produits-(\d+)", name) if not m or not (1 <= int(m.group(1)) <= _product_chunks()): return PlainTextResponse("Not found", status_code=404) idx = int(m.group(1)) xml = cached(f"sm:produits:{idx}", 3600, _products(idx)) return Response(xml, media_type="application/xml") # ---- dispatch ---------------------------------------------------------------- # uid = sha1[:20] (schema.Product.uid) — toujours 20 hex, le slug est optionnel _PROD_RE = re.compile(r"^produits/(?:(?P.+)-)?(?P[0-9a-f]{20})$") def render_route(path: str, params) -> Response: path = path.strip("/") if path == "": return page_home() if path == "produits": return page_listing(None, None, params) m = _PROD_RE.match(path) if m: return page_product(m.group("slug"), m.group("uid"), params) if path.startswith("produits/"): return page_404() if path == "boutiques": return page_stores_index() if path.startswith("boutiques/"): return page_store(path.split("/", 1)[1]) if path == "stats": return page_stats() if path == "a-propos": return page_about() if path == "contact": return page_contact() if path == "inscrire": return page_inscrire() if path == "profil": return render(title=f"Mon profil — {SITE}", description="Profil du membre Groupe KA.", body='

    Mon profil

    ', robots="noindex") parts = path.split("/") if parts[0] == "categorie" and len(parts) >= 2: cat = SLUG_CAT.get(parts[1]) if not cat: return page_404() if len(parts) == 2: return page_listing(cat, None, params) if len(parts) == 4 and parts[2] == "region": region = SLUG_REGION.get(parts[3]) if region: return page_listing(cat, region, params) return page_404() if parts[0] == "region" and len(parts) == 2: region = SLUG_REGION.get(parts[1]) if region: return page_listing(None, region, params) return page_404() return page_404()