# -----------------------------------------------------------------------------
# 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("", "<\\/")
extra.append(f'')
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'')
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'
'
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"""
{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("
" +
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"""
"""
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"])}.
"""
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"""
{esc(title_txt)}
{esc(price_txt or "Prix affiché en boutique")}{(" · " + esc(avail_txt)) if avail_txt else ""}
{gallery}
{esc(clean_text(p.get("description") or "", 1200))}
"""
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 ""}
"""
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
"""
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.
'
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 = """
"""
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()