SPB Git forge

spb/fabri-ka

Public

Agrégateur de produits québécois — www.fabri-ka.com

217commits 1branches 0releases
66.1 MBsize
maindefault branch
3 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%
23.2 KB · 500 lines python
Raw Blame History
1#!/usr/bin/env python32# -----------------------------------------------------------------------------3# Fabri-Ka — Agrégateur de produits québécois4# Auteur : Simon-Pierre Boucher — contact@spboucher.ai5# scripts/gen_connector_docs.py : documentation STANDARDISÉE des connecteurs.6#7#   Génère docs/connecteurs/INDEX.md + une fiche par connecteur-plateforme8#   (shopify, woocommerce, wix, squarespace, square, generic,9#   scrapfly-transport, ecwid/verdict) + une fiche « découverte & registre »10#   (data/stores.json) + une fiche « enrichissement boutiques »11#   (enrich_stores / enrich_shipping), en croisant :12#     1. le registre        : data/stores.json (3 224 boutiques)13#     2. l'introspection    : fabrika/connectors/* + scripts d'enrichissement14#     3. la base vivante    : data/fabrika.db (stores, products, sync_log)15#16#   REJOUABLE : ré-exécuter le script régénère tout docs/connecteurs/.17#   Usage : python3 scripts/gen_connector_docs.py18# -----------------------------------------------------------------------------19from __future__ import annotations2021import collections22import datetime23import json24import re25import sqlite326import sys27from pathlib import Path2829ROOT = Path(__file__).resolve().parent.parent30sys.path.insert(0, str(ROOT))3132DATA = ROOT / "data"33OUT = ROOT / "docs" / "connecteurs"34DB = DATA / "fabrika.db"35CONN_DIR = ROOT / "fabrika" / "connectors"3637NOW = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")38GEN_NOTE = ("*Généré le {} par `scripts/gen_connector_docs.py` — fichier "39            "produit automatiquement, ne pas éditer à la main.*".format(NOW))4041# plateformes BD regroupées par connecteur42GROUPS: dict[str, list[str]] = {43    "shopify": ["shopify"],44    "woocommerce": ["woocommerce", "wordpress"],45    "wix": ["wix"],46    "squarespace": ["squarespace"],47    "square": ["square"],48    "votresite": ["votresite"],49    "generic": ["generic", "prestashop", "magento", "bigcommerce",50                "lightspeed", "snipcart"],51}5253FICHES = {54    "shopify": dict(55        titre="Connecteur Shopify", module="shopify.py",56        endpoint="`GET /products.json?limit=250&page=N` — catalogue JSON public "57                 "de chaque boutique (aucune clé requise).",58        gotchas=[59            "**Transport curl anti-TLS** : l'empreinte TLS de python-requests "60            "déclenche le 429 de Shopify sous volume ; les GET passent par "61            "`curl -sS --compressed` en sous-processus.",62            "**Verrou global 0,7 s** (`_MIN_INTERVAL = 0.7` + lock inter-threads) "63            "entre deux requêtes Shopify, avec retry — throttle poli à l'échelle "64            "du procédé, pas par boutique.",65            "Les variantes/prix/images viennent du même JSON ; `details` stocke "66            "variants/options/published_at.",67        ]),68    "woocommerce": dict(69        titre="Connecteur WooCommerce (Store API)", module="woocommerce.py",70        endpoint="`GET /wp-json/wc/store/v1/products?per_page=100&page=N` — "71                 "Store API publique (sert aussi les sites `wordpress` avec "72                 "Store API active).",73        gotchas=[74            "Alias `wordpress` : les sites WordPress dont la Store API répond "75            "sont routés vers ce connecteur (voir `PLATFORM_CONNECTORS`).",76            "`details` porte attributes/variations, poids et dimensions "77            "formatés quand la boutique les publie.",78        ]),79    "wix": dict(80        titre="Connecteur Wix Stores", module="wix.py",81        endpoint="`GET /_api/v1/access-tokens` (jeton d'instance public de "82                 "l'app Wix Stores) puis GraphQL storefront "83                 "`getFilteredProducts` (catalogue complet, paginé).",84        gotchas=[85            "Le jeton est public mais par site : il faut le ré-extraire à "86            "chaque sync (pas de clé persistante).",87        ]),88    "squarespace": dict(89        titre="Connecteur Squarespace", module="squarespace.py",90        endpoint="`GET /shop|/boutique|/store?format=json` — rendu JSON natif "91                 "des pages boutique Squarespace, pagination par collection.",92        gotchas=[93            "L'URL de la page boutique varie (`/shop`, `/boutique`, `/store`…) : "94            "le sondage essaie les slugs usuels et mémorise le bon endpoint.",95        ]),96    "square": dict(97        titre="Connecteur Square Online", module="square.py",98        endpoint="IDs `user_id`/`site_id` extraits du HTML de la page d'accueil "99                 "→ `GET /app/store/api/v13/editor/users/{user}/sites/{site}/"100                 "store-pages/…/products` (API storefront publique).",101        gotchas=[102            "Connecteur ajouté en **vague 2** (commit `778d2b6`) — a rendu "103            "connectables les boutiques Square jusque-là à 0 produit.",104            "Deux requêtes minimum par boutique (HTML d'accueil + API).",105        ]),106    "votresite": dict(107        titre="Connecteur Votresite.ca (Drupal + OpenCart)", module="votresite.py",108        endpoint="Boutique OpenCart montée sous `/boutique` (parfois `/produits`, "109                 "`/shop`) : inventaire par `<mount>/sitemap.xml` sinon crawl des "110                 "catégories `<mount>/fr` (`?limit=100&page=N`), puis fiche par "111                 "fiche `…/<slug>-p<ID>/` (rendu 100 % serveur, aucune API JSON).",112        gotchas=[113            "**Plateforme québécoise** (scripts.votresite.ca) : vitrine Drupal 8 "114            "géré (thème `owebo-votresite`) + boutique OpenCart en sous-répertoire — "115            "le montage est détecté via le registre, la page d'accueil, puis "116            "`/boutique`/`/produits`.",117            "**Prix : le HTML d'abord, les meta en secours** — en solde, "118            "`twitter:data1` affiche le prix RÉGULIER ; le prix courant est le "119            "`<h2>…$</h2>` de la fiche et le prix barré le `<span>` "120            "`text-decoration: line-through` (→ `compare_at_price`).",121            "**Titre = dernier `<h1>` sans ancre** : le premier `<h1>` est le "122            "logo du site (un lien) sur plusieurs thèmes ; `og:title` peut être "123            "du bourrage de mots-clés (ex. lemieldabee.ca).",124            "**Dédup par ID produit** : le même `p<ID>` apparaît sous plusieurs "125            "chemins de catégorie (`-p361c37c45c44`).",126            "Les URLs d'images OpenCart contiennent espaces/accents bruts "127            "(`image/cache/catalog/…`) — encodées avant stockage.",128        ]),129    "generic": dict(130        titre="Connecteur générique (JSON-LD / microdata / OG)", module="generic.py",131        endpoint="Sitemap → pages produit → extraction du balisage produit "132                 "(JSON-LD, microdata, Open Graph). Sert PrestaShop, Magento, "133                 "BigCommerce, Lightspeed, Snipcart et les sites custom "134                 "(endpoint sentinelle `__generic__`).",135        gotchas=[136            "Scrapfly/Firecrawl en secours anti-bot quand l'accès direct "137            "échoue (403/429).",138            "Rendu 100 % client (Ecwid) non couvert — voir la fiche "139            "[verdict Ecwid](ecwid.md).",140        ]),141}142143ECWID_VERDICT = """# Ecwid — verdict : NON COUVERT144145{gen}146147## Verdict148149**Ecwid n'est pas connectable** en l'état (décision documentée, README) :150151- Le storefront Ecwid est **100 % client-side** : aucun rendu serveur du152  catalogue, ni sur les *instant sites* ni via le plugin WordPress — le153  connecteur générique (JSON-LD/microdata) ne voit rien.154- L'**API REST v3 exige un token secret** par boutique ; le token `pub…`155  présent dans `script.js` est générique et refusé (**403**).156- Conséquence : les boutiques Ecwid restent au registre (`enabled=0`,157  plateforme détectée) en attente d'une éventuelle voie d'accès.158159## Boutiques Ecwid recensées ({n})160161| Boutique | Domaine | Statut registre |162|---|---|---|163{rows}164"""165166167# --- helpers ------------------------------------------------------------------168def module_header(path: Path) -> str:169    try:170        text = path.read_text(encoding="utf-8")171    except OSError:172        return ""173    lines = []174    for line in text.splitlines():175        if line.startswith("#!"):176            continue177        if line.startswith("#"):178            s = line.lstrip("#").rstrip()179            if s.startswith(" "):180                s = s[1:]181            if set(s) <= {"-", " "}:182                continue183            lines.append(s)184        elif lines:185            break186        elif line.strip():187            break188    if not lines:189        m = re.match(r'\s*(?:"""|\'\'\')(.*?)(?:"""|\'\'\')', text, re.S)190        if m:191            lines = [ln.rstrip() for ln in m.group(1).strip().splitlines()]192    return "\n".join("> " + (ln or "") for ln in lines)193194195def pct(part, whole) -> str:196    if not whole:197        return "—"198    return f"{100.0 * (part or 0) / whole:.1f} %"199200201def nfr(n) -> str:202    return f"{n:,}".replace(",", " ") if isinstance(n, (int, float)) else str(n)203204205def fmt_ts(ts) -> str:206    if not ts:207        return "—"208    return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")209210211# --- collecte -------------------------------------------------------------------212def load_registry() -> dict:213    return json.loads((DATA / "stores.json").read_text(encoding="utf-8"))214215216def db_platform_stats(con) -> dict:217    """Agrégats produits par plateforme BD (une passe)."""218    rows = con.execute("""219        SELECT s.platform AS plat,220          COUNT(*)                                                    AS produits,221          SUM(p.price IS NOT NULL)                                    AS prix,222          SUM(p.images IS NOT NULL AND p.images NOT IN ('','[]'))     AS photos,223          SUM(p.details IS NOT NULL)                                  AS details,224          SUM(p.details LIKE '%average_rating%'225              OR p.details LIKE '%review_count%')                     AS rating,226          SUM(p.details LIKE '%weight%')                              AS poids,227          SUM(p.description IS NOT NULL AND p.description<>'')        AS descr228        FROM products p JOIN stores s ON s.id = p.store_id229        WHERE p.active = 1 GROUP BY s.platform""").fetchall()230    return {r["plat"]: dict(r) for r in rows}231232233def group_stats(pstats: dict, plats: list[str]) -> dict:234    keys = ("produits", "prix", "photos", "details", "rating", "poids", "descr")235    out = {k: 0 for k in keys}236    for p in plats:237        s = pstats.get(p)238        if s:239            for k in keys:240                out[k] += s[k] or 0241    return out242243244def volumetrie_md(g: dict) -> str:245    n = g["produits"] or 0246    rows = [247        ("Produits actifs", nfr(n)),248        ("Prix", pct(g["prix"], n)),249        ("Photos", pct(g["photos"], n)),250        ("Description", pct(g["descr"], n)),251        ("Détails (variantes/attributs)", pct(g["details"], n)),252        ("— dont avis (rating/review_count)", pct(g["rating"], n)),253        ("— dont poids/dimensions", pct(g["poids"], n)),254    ]255    return "| Indicateur | Valeur |\n|---|---|\n" + \256        "\n".join(f"| {k} | {v} |" for k, v in rows)257258259def top_stores(con, plats: list[str], limit=5):260    q = ",".join("?" * len(plats))261    return con.execute(262        f"SELECT id, name, product_count, last_sync, last_status FROM stores "263        f"WHERE platform IN ({q}) AND product_count > 0 "264        f"ORDER BY product_count DESC LIMIT {limit}", plats).fetchall()265266267def store_counts(con, registry, plats: list[str]) -> tuple[int, int, int]:268    reg = sum(1 for s in registry["stores"] if (s.get("platform") or "") in plats)269    q = ",".join("?" * len(plats))270    en, prod = con.execute(271        f"SELECT SUM(enabled=1), SUM(product_count>0) FROM stores "272        f"WHERE platform IN ({q})", plats).fetchone()273    return reg, en or 0, prod or 0274275276# --- fiches plateforme ------------------------------------------------------------277def write_platform(key: str, con, registry, pstats) -> None:278    f = FICHES[key]279    plats = GROUPS[key]280    g = group_stats(pstats, plats)281    reg, en, prod = store_counts(con, registry, plats)282    md = [f"# {f['titre']} (`{key}`)", "", GEN_NOTE, "",283          "## Mécanique / endpoint", "", f"- {f['endpoint']}",284          f"- Module : `fabrika/connectors/{f['module']}`",285          f"- Plateformes BD routées ici : " + ", ".join(f"`{p}`" for p in plats), ""]286    hdr = module_header(CONN_DIR / f["module"])287    if hdr:288        md += ["**En-tête du module :**", "", hdr, ""]289    md += ["## Boutiques rattachées", "",290           f"- Registre `data/stores.json` : **{reg}** boutiques",291           f"- En base (table `stores`) : **{en}** activées, **{prod}** avec produits", "",292           "**Top 5 par volume :**", "",293           "| Boutique | Domaine | Produits | Dernier sync | Statut |",294           "|---|---|---|---|---|"]295    for r in top_stores(con, plats):296        md.append(f"| {r['name'] or r['id']} | `{r['id']}` | "297                  f"{nfr(r['product_count'])} | {fmt_ts(r['last_sync'])} | "298                  f"{r['last_status'] or '—'} |")299    md += ["", "## Volumétrie & complétude (BD live)", "", volumetrie_md(g), "",300           "## Gotchas", ""]301    md += [f"- {gtc}" for gtc in f["gotchas"]]302    md += ["", "Voir aussi : [INDEX](INDEX.md) · "303           "[transport Scrapfly](scrapfly-transport.md) · "304           "[découverte & registre](decouverte-registre.md)."]305    (OUT / f"{key}.md").write_text("\n".join(md) + "\n", encoding="utf-8")306307308def write_scrapfly(con) -> None:309    md = ["# Transport de secours Scrapfly (`scrapfly-transport`)", "", GEN_NOTE, "",310          "## Mécanique", "",311          "- `POST` → `https://api.scrapfly.io/scrape` avec `asp=true` "312          "(anti-bot) et `country=ca`, `render_js` optionnel.",313          "- Employé **en dernier recours** quand l'accès direct échoue "314          "(403/429/HTML au lieu de JSON) ; chaque appel consomme des crédits "315          "et les échecs définitifs sont mémorisés.",316          "- Clé : variable d'environnement `SCRAPFLY_API_KEY` (`.env`).",317          "- Throttle interne : verrou global, 0,5 s minimum entre appels.", ""]318    hdr = module_header(CONN_DIR / "scrapfly.py")319    if hdr:320        md += ["**En-tête du module :**", "", hdr, ""]321    md += ["## Gotchas", "",322           "- **`large_object`** : au-delà d'une certaine taille, Scrapfly ne "323           "renvoie pas le contenu mais une URL "324           "`https://api.scrapfly.io/scrape/large_object/…` qu'il faut suivre "325           "(2e GET, avec la clé en paramètre) pour obtenir le corps réel — "326           "géré depuis le commit `4c85b1f` (repli Scrapfly robuste).",327           "- Utilisé aussi par les scripts d'enrichissement "328           "(`enrich_stores.py`) pour les pages d'accueil en 403.", ""]329    (OUT / "scrapfly-transport.md").write_text("\n".join(md) + "\n", encoding="utf-8")330331332def write_ecwid(con, registry) -> None:333    stores = [s for s in registry["stores"] if s.get("platform") == "ecwid"]334    rows = "\n".join(335        "| {} | `{}` | {} |".format(336            s.get("name") or s["id"], s["id"],337            "activée" if s.get("enabled") else "désactivée (pas d'endpoint)")338        for s in sorted(stores, key=lambda x: x["id"]))339    (OUT / "ecwid.md").write_text(340        ECWID_VERDICT.format(gen=GEN_NOTE, n=len(stores), rows=rows),341        encoding="utf-8")342343344def write_discovery(registry) -> None:345    ss = registry["stores"]346    oc = collections.Counter(s.get("origin_class") for s in ss)347    plat = collections.Counter((s.get("platform") or "(vide)") for s in ss)348    src = collections.Counter()349    for s in ss:350        src.update(s.get("discovery_sources") or [])351    status = collections.Counter(s.get("status") for s in ss)352    md = ["# Découverte & registre (`data/stores.json`)", "", GEN_NOTE, "",353          f"Registre généré le **{registry.get('generated')}** — "354          f"**{registry.get('count')} boutiques** (champ `count`), toutes avec "355          "id (domaine canonique), plateforme, endpoint catalogue, classe "356          "d'origine, preuves et sources de découverte.", "",357          "## Classes d'origine (`origin_class`)", "",358          "| Classe | Boutiques | Signification |", "|---|---|---|"]359    signif = {"A": "fabrication/production au Québec attestée",360              "B": "transformation/assemblage au Québec",361              "C": "marque québécoise (fabrication partielle ou incertaine)",362              "D": "revendeur/distributeur québécois",363              "E": "à requalifier / preuve faible"}364    for k in sorted(oc):365        md.append(f"| {k} | {oc[k]} | {signif.get(k, '—')} |")366    md += ["", "Confiance : `origin_confidence` (0-1) + `origin_evidence` "367           "(texte de preuve, annuaire ou mention sur le site).", "",368           "## Annuaires & sources de découverte (top 15)", "",369           "| Source | Boutiques |", "|---|---|"]370    for name, n in src.most_common(15):371        md.append(f"| `{name}` | {n} |")372    md += ["", f"Statuts de vérification : " +373           ", ".join(f"`{k}` : {v}" for k, v in status.most_common()), "",374           "## Plateformes détectées au registre", "",375           "| Plateforme | Boutiques |", "|---|---|"]376    for p, n in plat.most_common():377        md.append(f"| `{p}` | {n} |")378    md += ["", "## Re-sondage (`scripts/reprobe_stores.py`)", ""]379    hdr = module_header(ROOT / "scripts" / "reprobe_stores.py")380    if hdr:381        md += [hdr, ""]382    md += ["Le re-sondage est **additif** : il réactive des boutiques à "383           "0 produit (migrations de plateforme, Square Online devenu "384           "connectable en vague 2) sans jamais toucher aux boutiques déjà "385           "actives. Il met à jour `stores.json`, `data/verify_cache/`, "386           "`data/enriched/verified.jsonl` et la table `stores`.", ""]387    (OUT / "decouverte-registre.md").write_text("\n".join(md) + "\n", encoding="utf-8")388389390def write_enrichment(con) -> None:391    logo, cover, ship, tot = con.execute(392        "SELECT SUM(logo_url IS NOT NULL AND logo_url<>''),"393        " SUM(cover_url IS NOT NULL AND cover_url<>''),"394        " SUM(shipping_info IS NOT NULL AND shipping_info<>''),"395        " COUNT(*) FROM stores").fetchone()396    n_cache = len(list((DATA / "enrich_cache").glob("*.json")))397    ship_dir = DATA / "enrich_cache" / "shipping"398    n_ship = len(list(ship_dir.glob("*.json"))) if ship_dir.exists() else 0399    md = ["# Enrichissement des boutiques", "", GEN_NOTE, "",400          "Deux scripts rejouables complètent la table `stores` (colonnes "401          "additives `logo_url`, `cover_url`, `description_meta`, "402          "`shipping_info`).", "",403          "## `scripts/enrich_stores.py` — logo / couverture / description", ""]404    hdr = module_header(ROOT / "scripts" / "enrich_stores.py")405    if hdr:406        md += [hdr, ""]407    md += [f"- Couverture actuelle : **logo {logo}/{tot}** ({pct(logo, tot)}), "408           f"**cover {cover}/{tot}** ({pct(cover, tot)}).",409           f"- Cache disque `data/enrich_cache/` : {n_cache} fichiers "410           "(page d'accueil analysée une seule fois ; Scrapfly en secours "411           "pour les 403).", "",412           "## `scripts/enrich_shipping.py` — politiques de livraison (vague 2)", ""]413    hdr = module_header(ROOT / "scripts" / "enrich_shipping.py")414    if hdr:415        md += [hdr, ""]416    md += [f"- Couverture actuelle : **shipping_info {ship}/{tot}** "417           f"({pct(ship, tot)}) — ciblé sur les boutiques productives "418           "(`product_count > 0`).",419           f"- Cache disque `data/enrich_cache/shipping/` : {n_ship} fichiers "420           "(échecs mémorisés pour ne pas re-marteler les sites).", ""]421    (OUT / "enrichissement-boutiques.md").write_text("\n".join(md) + "\n",422                                                     encoding="utf-8")423424425def write_index(con, registry, pstats) -> None:426    tot_prod, tot_stores = con.execute(427        "SELECT (SELECT COUNT(*) FROM products WHERE active=1),"428        " (SELECT COUNT(*) FROM stores WHERE enabled=1)").fetchone()429    md = ["# Fabri-Ka — Connecteurs (documentation standardisée)", "", GEN_NOTE, "",430          f"**{len(registry['stores'])} boutiques** au registre "431          f"(`data/stores.json`, généré le {registry.get('generated')}), "432          f"**{nfr(tot_stores)}** activées en base, **{nfr(tot_prod)} produits "433          "actifs**. Un connecteur par PLATEFORME e-commerce (dispatch "434          "`fabrika/connectors/__init__.py`), plus un transport de secours "435          "Scrapfly et deux pipelines transverses (découverte/registre, "436          "enrichissement).", "",437          "## Connecteurs-plateformes", "",438          "| Connecteur | Fiche | Boutiques (registre) | Avec produits | "439          "Produits actifs | Prix | Photos | Détails |",440          "|---|---|---|---|---|---|---|---|"]441    for key in GROUPS:442        g = group_stats(pstats, GROUPS[key])443        reg, en, prod = store_counts(con, registry, GROUPS[key])444        md.append(f"| {FICHES[key]['titre']} | [{key}]({key}.md) | {reg} | "445                  f"{prod} | {nfr(g['produits'])} | "446                  f"{pct(g['prix'], g['produits'])} | "447                  f"{pct(g['photos'], g['produits'])} | "448                  f"{pct(g['details'], g['produits'])} |")449    md += ["| Transport Scrapfly | [scrapfly-transport](scrapfly-transport.md) "450           "| — | — | — | — | — | — |",451           "| Ecwid (verdict : non couvert) | [ecwid](ecwid.md) | "452           f"{sum(1 for s in registry['stores'] if s.get('platform') == 'ecwid')} "453           "| 0 | 0 | — | — | — |", "",454           "Pipelines transverses : [découverte & registre]"455           "(decouverte-registre.md) · [enrichissement boutiques]"456           "(enrichissement-boutiques.md).", "",457           "## Top 15 boutiques par volume", "",458           "| Boutique | Domaine | Plateforme | Produits | Dernier sync |",459           "|---|---|---|---|---|"]460    for r in con.execute("SELECT id, name, platform, product_count, last_sync "461                         "FROM stores ORDER BY product_count DESC LIMIT 15"):462        md.append(f"| {r['name'] or r['id']} | `{r['id']}` | {r['platform']} | "463                  f"{nfr(r['product_count'])} | {fmt_ts(r['last_sync'])} |")464    md += ["", "## Gotchas transverses", "",465           "- **Shopify curl anti-TLS + verrou 0,7 s** : voir "466           "[shopify](shopify.md).",467           "- **Scrapfly `large_object`** : les grosses réponses arrivent en "468           "deux temps — voir [scrapfly-transport](scrapfly-transport.md).",469           "- **FTS par lots** (`fabrika/db.py`) : la purge/réinsertion de "470           "l'index `products_fts` se fait par lots de 500 uid (1 balayage "471           "par lot au lieu de N deletes unitaires).",472           "- **Verrou BD** (`fabrika/ingest.py`) : écritures sérialisées via "473           "`threading.Lock` — les syncs multi-boutiques sont parallèles côté "474           "réseau, séquentiels côté SQLite.", ""]475    (OUT / "INDEX.md").write_text("\n".join(md) + "\n", encoding="utf-8")476477478def main() -> None:479    OUT.mkdir(parents=True, exist_ok=True)480    for old in OUT.glob("*.md"):481        old.unlink()482    registry = load_registry()483    con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)484    con.row_factory = sqlite3.Row485    pstats = db_platform_stats(con)486    for key in GROUPS:487        write_platform(key, con, registry, pstats)488    write_scrapfly(con)489    write_ecwid(con, registry)490    write_discovery(registry)491    write_enrichment(con)492    write_index(con, registry, pstats)493    con.close()494    print(f"[gen_connector_docs] {len(GROUPS)} fiches plateformes + scrapfly + "495          f"ecwid + découverte + enrichissement + INDEX → {OUT}")496497498if __name__ == "__main__":499    main()500