Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 53.9%
TypeScript 24%
CSS 14.9%
JavaScript 5.8%
HTML 1.4%
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