SPB Git forge

spb/food-ka

Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

55commits 1branches 0releases
10.2 MBsize
maindefault branch
9 days agolast push
Python 53.9% TypeScript 24% CSS 14.9% JavaScript 5.8% HTML 1.4%
26.1 KB · 566 lines python
Raw Blame History
1#!/usr/bin/env python32# -----------------------------------------------------------------------------3# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)4# Auteur : Simon-Pierre Boucher — contact@spboucher.ai5# scripts/gen_connector_docs.py : documentation standardisée des connecteurs6#7#   Génère docs/connecteurs/ (INDEX.md + une fiche par FAMILLE de connecteurs +8#   fiches transverses) en croisant :9#     1. data/sources.json          — registre des sources (61 bannières) ;10#     2. le code des connecteurs    — familles par héritage de classe11#        (FlippConnector, ShopifyConnector, WooCommerceConnector…), en-têtes12#        « mécanique » des modules, merchant_id/flyer_name_filter des13#        circulaires, backends, plafonds env (FOODKA_*) ;14#     3. la BD live data/foodka.db  — volumétrie, complétude par champ,15#        dernier sync et erreurs (sync_log), matching inter-bannières16#        (product_links), nutrition OFF (off_cache), historique de prix.17#18#   Rejouable à volonté (BD ouverte en lecture seule, docs régénérés) :19#       .venv/bin/python3 scripts/gen_connector_docs.py20# -----------------------------------------------------------------------------21from __future__ import annotations2223import datetime24import json25import re26import sqlite327import sys28from collections import Counter, OrderedDict29from pathlib import Path3031ROOT = Path(__file__).resolve().parent.parent32sys.path.insert(0, str(ROOT))3334DB_PATH = ROOT / "data" / "foodka.db"35SOURCES_PATH = ROOT / "data" / "sources.json"36CONNECTORS_DIR = ROOT / "foodka" / "connectors"37OUT_DIR = ROOT / "docs" / "connecteurs"3839# Champs de complétude (produits actifs) : libellé -> expression SQL "rempli"40FIELDS = OrderedDict([41    ("Prix", "price IS NOT NULL"),42    ("Prix régulier", "regular_price IS NOT NULL"),43    ("Format (size_label)", "size_label IS NOT NULL AND size_label != ''"),44    ("Prix unitaire", "unit_price IS NOT NULL"),45    ("Marque", "brand IS NOT NULL AND brand != ''"),46    ("Images", "images IS NOT NULL AND images NOT IN ('', '[]')"),47    ("Nutrition OFF (details.off)", "details LIKE '%\"off\"%'"),48])4950# --- Définition des familles : (slug, titre, module socle éventuel, gotchas) --51FAMILIES = OrderedDict([52    ("flipp-proximite", {53        "title": "Circulaires Flipp — épiceries de proximité (catalogue = circulaire)",54        "base": "_flipp.py",55        "gotchas": [56            "Le paramètre d'URL de l'API backflipp ne filtre PAS par marchand : "57            "filtrer côté client sur `merchant_id`.",58            "`flyer_name_filter` écarte les cahiers parasites du même merchant "59            "(ex. Supermarché PA : « Weekly Flyer » vs « Nature Flyer »).",60            "Tri des circulaires candidates par `valid_from` croissant : en cas "61            "de chevauchement (semaine courante + semaine à venir), on prend la "62            "courante.",63            "Les items Flipp n'ont ni SKU, ni catégorie, ni unité : "64            "`external_id` = slug stable marque+nom+format (continuité du "65            "price_log d'une semaine à l'autre), catégorie déduite du nom.",66            "Le `flyer_id` change chaque semaine — toujours résolu dynamiquement "67            "via /flipp/flyers (une circulaire provinciale par bannière : un "68            "poste montréalais suffit).",69        ]}),70    ("flyers-grandes-bannieres", {71        "title": "Circulaires Flipp — grandes bannières (complément rabais du catalogue)",72        "base": "_flipp.py",73        "gotchas": [74            "Chaque source `*_flyer` complète la source catalogue de la même "75            "bannière (metro / metro_flyer) : le matching inter-bannières les "76            "traite comme UNE bannière.",77            "`flyer_name_filter` est indispensable ici : les grands merchants "78            "publient plusieurs cahiers (Metro « Metrogo! », Costco cahiers non "79            "alimentaires, Walmart livrets thématiques…).",80            "Tri `valid_from` croissant pour choisir la circulaire de la semaine "81            "courante ; flyer_id résolu à chaque sync.",82        ]}),83    ("scrapfly-asp", {84        "title": "Catalogues derrière anti-bot — Scrapfly (ASP)",85        "base": None,86        "gotchas": [87            "Chaque requête passe par Scrapfly en mode ASP (anti-scraping "88            "protection) : coût par appel — les plafonds de pagination par "89            "allée/catégorie bornent le budget de chaque sync.",90            "Metro et Super C partagent le socle `_metro.py` (tuiles rendues "91            "serveur, pagination « <allée>-page-N ») ; Maxi, Provigo et Club "92            "Entrepôt partagent `_loblaw.py` (grille complète dans "93            "`__NEXT_DATA__`).",94        ]}),95    ("render-js", {96        "title": "Catalogues SPA — Scrapfly avec rendu JavaScript",97        "base": None,98        "gotchas": [99            "Le rendu JavaScript Scrapfly est encore plus coûteux que l'ASP "100            "simple : nombre de pages par catégorie plafonné, syncs espacés.",101        ]}),102    ("shopify", {103        "title": "Boutiques Shopify — /products.json ouvert",104        "base": "_shopify.py",105        "gotchas": [106            "Catalogue public JSON sans anti-bot : "107            "/products.json?limit=250&page=N (pagination plafonnée par "108            "garde-fou).",109            "Le format vient des variantes ; prix unitaire recalculé "110            "(normalize.unit_price) quand la taille est parsable.",111        ]}),112    ("woocommerce", {113        "title": "Épiceries WooCommerce — Store API ouverte",114        "base": "_woocommerce.py",115        "gotchas": [116            "Prix en unités mineures (cents) : « 1299 » + "117            "currency_minor_unit=2 -> 12,99 $.",118            "/wp-json/wc/store/v1/products?per_page=100&page=N, pagination "119            "plafonnée par garde-fou.",120        ]}),121    ("html", {122        "title": "Catalogues HTML rendus serveur ou API directe — requests",123        "base": None,124        "gotchas": [125            "Sites sans anti-bot : parsing HTML/sitemap direct (HubSpot, "126            "Magento 2, k-eCommerce, SSR maison).",127            "SAQ : API GraphQL Adobe Live Search "128            "(catalog-service.adobe.io) appelée en direct avec la clé "129            "publique du storefront — tout est dans la réponse liste, "130            "aucune page détail ; catégorie unique « Alcool », exclue du "131            "matching (matching.EXCLUDED_SOURCES).",132        ]}),133    ("iga-api", {134        "title": "IGA — API Voilà (Sobeys Québec)",135        "base": None,136        "gotchas": [137            "Voie principale : API REST publique des promotions de voila.ca "138            "(JSON complet, curseur, sans anti-bot) ; complément optionnel par "139            "recherche SPA rendue via Scrapfly (render_js).",140            "`regionId` public requis par l'API (région de livraison Québec).",141        ]}),142    ("non-connectables", {143        "title": "Sources recensées non connectables",144        "base": None,145        "gotchas": []}),146])147148HISTORIQUE = """\149## Historique des vagues (2026-08-18)150151| Commit | Contenu |152|---|---|153| `afdb2e6` | Enrichissement connecteurs + robustesse DB (audit 2026-08-18) |154| `a45aeb4` | Vague 2 : circulaires grandes bannières + comparateur inter-bannières + nutrition Open Food Facts |155| `114aa76` | Matching : MAX_BLOCK 400 → 3000 (marques maison des circulaires ; fenêtre triée de 30 = coût linéaire) |156| `2e52f7c` | Vague 3 : SAQ (API GraphQL directe, « Alcool », hors matching) + 6 circulaires Flipp (Pharmaprix, Jean Coutu, Brunet, Uniprix — rayons alimentaires seulement ; Adonis, Avril) + Metro 4 pages sur 3 allées prioritaires |157"""158159160# --------------------------------------------------------------------------- #161#  Introspection du code162# --------------------------------------------------------------------------- #163164def get_registry():165    import foodka.connectors as reg166    return reg.CONNECTORS167168169def classify(source: dict, registry) -> str:170    if source.get("status") == "non connectable":171        return "non-connectables"172    sid = source["id"]173    if sid == "iga":174        return "iga-api"175    cls = registry.get(sid)176    bases = {b.__name__ for b in cls.__mro__} if cls else set()177    if "FlippConnector" in bases:178        return "flyers-grandes-bannieres" if sid.endswith("_flyer") else "flipp-proximite"179    if "ShopifyConnector" in bases:180        return "shopify"181    if "WooStoreConnector" in bases:182        return "woocommerce"183    tech = source.get("tech", "")184    if "Flipp" in tech:            # connecteur Flipp sur mesure hors socle185        return "flyers-grandes-bannieres" if sid.endswith("_flyer") else "flipp-proximite"186    if "WooCommerce" in tech:      # ex. akhavan : Store API, impl. sur mesure187        return "woocommerce"188    if "rendu JavaScript" in tech:189        return "render-js"190    if "Scrapfly" in tech:191        return "scrapfly-asp"192    return "html"193194195def module_header(path: Path, marker: str | None = None) -> str:196    """Bloc de commentaires du haut du fichier, rendu en citation Markdown.197    Si `marker` est donné, on démarre à la ligne qui commence par ce texte."""198    lines, started = [], marker is None199    for raw in path.read_text(encoding="utf-8").splitlines():200        if not raw.startswith("#") or raw.startswith("#!"):201            if raw.startswith("#!"):202                continue203            break204        body = raw.lstrip("#").strip()205        if not started:206            if marker and body.startswith(marker):207                started = True208                lines.append(body)209            continue210        if set(body) <= {"-"} and len(body) > 10:211            break212        lines.append(body)213    if not lines:214        return "_(pas d'en-tête trouvé)_"215    return "\n".join("> " + l for l in lines)216217218def source_module_header(sid: str, registry) -> str:219    cls = registry.get(sid)220    if not cls:221        return "_(pas de connecteur)_"222    mod = cls.__module__.rsplit(".", 1)[-1]223    path = CONNECTORS_DIR / f"{mod}.py"224    # démarrer après les 2 lignes standard (titre projet + auteur)225    text = path.read_text(encoding="utf-8").splitlines()226    lines, seen_author = [], False227    for raw in text:228        if not raw.startswith("#"):229            break230        body = raw.lstrip("#").strip()231        if set(body) <= {"-"} and len(body) > 10:232            if seen_author and lines:233                break234            continue235        if body.startswith("Auteur :"):236            seen_author = True237            continue238        if body.startswith("Food-Ka —"):239            continue240        if seen_author:241            lines.append(body)242    return "\n".join("> " + l for l in lines) if lines else "_(pas d'en-tête)_"243244245def detect_caps(sid: str, registry) -> str:246    cls = registry.get(sid)247    if not cls:248        return "—"249    mod = cls.__module__.rsplit(".", 1)[-1]250    src = (CONNECTORS_DIR / f"{mod}.py").read_text(encoding="utf-8")251    caps = sorted(set(re.findall(r"FOODKA_[A-Z_]+", src)))252    hard = sorted(set(re.findall(r"(?:MAX|LIMIT)_[A-Z_]+\s*=\s*\d+",253                                 src)))[:3]254    out = caps + [h.replace(" ", "") for h in hard]255    return ", ".join(f"`{c}`" for c in out) if out else "—"256257258# --------------------------------------------------------------------------- #259#  BD live260# --------------------------------------------------------------------------- #261262def q1(db, sql, args=()):263    return db.execute(sql, args).fetchone()264265266def completeness(db, source_ids: list[str]) -> tuple[int, OrderedDict]:267    if not source_ids:268        return 0, OrderedDict((l, 0.0) for l in FIELDS)269    ph = ",".join("?" * len(source_ids))270    parts = ", ".join(f"SUM(CASE WHEN {expr} THEN 1 ELSE 0 END)"271                      for expr in FIELDS.values())272    row = q1(db, f"SELECT COUNT(*), {parts} FROM products "273                 f"WHERE active = 1 AND source IN ({ph})", source_ids)274    n = row[0] or 0275    out = OrderedDict()276    for (label, _), filled in zip(FIELDS.items(), row[1:]):277        out[label] = (100.0 * (filled or 0) / n) if n else 0.0278    return n, out279280281def source_stats(db, sid: str) -> dict:282    tot, act, sale = q1(db, "SELECT COUNT(*), SUM(active), "283                            "SUM(active = 1 AND on_sale = 1) FROM products "284                            "WHERE source = ?", (sid,))285    last = q1(db, "SELECT ts, ok, found, message FROM sync_log "286                  "WHERE source = ? ORDER BY ts DESC LIMIT 1", (sid,))287    errs = q1(db, "SELECT COUNT(*) FROM (SELECT ok FROM sync_log "288                  "WHERE source = ? ORDER BY ts DESC LIMIT 10) WHERE ok = 0",289              (sid,))[0]290    return {"total": tot or 0, "active": act or 0, "sale": sale or 0,291            "last": last, "errs10": errs}292293294def fmt_ts(ts) -> str:295    if not ts:296        return "—"297    return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")298299300def recent_errors(db, source_ids: list[str], limit=8) -> list[tuple]:301    if not source_ids:302        return []303    ph = ",".join("?" * len(source_ids))304    return db.execute(305        f"SELECT source, ts, message FROM sync_log "306        f"WHERE ok = 0 AND source IN ({ph}) ORDER BY ts DESC LIMIT ?",307        (*source_ids, limit)).fetchall()308309310# --------------------------------------------------------------------------- #311#  Rendu des fiches312# --------------------------------------------------------------------------- #313314def nfmt(n: int) -> str:315    return f"{n:,}".replace(",", " ")316317318def render_family_fiche(db, slug: str, members: list[dict], registry) -> str:319    meta = FAMILIES[slug]320    sids = [s["id"] for s in members]321    n, comp = completeness(db, sids)322323    lines = [f"# Famille `{slug}` — {meta['title']}", "",324             "_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._",325             "", "## Vue d'ensemble", "",326             f"- **Sources membres** : {len(members)}"]327    if slug != "non-connectables":328        act = q1(db, "SELECT COALESCE(SUM(active), 0) FROM products WHERE source IN "329                     f"({','.join('?' * len(sids))})", sids)[0]330        lines.append(f"- **Produits actifs (BD)** : {nfmt(act)}")331    if meta["base"]:332        lines.append(f"- **Socle commun** : `foodka/connectors/{meta['base']}`")333334    if meta["base"]:335        lines += ["", f"## Mécanique du socle (`{meta['base']}`)", "",336                  module_header(CONNECTORS_DIR / meta["base"],337                                f"connectors/{meta['base']}"), ""]338339    # ---- tableau des membres340    if slug == "non-connectables":341        lines += ["", "## Sources", "",342                  "| Source | Nom | Tech | Pourquoi non connectable |",343                  "|---|---|---|---|"]344        for s in sorted(members, key=lambda x: x["id"]):345            lines.append(f"| `{s['id']}` | {s['name']} | {s.get('tech', '')} | "346                         f"{(s.get('notes') or '').replace('|', '—')} |")347        lines.append("")348        return "\n".join(lines)349350    if slug in ("flipp-proximite", "flyers-grandes-bannieres"):351        header = ("| Source | Nom | merchant_id | flyer_name_filter | Région | "352                  "Produits actifs | En rabais | Dernier sync | Statut |")353        sep = "|---|---|---|---|---|---|---|---|---|"354    else:355        header = ("| Source | Nom | Tech | Région | Produits actifs | "356                  "En rabais | Dernier sync | Statut |")357        sep = "|---|---|---|---|---|---|---|---|"358    lines += ["## Sources membres (BD live)", "", header, sep]359360    for s in sorted(members, key=lambda x: x["id"]):361        st = source_stats(db, s["id"])362        last = st["last"]363        when = fmt_ts(last[0]) if last else "jamais"364        status = ("OK" if last and last[1] else "ERREUR") + \365                 (f" ({last[2] or 0} trouvés)" if last else "")366        if slug in ("flipp-proximite", "flyers-grandes-bannieres"):367            cls = registry.get(s["id"])368            mid = getattr(cls, "merchant_id", "—") if cls else "—"369            filt = getattr(cls, "flyer_name_filter", "") if cls else ""370            lines.append(f"| `{s['id']}` | {s['name']} | {mid} | "371                         f"{('`' + filt + '`') if filt else '—'} | "372                         f"{s.get('region', '—')} | {st['active']} | "373                         f"{st['sale']} | {when} | {status} |")374        else:375            lines.append(f"| `{s['id']}` | {s['name']} | "376                         f"{(s.get('tech') or '—').replace('|', '—')} | "377                         f"{s.get('region', '—')} | {st['active']} | "378                         f"{st['sale']} | {when} | {status} |")379    lines.append("")380381    # ---- complétude382    lines += [f"## Complétude des champs (produits actifs, N = {nfmt(n)})", "",383              "| Champ | % rempli |", "|---|---|"]384    lines += [f"| {label} | {pct:.1f} % |" for label, pct in comp.items()]385    lines.append("")386387    # ---- gotchas388    if meta["gotchas"]:389        lines += ["## Gotchas", ""]390        lines += [f"- {g}" for g in meta["gotchas"]]391        lines.append("")392393    # ---- mécanique par source (familles sans socle unique)394    if not meta["base"] and slug != "iga-api":395        lines += ["## Mécanique par source (en-têtes des modules)", ""]396        for s in sorted(members, key=lambda x: x["id"]):397            lines += [f"### `{s['id']}` — {s['name']}", "",398                      f"Plafonds/env : {detect_caps(s['id'], registry)}", "",399                      source_module_header(s["id"], registry), ""]400    elif slug == "iga-api":401        lines += ["## Mécanique (`foodka/connectors/iga.py`)", "",402                  source_module_header("iga", registry), ""]403404    errs = recent_errors(db, sids)405    if errs:406        lines += ["## Erreurs de synchronisation récentes (sync_log, ok = 0)", "",407                  "| Source | Quand | Message |", "|---|---|---|"]408        for sid, ts, msg in errs:409            lines.append(f"| `{sid}` | {fmt_ts(ts)} | "410                         f"{(msg or '').replace('|', '—')[:160]} |")411        lines.append("")412    else:413        lines += ["## Erreurs de synchronisation récentes", "",414                  "Aucune erreur dans le sync_log pour les sources de cette famille.", ""]415    return "\n".join(lines)416417418def render_transverse_matching(db) -> str:419    groups, uids = q1(db, "SELECT COUNT(DISTINCT group_id), COUNT(*) FROM product_links")420    dist = db.execute(421        "SELECT n, COUNT(*) FROM (SELECT group_id, COUNT(*) n FROM product_links "422        "GROUP BY group_id) GROUP BY n ORDER BY n").fetchall()423    top_src = db.execute(424        "SELECT p.source, COUNT(*) c FROM product_links l "425        "JOIN products p ON p.uid = l.uid GROUP BY p.source "426        "ORDER BY c DESC LIMIT 10").fetchall()427    lines = ["# Transverse — Matching inter-bannières (`product_links`)", "",428             "_Généré automatiquement par `scripts/gen_connector_docs.py`._", "",429             "## Mécanique (`foodka/matching.py`)", "",430             module_header(ROOT / "foodka" / "matching.py", "matching.py"), "",431             "## État live", "",432             f"- **Groupes de produits identiques** : {nfmt(groups)}",433             f"- **Produits rapprochés** : {nfmt(uids)}",434             "- **Règles conservatrices** : marque non vide et strictement égale ; "435             "format identique (quantité + unité de base) ; similarité de noms "436             "nettoyés ≥ 0,86 (0,95 sans format) ; un groupe couvre ≥ 2 bannières "437             "distinctes (`*_flyer` = même bannière que son catalogue).",438             "- **Garde-fous perf** : MAX_BLOCK 3000, comparaisons en fenêtre "439             "triée de 30 (coût linéaire ; rebuild mesuré à ~7 s).",440             "", "## Taille des groupes", "",441             "| Produits par groupe | Groupes |", "|---|---|"]442    lines += [f"| {n} | {c} |" for n, c in dist]443    lines += ["", "## Sources les plus rapprochées", "",444              "| Source | Produits dans un groupe |", "|---|---|"]445    lines += [f"| `{s}` | {c} |" for s, c in top_src]446    lines += ["", "Reconstruit après chaque cycle d'ingestion (table repartie "447              "de zéro : idempotent).", ""]448    return "\n".join(lines)449450451def render_transverse_nutrition(db) -> str:452    tot, found = q1(db, "SELECT COUNT(*), COALESCE(SUM(found), 0) FROM off_cache")453    enriched = q1(db, "SELECT COUNT(*) FROM products WHERE active = 1 "454                      "AND details LIKE '%\"off\"%'")[0]455    lines = ["# Transverse — Nutrition Open Food Facts (`off_cache`)", "",456             "_Généré automatiquement par `scripts/gen_connector_docs.py`._", "",457             "## Mécanique (`foodka/nutrition.py`)", "",458             module_header(ROOT / "foodka" / "nutrition.py", "nutrition.py"), "",459             "## État live", "",460             f"- **Clés cherchées (cache)** : {nfmt(tot)} — "461             f"dont {nfmt(found)} correspondances trouvées "462             f"({100.0 * found / tot:.0f} % de hit)" if tot else463             "- **Cache vide**",464             f"- **Produits actifs enrichis (`details.off`)** : {nfmt(enriched)}",465             "- **Budget** : 100 requêtes/cycle par défaut, throttle 6 s "466             "(limite OFF : 10 recherches/min) + 1 s entre recherche et fiche "467             "produit.",468             "- **Priorisation** : produits avec marque, non encore cherchés "469             "(le cache est la vérité : vider `off_cache` pour re-tenter).",470             "- **Gotcha** : l'ancien `cgi/search.pl` OFF répond 503 (déprécié) "471             "— utiliser search.openfoodfacts.org (search-a-licious) + "472             "/api/v2/product/{code} pour les ingrédients.", ""]473    return "\n".join(lines)474475476def render_transverse_pricelog(db) -> str:477    rows, uids, t0, t1 = q1(db, "SELECT COUNT(*), COUNT(DISTINCT uid), "478                                "MIN(ts), MAX(ts) FROM price_log")479    multi = q1(db, "SELECT COUNT(*) FROM (SELECT uid FROM price_log "480                   "GROUP BY uid HAVING COUNT(DISTINCT price) > 1)")[0]481    lines = ["# Transverse — Historique de prix (`price_log`)", "",482             "_Généré automatiquement par `scripts/gen_connector_docs.py`._", "",483             "## Mécanique", "",484             "À chaque cycle d'ingestion, tout changement de prix observé est "485             "journalisé : `(uid, ts, price)` — `price` NULL = produit retiré "486             "de l'affichage. C'est la matière première des tendances de prix "487             "et des graphiques d'historique ; la continuité repose sur des "488             "`external_id` stables (d'où les slugs marque+nom+format des "489             "circulaires Flipp).", "",490             "## État live", "",491             f"- **Observations** : {nfmt(rows)}",492             f"- **Produits suivis** : {nfmt(uids)}",493             f"- **Produits avec au moins un changement de prix** : {nfmt(multi)}",494             f"- **Période couverte** : {fmt_ts(t0)} → {fmt_ts(t1)}", ""]495    return "\n".join(lines)496497498def render_index(db, groups: OrderedDict, sources: list[dict]) -> str:499    total, active = q1(db, "SELECT COUNT(*), SUM(active) FROM products")500    links = q1(db, "SELECT COUNT(DISTINCT group_id) FROM product_links")[0]501    off = q1(db, "SELECT COUNT(*) FROM products WHERE active = 1 "502                 "AND details LIKE '%\"off\"%'")[0]503    plog = q1(db, "SELECT COUNT(*) FROM price_log")[0]504    now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")505    n_conn = sum(1 for s in sources if s.get("connector"))506507    lines = ["# Food-Ka — Documentation des connecteurs", "",508             f"_Générée le {now} par `scripts/gen_connector_docs.py`"509             " (rejouable : `.venv/bin/python3 scripts/gen_connector_docs.py`)._",510             "", "## Vue d'ensemble", "",511             f"- **Sources recensées** : {len(sources)} (registre "512             f"`data/sources.json`) — dont {n_conn} connectées",513             f"- **Produits en base** : {nfmt(total)} — dont {nfmt(active)} actifs",514             f"- **Groupes inter-bannières** : {nfmt(links)} "515             "(voir [matching](transverse-matching.md))",516             f"- **Produits enrichis Open Food Facts** : {nfmt(off)} "517             "(voir [nutrition](transverse-nutrition-off.md))",518             f"- **Observations de prix** : {nfmt(plog)} "519             "(voir [price_log](transverse-price-log.md))",520             "", "## Fiches par famille de connecteurs", "",521             "| Famille | Fiche | Sources | Produits actifs |",522             "|---|---|---|---|"]523    for slug, members in groups.items():524        sids = [s["id"] for s in members]525        act = q1(db, "SELECT COALESCE(SUM(active), 0) FROM products "526                     f"WHERE source IN ({','.join('?' * len(sids))})", sids)[0] \527            if sids else 0528        lines.append(f"| `{slug}` | [{slug}.md]({slug}.md) | {len(members)} | "529                     f"{nfmt(act)} |")530531    lines += ["", "## Fiches transverses", "",532              "| Sujet | Fiche |", "|---|---|",533              "| Matching inter-bannières (product_links) | [transverse-matching.md](transverse-matching.md) |",534              "| Nutrition Open Food Facts (off_cache) | [transverse-nutrition-off.md](transverse-nutrition-off.md) |",535              "| Historique de prix (price_log) | [transverse-price-log.md](transverse-price-log.md) |",536              "", HISTORIQUE]537    return "\n".join(lines)538539540# --------------------------------------------------------------------------- #541542def main() -> None:543    sources = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]544    registry = get_registry()545    groups: OrderedDict[str, list] = OrderedDict((k, []) for k in FAMILIES)546    for s in sources:547        groups[classify(s, registry)].append(s)548549    db = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)550    OUT_DIR.mkdir(parents=True, exist_ok=True)551552    for slug, members in groups.items():553        (OUT_DIR / f"{slug}.md").write_text(554            render_family_fiche(db, slug, members, registry), encoding="utf-8")555        print(f"  fiche {slug}.md ({len(members)} source(s))")556557    (OUT_DIR / "transverse-matching.md").write_text(render_transverse_matching(db), encoding="utf-8")558    (OUT_DIR / "transverse-nutrition-off.md").write_text(render_transverse_nutrition(db), encoding="utf-8")559    (OUT_DIR / "transverse-price-log.md").write_text(render_transverse_pricelog(db), encoding="utf-8")560    (OUT_DIR / "INDEX.md").write_text(render_index(db, groups, sources), encoding="utf-8")561    print(f"OK — {len(groups)} fiches famille + 3 transverses + INDEX.md dans {OUT_DIR}")562563564if __name__ == "__main__":565    main()566