#!/usr/bin/env python3 # ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # scripts/gen_connector_docs.py : documentation standardisée des connecteurs # # Génère docs/connecteurs/ (INDEX.md + une fiche par FAMILLE de connecteurs + # fiches transverses) en croisant : # 1. data/sources.json — registre des sources (61 bannières) ; # 2. le code des connecteurs — familles par héritage de classe # (FlippConnector, ShopifyConnector, WooCommerceConnector…), en-têtes # « mécanique » des modules, merchant_id/flyer_name_filter des # circulaires, backends, plafonds env (FOODKA_*) ; # 3. la BD live data/foodka.db — volumétrie, complétude par champ, # dernier sync et erreurs (sync_log), matching inter-bannières # (product_links), nutrition OFF (off_cache), historique de prix. # # Rejouable à volonté (BD ouverte en lecture seule, docs régénérés) : # .venv/bin/python3 scripts/gen_connector_docs.py # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import json import re import sqlite3 import sys from collections import Counter, OrderedDict from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) DB_PATH = ROOT / "data" / "foodka.db" SOURCES_PATH = ROOT / "data" / "sources.json" CONNECTORS_DIR = ROOT / "foodka" / "connectors" OUT_DIR = ROOT / "docs" / "connecteurs" # Champs de complétude (produits actifs) : libellé -> expression SQL "rempli" FIELDS = OrderedDict([ ("Prix", "price IS NOT NULL"), ("Prix régulier", "regular_price IS NOT NULL"), ("Format (size_label)", "size_label IS NOT NULL AND size_label != ''"), ("Prix unitaire", "unit_price IS NOT NULL"), ("Marque", "brand IS NOT NULL AND brand != ''"), ("Images", "images IS NOT NULL AND images NOT IN ('', '[]')"), ("Nutrition OFF (details.off)", "details LIKE '%\"off\"%'"), ]) # --- Définition des familles : (slug, titre, module socle éventuel, gotchas) -- FAMILIES = OrderedDict([ ("flipp-proximite", { "title": "Circulaires Flipp — épiceries de proximité (catalogue = circulaire)", "base": "_flipp.py", "gotchas": [ "Le paramètre d'URL de l'API backflipp ne filtre PAS par marchand : " "filtrer côté client sur `merchant_id`.", "`flyer_name_filter` écarte les cahiers parasites du même merchant " "(ex. Supermarché PA : « Weekly Flyer » vs « Nature Flyer »).", "Tri des circulaires candidates par `valid_from` croissant : en cas " "de chevauchement (semaine courante + semaine à venir), on prend la " "courante.", "Les items Flipp n'ont ni SKU, ni catégorie, ni unité : " "`external_id` = slug stable marque+nom+format (continuité du " "price_log d'une semaine à l'autre), catégorie déduite du nom.", "Le `flyer_id` change chaque semaine — toujours résolu dynamiquement " "via /flipp/flyers (une circulaire provinciale par bannière : un " "poste montréalais suffit).", ]}), ("flyers-grandes-bannieres", { "title": "Circulaires Flipp — grandes bannières (complément rabais du catalogue)", "base": "_flipp.py", "gotchas": [ "Chaque source `*_flyer` complète la source catalogue de la même " "bannière (metro / metro_flyer) : le matching inter-bannières les " "traite comme UNE bannière.", "`flyer_name_filter` est indispensable ici : les grands merchants " "publient plusieurs cahiers (Metro « Metrogo! », Costco cahiers non " "alimentaires, Walmart livrets thématiques…).", "Tri `valid_from` croissant pour choisir la circulaire de la semaine " "courante ; flyer_id résolu à chaque sync.", ]}), ("scrapfly-asp", { "title": "Catalogues derrière anti-bot — Scrapfly (ASP)", "base": None, "gotchas": [ "Chaque requête passe par Scrapfly en mode ASP (anti-scraping " "protection) : coût par appel — les plafonds de pagination par " "allée/catégorie bornent le budget de chaque sync.", "Metro et Super C partagent le socle `_metro.py` (tuiles rendues " "serveur, pagination « -page-N ») ; Maxi, Provigo et Club " "Entrepôt partagent `_loblaw.py` (grille complète dans " "`__NEXT_DATA__`).", ]}), ("render-js", { "title": "Catalogues SPA — Scrapfly avec rendu JavaScript", "base": None, "gotchas": [ "Le rendu JavaScript Scrapfly est encore plus coûteux que l'ASP " "simple : nombre de pages par catégorie plafonné, syncs espacés.", ]}), ("shopify", { "title": "Boutiques Shopify — /products.json ouvert", "base": "_shopify.py", "gotchas": [ "Catalogue public JSON sans anti-bot : " "/products.json?limit=250&page=N (pagination plafonnée par " "garde-fou).", "Le format vient des variantes ; prix unitaire recalculé " "(normalize.unit_price) quand la taille est parsable.", ]}), ("woocommerce", { "title": "Épiceries WooCommerce — Store API ouverte", "base": "_woocommerce.py", "gotchas": [ "Prix en unités mineures (cents) : « 1299 » + " "currency_minor_unit=2 -> 12,99 $.", "/wp-json/wc/store/v1/products?per_page=100&page=N, pagination " "plafonnée par garde-fou.", ]}), ("html", { "title": "Catalogues HTML rendus serveur ou API directe — requests", "base": None, "gotchas": [ "Sites sans anti-bot : parsing HTML/sitemap direct (HubSpot, " "Magento 2, k-eCommerce, SSR maison).", "SAQ : API GraphQL Adobe Live Search " "(catalog-service.adobe.io) appelée en direct avec la clé " "publique du storefront — tout est dans la réponse liste, " "aucune page détail ; catégorie unique « Alcool », exclue du " "matching (matching.EXCLUDED_SOURCES).", ]}), ("iga-api", { "title": "IGA — API Voilà (Sobeys Québec)", "base": None, "gotchas": [ "Voie principale : API REST publique des promotions de voila.ca " "(JSON complet, curseur, sans anti-bot) ; complément optionnel par " "recherche SPA rendue via Scrapfly (render_js).", "`regionId` public requis par l'API (région de livraison Québec).", ]}), ("non-connectables", { "title": "Sources recensées non connectables", "base": None, "gotchas": []}), ]) HISTORIQUE = """\ ## Historique des vagues (2026-08-18) | Commit | Contenu | |---|---| | `afdb2e6` | Enrichissement connecteurs + robustesse DB (audit 2026-08-18) | | `a45aeb4` | Vague 2 : circulaires grandes bannières + comparateur inter-bannières + nutrition Open Food Facts | | `114aa76` | Matching : MAX_BLOCK 400 → 3000 (marques maison des circulaires ; fenêtre triée de 30 = coût linéaire) | | `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 | """ # --------------------------------------------------------------------------- # # Introspection du code # --------------------------------------------------------------------------- # def get_registry(): import foodka.connectors as reg return reg.CONNECTORS def classify(source: dict, registry) -> str: if source.get("status") == "non connectable": return "non-connectables" sid = source["id"] if sid == "iga": return "iga-api" cls = registry.get(sid) bases = {b.__name__ for b in cls.__mro__} if cls else set() if "FlippConnector" in bases: return "flyers-grandes-bannieres" if sid.endswith("_flyer") else "flipp-proximite" if "ShopifyConnector" in bases: return "shopify" if "WooStoreConnector" in bases: return "woocommerce" tech = source.get("tech", "") if "Flipp" in tech: # connecteur Flipp sur mesure hors socle return "flyers-grandes-bannieres" if sid.endswith("_flyer") else "flipp-proximite" if "WooCommerce" in tech: # ex. akhavan : Store API, impl. sur mesure return "woocommerce" if "rendu JavaScript" in tech: return "render-js" if "Scrapfly" in tech: return "scrapfly-asp" return "html" def module_header(path: Path, marker: str | None = None) -> str: """Bloc de commentaires du haut du fichier, rendu en citation Markdown. Si `marker` est donné, on démarre à la ligne qui commence par ce texte.""" lines, started = [], marker is None for raw in path.read_text(encoding="utf-8").splitlines(): if not raw.startswith("#") or raw.startswith("#!"): if raw.startswith("#!"): continue break body = raw.lstrip("#").strip() if not started: if marker and body.startswith(marker): started = True lines.append(body) continue if set(body) <= {"-"} and len(body) > 10: break lines.append(body) if not lines: return "_(pas d'en-tête trouvé)_" return "\n".join("> " + l for l in lines) def source_module_header(sid: str, registry) -> str: cls = registry.get(sid) if not cls: return "_(pas de connecteur)_" mod = cls.__module__.rsplit(".", 1)[-1] path = CONNECTORS_DIR / f"{mod}.py" # démarrer après les 2 lignes standard (titre projet + auteur) text = path.read_text(encoding="utf-8").splitlines() lines, seen_author = [], False for raw in text: if not raw.startswith("#"): break body = raw.lstrip("#").strip() if set(body) <= {"-"} and len(body) > 10: if seen_author and lines: break continue if body.startswith("Auteur :"): seen_author = True continue if body.startswith("Food-Ka —"): continue if seen_author: lines.append(body) return "\n".join("> " + l for l in lines) if lines else "_(pas d'en-tête)_" def detect_caps(sid: str, registry) -> str: cls = registry.get(sid) if not cls: return "—" mod = cls.__module__.rsplit(".", 1)[-1] src = (CONNECTORS_DIR / f"{mod}.py").read_text(encoding="utf-8") caps = sorted(set(re.findall(r"FOODKA_[A-Z_]+", src))) hard = sorted(set(re.findall(r"(?:MAX|LIMIT)_[A-Z_]+\s*=\s*\d+", src)))[:3] out = caps + [h.replace(" ", "") for h in hard] return ", ".join(f"`{c}`" for c in out) if out else "—" # --------------------------------------------------------------------------- # # BD live # --------------------------------------------------------------------------- # def q1(db, sql, args=()): return db.execute(sql, args).fetchone() def completeness(db, source_ids: list[str]) -> tuple[int, OrderedDict]: if not source_ids: return 0, OrderedDict((l, 0.0) for l in FIELDS) ph = ",".join("?" * len(source_ids)) parts = ", ".join(f"SUM(CASE WHEN {expr} THEN 1 ELSE 0 END)" for expr in FIELDS.values()) row = q1(db, f"SELECT COUNT(*), {parts} FROM products " f"WHERE active = 1 AND source IN ({ph})", source_ids) n = row[0] or 0 out = OrderedDict() for (label, _), filled in zip(FIELDS.items(), row[1:]): out[label] = (100.0 * (filled or 0) / n) if n else 0.0 return n, out def source_stats(db, sid: str) -> dict: tot, act, sale = q1(db, "SELECT COUNT(*), SUM(active), " "SUM(active = 1 AND on_sale = 1) FROM products " "WHERE source = ?", (sid,)) last = q1(db, "SELECT ts, ok, found, message FROM sync_log " "WHERE source = ? ORDER BY ts DESC LIMIT 1", (sid,)) errs = q1(db, "SELECT COUNT(*) FROM (SELECT ok FROM sync_log " "WHERE source = ? ORDER BY ts DESC LIMIT 10) WHERE ok = 0", (sid,))[0] return {"total": tot or 0, "active": act or 0, "sale": sale or 0, "last": last, "errs10": errs} def fmt_ts(ts) -> str: if not ts: return "—" return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") def recent_errors(db, source_ids: list[str], limit=8) -> list[tuple]: if not source_ids: return [] ph = ",".join("?" * len(source_ids)) return db.execute( f"SELECT source, ts, message FROM sync_log " f"WHERE ok = 0 AND source IN ({ph}) ORDER BY ts DESC LIMIT ?", (*source_ids, limit)).fetchall() # --------------------------------------------------------------------------- # # Rendu des fiches # --------------------------------------------------------------------------- # def nfmt(n: int) -> str: return f"{n:,}".replace(",", " ") def render_family_fiche(db, slug: str, members: list[dict], registry) -> str: meta = FAMILIES[slug] sids = [s["id"] for s in members] n, comp = completeness(db, sids) lines = [f"# Famille `{slug}` — {meta['title']}", "", "_Généré automatiquement par `scripts/gen_connector_docs.py` — ne pas éditer à la main._", "", "## Vue d'ensemble", "", f"- **Sources membres** : {len(members)}"] if slug != "non-connectables": act = q1(db, "SELECT COALESCE(SUM(active), 0) FROM products WHERE source IN " f"({','.join('?' * len(sids))})", sids)[0] lines.append(f"- **Produits actifs (BD)** : {nfmt(act)}") if meta["base"]: lines.append(f"- **Socle commun** : `foodka/connectors/{meta['base']}`") if meta["base"]: lines += ["", f"## Mécanique du socle (`{meta['base']}`)", "", module_header(CONNECTORS_DIR / meta["base"], f"connectors/{meta['base']}"), ""] # ---- tableau des membres if slug == "non-connectables": lines += ["", "## Sources", "", "| Source | Nom | Tech | Pourquoi non connectable |", "|---|---|---|---|"] for s in sorted(members, key=lambda x: x["id"]): lines.append(f"| `{s['id']}` | {s['name']} | {s.get('tech', '')} | " f"{(s.get('notes') or '').replace('|', '—')} |") lines.append("") return "\n".join(lines) if slug in ("flipp-proximite", "flyers-grandes-bannieres"): header = ("| Source | Nom | merchant_id | flyer_name_filter | Région | " "Produits actifs | En rabais | Dernier sync | Statut |") sep = "|---|---|---|---|---|---|---|---|---|" else: header = ("| Source | Nom | Tech | Région | Produits actifs | " "En rabais | Dernier sync | Statut |") sep = "|---|---|---|---|---|---|---|---|" lines += ["## Sources membres (BD live)", "", header, sep] for s in sorted(members, key=lambda x: x["id"]): st = source_stats(db, s["id"]) last = st["last"] when = fmt_ts(last[0]) if last else "jamais" status = ("OK" if last and last[1] else "ERREUR") + \ (f" ({last[2] or 0} trouvés)" if last else "") if slug in ("flipp-proximite", "flyers-grandes-bannieres"): cls = registry.get(s["id"]) mid = getattr(cls, "merchant_id", "—") if cls else "—" filt = getattr(cls, "flyer_name_filter", "") if cls else "" lines.append(f"| `{s['id']}` | {s['name']} | {mid} | " f"{('`' + filt + '`') if filt else '—'} | " f"{s.get('region', '—')} | {st['active']} | " f"{st['sale']} | {when} | {status} |") else: lines.append(f"| `{s['id']}` | {s['name']} | " f"{(s.get('tech') or '—').replace('|', '—')} | " f"{s.get('region', '—')} | {st['active']} | " f"{st['sale']} | {when} | {status} |") lines.append("") # ---- complétude lines += [f"## Complétude des champs (produits actifs, N = {nfmt(n)})", "", "| Champ | % rempli |", "|---|---|"] lines += [f"| {label} | {pct:.1f} % |" for label, pct in comp.items()] lines.append("") # ---- gotchas if meta["gotchas"]: lines += ["## Gotchas", ""] lines += [f"- {g}" for g in meta["gotchas"]] lines.append("") # ---- mécanique par source (familles sans socle unique) if not meta["base"] and slug != "iga-api": lines += ["## Mécanique par source (en-têtes des modules)", ""] for s in sorted(members, key=lambda x: x["id"]): lines += [f"### `{s['id']}` — {s['name']}", "", f"Plafonds/env : {detect_caps(s['id'], registry)}", "", source_module_header(s["id"], registry), ""] elif slug == "iga-api": lines += ["## Mécanique (`foodka/connectors/iga.py`)", "", source_module_header("iga", registry), ""] errs = recent_errors(db, sids) if errs: lines += ["## Erreurs de synchronisation récentes (sync_log, ok = 0)", "", "| Source | Quand | Message |", "|---|---|---|"] for sid, ts, msg in errs: lines.append(f"| `{sid}` | {fmt_ts(ts)} | " f"{(msg or '').replace('|', '—')[:160]} |") lines.append("") else: lines += ["## Erreurs de synchronisation récentes", "", "Aucune erreur dans le sync_log pour les sources de cette famille.", ""] return "\n".join(lines) def render_transverse_matching(db) -> str: groups, uids = q1(db, "SELECT COUNT(DISTINCT group_id), COUNT(*) FROM product_links") dist = db.execute( "SELECT n, COUNT(*) FROM (SELECT group_id, COUNT(*) n FROM product_links " "GROUP BY group_id) GROUP BY n ORDER BY n").fetchall() top_src = db.execute( "SELECT p.source, COUNT(*) c FROM product_links l " "JOIN products p ON p.uid = l.uid GROUP BY p.source " "ORDER BY c DESC LIMIT 10").fetchall() lines = ["# Transverse — Matching inter-bannières (`product_links`)", "", "_Généré automatiquement par `scripts/gen_connector_docs.py`._", "", "## Mécanique (`foodka/matching.py`)", "", module_header(ROOT / "foodka" / "matching.py", "matching.py"), "", "## État live", "", f"- **Groupes de produits identiques** : {nfmt(groups)}", f"- **Produits rapprochés** : {nfmt(uids)}", "- **Règles conservatrices** : marque non vide et strictement égale ; " "format identique (quantité + unité de base) ; similarité de noms " "nettoyés ≥ 0,86 (0,95 sans format) ; un groupe couvre ≥ 2 bannières " "distinctes (`*_flyer` = même bannière que son catalogue).", "- **Garde-fous perf** : MAX_BLOCK 3000, comparaisons en fenêtre " "triée de 30 (coût linéaire ; rebuild mesuré à ~7 s).", "", "## Taille des groupes", "", "| Produits par groupe | Groupes |", "|---|---|"] lines += [f"| {n} | {c} |" for n, c in dist] lines += ["", "## Sources les plus rapprochées", "", "| Source | Produits dans un groupe |", "|---|---|"] lines += [f"| `{s}` | {c} |" for s, c in top_src] lines += ["", "Reconstruit après chaque cycle d'ingestion (table repartie " "de zéro : idempotent).", ""] return "\n".join(lines) def render_transverse_nutrition(db) -> str: tot, found = q1(db, "SELECT COUNT(*), COALESCE(SUM(found), 0) FROM off_cache") enriched = q1(db, "SELECT COUNT(*) FROM products WHERE active = 1 " "AND details LIKE '%\"off\"%'")[0] lines = ["# Transverse — Nutrition Open Food Facts (`off_cache`)", "", "_Généré automatiquement par `scripts/gen_connector_docs.py`._", "", "## Mécanique (`foodka/nutrition.py`)", "", module_header(ROOT / "foodka" / "nutrition.py", "nutrition.py"), "", "## État live", "", f"- **Clés cherchées (cache)** : {nfmt(tot)} — " f"dont {nfmt(found)} correspondances trouvées " f"({100.0 * found / tot:.0f} % de hit)" if tot else "- **Cache vide**", f"- **Produits actifs enrichis (`details.off`)** : {nfmt(enriched)}", "- **Budget** : 100 requêtes/cycle par défaut, throttle 6 s " "(limite OFF : 10 recherches/min) + 1 s entre recherche et fiche " "produit.", "- **Priorisation** : produits avec marque, non encore cherchés " "(le cache est la vérité : vider `off_cache` pour re-tenter).", "- **Gotcha** : l'ancien `cgi/search.pl` OFF répond 503 (déprécié) " "— utiliser search.openfoodfacts.org (search-a-licious) + " "/api/v2/product/{code} pour les ingrédients.", ""] return "\n".join(lines) def render_transverse_pricelog(db) -> str: rows, uids, t0, t1 = q1(db, "SELECT COUNT(*), COUNT(DISTINCT uid), " "MIN(ts), MAX(ts) FROM price_log") multi = q1(db, "SELECT COUNT(*) FROM (SELECT uid FROM price_log " "GROUP BY uid HAVING COUNT(DISTINCT price) > 1)")[0] lines = ["# Transverse — Historique de prix (`price_log`)", "", "_Généré automatiquement par `scripts/gen_connector_docs.py`._", "", "## Mécanique", "", "À chaque cycle d'ingestion, tout changement de prix observé est " "journalisé : `(uid, ts, price)` — `price` NULL = produit retiré " "de l'affichage. C'est la matière première des tendances de prix " "et des graphiques d'historique ; la continuité repose sur des " "`external_id` stables (d'où les slugs marque+nom+format des " "circulaires Flipp).", "", "## État live", "", f"- **Observations** : {nfmt(rows)}", f"- **Produits suivis** : {nfmt(uids)}", f"- **Produits avec au moins un changement de prix** : {nfmt(multi)}", f"- **Période couverte** : {fmt_ts(t0)} → {fmt_ts(t1)}", ""] return "\n".join(lines) def render_index(db, groups: OrderedDict, sources: list[dict]) -> str: total, active = q1(db, "SELECT COUNT(*), SUM(active) FROM products") links = q1(db, "SELECT COUNT(DISTINCT group_id) FROM product_links")[0] off = q1(db, "SELECT COUNT(*) FROM products WHERE active = 1 " "AND details LIKE '%\"off\"%'")[0] plog = q1(db, "SELECT COUNT(*) FROM price_log")[0] now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") n_conn = sum(1 for s in sources if s.get("connector")) lines = ["# Food-Ka — Documentation des connecteurs", "", f"_Générée le {now} par `scripts/gen_connector_docs.py`" " (rejouable : `.venv/bin/python3 scripts/gen_connector_docs.py`)._", "", "## Vue d'ensemble", "", f"- **Sources recensées** : {len(sources)} (registre " f"`data/sources.json`) — dont {n_conn} connectées", f"- **Produits en base** : {nfmt(total)} — dont {nfmt(active)} actifs", f"- **Groupes inter-bannières** : {nfmt(links)} " "(voir [matching](transverse-matching.md))", f"- **Produits enrichis Open Food Facts** : {nfmt(off)} " "(voir [nutrition](transverse-nutrition-off.md))", f"- **Observations de prix** : {nfmt(plog)} " "(voir [price_log](transverse-price-log.md))", "", "## Fiches par famille de connecteurs", "", "| Famille | Fiche | Sources | Produits actifs |", "|---|---|---|---|"] for slug, members in groups.items(): sids = [s["id"] for s in members] act = q1(db, "SELECT COALESCE(SUM(active), 0) FROM products " f"WHERE source IN ({','.join('?' * len(sids))})", sids)[0] \ if sids else 0 lines.append(f"| `{slug}` | [{slug}.md]({slug}.md) | {len(members)} | " f"{nfmt(act)} |") lines += ["", "## Fiches transverses", "", "| Sujet | Fiche |", "|---|---|", "| Matching inter-bannières (product_links) | [transverse-matching.md](transverse-matching.md) |", "| Nutrition Open Food Facts (off_cache) | [transverse-nutrition-off.md](transverse-nutrition-off.md) |", "| Historique de prix (price_log) | [transverse-price-log.md](transverse-price-log.md) |", "", HISTORIQUE] return "\n".join(lines) # --------------------------------------------------------------------------- # def main() -> None: sources = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] registry = get_registry() groups: OrderedDict[str, list] = OrderedDict((k, []) for k in FAMILIES) for s in sources: groups[classify(s, registry)].append(s) db = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) OUT_DIR.mkdir(parents=True, exist_ok=True) for slug, members in groups.items(): (OUT_DIR / f"{slug}.md").write_text( render_family_fiche(db, slug, members, registry), encoding="utf-8") print(f" fiche {slug}.md ({len(members)} source(s))") (OUT_DIR / "transverse-matching.md").write_text(render_transverse_matching(db), encoding="utf-8") (OUT_DIR / "transverse-nutrition-off.md").write_text(render_transverse_nutrition(db), encoding="utf-8") (OUT_DIR / "transverse-price-log.md").write_text(render_transverse_pricelog(db), encoding="utf-8") (OUT_DIR / "INDEX.md").write_text(render_index(db, groups, sources), encoding="utf-8") print(f"OK — {len(groups)} fiches famille + 3 transverses + INDEX.md dans {OUT_DIR}") if __name__ == "__main__": main()