#!/usr/bin/env python3 # ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois # 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 connecteur-plateforme # (shopify, woocommerce, wix, squarespace, square, generic, # scrapfly-transport, ecwid/verdict) + une fiche « découverte & registre » # (data/stores.json) + une fiche « enrichissement boutiques » # (enrich_stores / enrich_shipping), en croisant : # 1. le registre : data/stores.json (3 224 boutiques) # 2. l'introspection : fabrika/connectors/* + scripts d'enrichissement # 3. la base vivante : data/fabrika.db (stores, products, sync_log) # # REJOUABLE : ré-exécuter le script régénère tout docs/connecteurs/. # Usage : python3 scripts/gen_connector_docs.py # ----------------------------------------------------------------------------- from __future__ import annotations import collections import datetime import json import re import sqlite3 import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) DATA = ROOT / "data" OUT = ROOT / "docs" / "connecteurs" DB = DATA / "fabrika.db" CONN_DIR = ROOT / "fabrika" / "connectors" NOW = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") GEN_NOTE = ("*Généré le {} par `scripts/gen_connector_docs.py` — fichier " "produit automatiquement, ne pas éditer à la main.*".format(NOW)) # plateformes BD regroupées par connecteur GROUPS: dict[str, list[str]] = { "shopify": ["shopify"], "woocommerce": ["woocommerce", "wordpress"], "wix": ["wix"], "squarespace": ["squarespace"], "square": ["square"], "votresite": ["votresite"], "generic": ["generic", "prestashop", "magento", "bigcommerce", "lightspeed", "snipcart"], } FICHES = { "shopify": dict( titre="Connecteur Shopify", module="shopify.py", endpoint="`GET /products.json?limit=250&page=N` — catalogue JSON public " "de chaque boutique (aucune clé requise).", gotchas=[ "**Transport curl anti-TLS** : l'empreinte TLS de python-requests " "déclenche le 429 de Shopify sous volume ; les GET passent par " "`curl -sS --compressed` en sous-processus.", "**Verrou global 0,7 s** (`_MIN_INTERVAL = 0.7` + lock inter-threads) " "entre deux requêtes Shopify, avec retry — throttle poli à l'échelle " "du procédé, pas par boutique.", "Les variantes/prix/images viennent du même JSON ; `details` stocke " "variants/options/published_at.", ]), "woocommerce": dict( titre="Connecteur WooCommerce (Store API)", module="woocommerce.py", endpoint="`GET /wp-json/wc/store/v1/products?per_page=100&page=N` — " "Store API publique (sert aussi les sites `wordpress` avec " "Store API active).", gotchas=[ "Alias `wordpress` : les sites WordPress dont la Store API répond " "sont routés vers ce connecteur (voir `PLATFORM_CONNECTORS`).", "`details` porte attributes/variations, poids et dimensions " "formatés quand la boutique les publie.", ]), "wix": dict( titre="Connecteur Wix Stores", module="wix.py", endpoint="`GET /_api/v1/access-tokens` (jeton d'instance public de " "l'app Wix Stores) puis GraphQL storefront " "`getFilteredProducts` (catalogue complet, paginé).", gotchas=[ "Le jeton est public mais par site : il faut le ré-extraire à " "chaque sync (pas de clé persistante).", ]), "squarespace": dict( titre="Connecteur Squarespace", module="squarespace.py", endpoint="`GET /shop|/boutique|/store?format=json` — rendu JSON natif " "des pages boutique Squarespace, pagination par collection.", gotchas=[ "L'URL de la page boutique varie (`/shop`, `/boutique`, `/store`…) : " "le sondage essaie les slugs usuels et mémorise le bon endpoint.", ]), "square": dict( titre="Connecteur Square Online", module="square.py", endpoint="IDs `user_id`/`site_id` extraits du HTML de la page d'accueil " "→ `GET /app/store/api/v13/editor/users/{user}/sites/{site}/" "store-pages/…/products` (API storefront publique).", gotchas=[ "Connecteur ajouté en **vague 2** (commit `778d2b6`) — a rendu " "connectables les boutiques Square jusque-là à 0 produit.", "Deux requêtes minimum par boutique (HTML d'accueil + API).", ]), "votresite": dict( titre="Connecteur Votresite.ca (Drupal + OpenCart)", module="votresite.py", endpoint="Boutique OpenCart montée sous `/boutique` (parfois `/produits`, " "`/shop`) : inventaire par `/sitemap.xml` sinon crawl des " "catégories `/fr` (`?limit=100&page=N`), puis fiche par " "fiche `…/-p/` (rendu 100 % serveur, aucune API JSON).", gotchas=[ "**Plateforme québécoise** (scripts.votresite.ca) : vitrine Drupal 8 " "géré (thème `owebo-votresite`) + boutique OpenCart en sous-répertoire — " "le montage est détecté via le registre, la page d'accueil, puis " "`/boutique`/`/produits`.", "**Prix : le HTML d'abord, les meta en secours** — en solde, " "`twitter:data1` affiche le prix RÉGULIER ; le prix courant est le " "`

…$

` de la fiche et le prix barré le `` " "`text-decoration: line-through` (→ `compare_at_price`).", "**Titre = dernier `

` sans ancre** : le premier `

` est le " "logo du site (un lien) sur plusieurs thèmes ; `og:title` peut être " "du bourrage de mots-clés (ex. lemieldabee.ca).", "**Dédup par ID produit** : le même `p` apparaît sous plusieurs " "chemins de catégorie (`-p361c37c45c44`).", "Les URLs d'images OpenCart contiennent espaces/accents bruts " "(`image/cache/catalog/…`) — encodées avant stockage.", ]), "generic": dict( titre="Connecteur générique (JSON-LD / microdata / OG)", module="generic.py", endpoint="Sitemap → pages produit → extraction du balisage produit " "(JSON-LD, microdata, Open Graph). Sert PrestaShop, Magento, " "BigCommerce, Lightspeed, Snipcart et les sites custom " "(endpoint sentinelle `__generic__`).", gotchas=[ "Scrapfly/Firecrawl en secours anti-bot quand l'accès direct " "échoue (403/429).", "Rendu 100 % client (Ecwid) non couvert — voir la fiche " "[verdict Ecwid](ecwid.md).", ]), } ECWID_VERDICT = """# Ecwid — verdict : NON COUVERT {gen} ## Verdict **Ecwid n'est pas connectable** en l'état (décision documentée, README) : - Le storefront Ecwid est **100 % client-side** : aucun rendu serveur du catalogue, ni sur les *instant sites* ni via le plugin WordPress — le connecteur générique (JSON-LD/microdata) ne voit rien. - L'**API REST v3 exige un token secret** par boutique ; le token `pub…` présent dans `script.js` est générique et refusé (**403**). - Conséquence : les boutiques Ecwid restent au registre (`enabled=0`, plateforme détectée) en attente d'une éventuelle voie d'accès. ## Boutiques Ecwid recensées ({n}) | Boutique | Domaine | Statut registre | |---|---|---| {rows} """ # --- helpers ------------------------------------------------------------------ def module_header(path: Path) -> str: try: text = path.read_text(encoding="utf-8") except OSError: return "" lines = [] for line in text.splitlines(): if line.startswith("#!"): continue if line.startswith("#"): s = line.lstrip("#").rstrip() if s.startswith(" "): s = s[1:] if set(s) <= {"-", " "}: continue lines.append(s) elif lines: break elif line.strip(): break if not lines: m = re.match(r'\s*(?:"""|\'\'\')(.*?)(?:"""|\'\'\')', text, re.S) if m: lines = [ln.rstrip() for ln in m.group(1).strip().splitlines()] return "\n".join("> " + (ln or "") for ln in lines) def pct(part, whole) -> str: if not whole: return "—" return f"{100.0 * (part or 0) / whole:.1f} %" def nfr(n) -> str: return f"{n:,}".replace(",", " ") if isinstance(n, (int, float)) else str(n) def fmt_ts(ts) -> str: if not ts: return "—" return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") # --- collecte ------------------------------------------------------------------- def load_registry() -> dict: return json.loads((DATA / "stores.json").read_text(encoding="utf-8")) def db_platform_stats(con) -> dict: """Agrégats produits par plateforme BD (une passe).""" rows = con.execute(""" SELECT s.platform AS plat, COUNT(*) AS produits, SUM(p.price IS NOT NULL) AS prix, SUM(p.images IS NOT NULL AND p.images NOT IN ('','[]')) AS photos, SUM(p.details IS NOT NULL) AS details, SUM(p.details LIKE '%average_rating%' OR p.details LIKE '%review_count%') AS rating, SUM(p.details LIKE '%weight%') AS poids, SUM(p.description IS NOT NULL AND p.description<>'') AS descr FROM products p JOIN stores s ON s.id = p.store_id WHERE p.active = 1 GROUP BY s.platform""").fetchall() return {r["plat"]: dict(r) for r in rows} def group_stats(pstats: dict, plats: list[str]) -> dict: keys = ("produits", "prix", "photos", "details", "rating", "poids", "descr") out = {k: 0 for k in keys} for p in plats: s = pstats.get(p) if s: for k in keys: out[k] += s[k] or 0 return out def volumetrie_md(g: dict) -> str: n = g["produits"] or 0 rows = [ ("Produits actifs", nfr(n)), ("Prix", pct(g["prix"], n)), ("Photos", pct(g["photos"], n)), ("Description", pct(g["descr"], n)), ("Détails (variantes/attributs)", pct(g["details"], n)), ("— dont avis (rating/review_count)", pct(g["rating"], n)), ("— dont poids/dimensions", pct(g["poids"], n)), ] return "| Indicateur | Valeur |\n|---|---|\n" + \ "\n".join(f"| {k} | {v} |" for k, v in rows) def top_stores(con, plats: list[str], limit=5): q = ",".join("?" * len(plats)) return con.execute( f"SELECT id, name, product_count, last_sync, last_status FROM stores " f"WHERE platform IN ({q}) AND product_count > 0 " f"ORDER BY product_count DESC LIMIT {limit}", plats).fetchall() def store_counts(con, registry, plats: list[str]) -> tuple[int, int, int]: reg = sum(1 for s in registry["stores"] if (s.get("platform") or "") in plats) q = ",".join("?" * len(plats)) en, prod = con.execute( f"SELECT SUM(enabled=1), SUM(product_count>0) FROM stores " f"WHERE platform IN ({q})", plats).fetchone() return reg, en or 0, prod or 0 # --- fiches plateforme ------------------------------------------------------------ def write_platform(key: str, con, registry, pstats) -> None: f = FICHES[key] plats = GROUPS[key] g = group_stats(pstats, plats) reg, en, prod = store_counts(con, registry, plats) md = [f"# {f['titre']} (`{key}`)", "", GEN_NOTE, "", "## Mécanique / endpoint", "", f"- {f['endpoint']}", f"- Module : `fabrika/connectors/{f['module']}`", f"- Plateformes BD routées ici : " + ", ".join(f"`{p}`" for p in plats), ""] hdr = module_header(CONN_DIR / f["module"]) if hdr: md += ["**En-tête du module :**", "", hdr, ""] md += ["## Boutiques rattachées", "", f"- Registre `data/stores.json` : **{reg}** boutiques", f"- En base (table `stores`) : **{en}** activées, **{prod}** avec produits", "", "**Top 5 par volume :**", "", "| Boutique | Domaine | Produits | Dernier sync | Statut |", "|---|---|---|---|---|"] for r in top_stores(con, plats): md.append(f"| {r['name'] or r['id']} | `{r['id']}` | " f"{nfr(r['product_count'])} | {fmt_ts(r['last_sync'])} | " f"{r['last_status'] or '—'} |") md += ["", "## Volumétrie & complétude (BD live)", "", volumetrie_md(g), "", "## Gotchas", ""] md += [f"- {gtc}" for gtc in f["gotchas"]] md += ["", "Voir aussi : [INDEX](INDEX.md) · " "[transport Scrapfly](scrapfly-transport.md) · " "[découverte & registre](decouverte-registre.md)."] (OUT / f"{key}.md").write_text("\n".join(md) + "\n", encoding="utf-8") def write_scrapfly(con) -> None: md = ["# Transport de secours Scrapfly (`scrapfly-transport`)", "", GEN_NOTE, "", "## Mécanique", "", "- `POST` → `https://api.scrapfly.io/scrape` avec `asp=true` " "(anti-bot) et `country=ca`, `render_js` optionnel.", "- Employé **en dernier recours** quand l'accès direct échoue " "(403/429/HTML au lieu de JSON) ; chaque appel consomme des crédits " "et les échecs définitifs sont mémorisés.", "- Clé : variable d'environnement `SCRAPFLY_API_KEY` (`.env`).", "- Throttle interne : verrou global, 0,5 s minimum entre appels.", ""] hdr = module_header(CONN_DIR / "scrapfly.py") if hdr: md += ["**En-tête du module :**", "", hdr, ""] md += ["## Gotchas", "", "- **`large_object`** : au-delà d'une certaine taille, Scrapfly ne " "renvoie pas le contenu mais une URL " "`https://api.scrapfly.io/scrape/large_object/…` qu'il faut suivre " "(2e GET, avec la clé en paramètre) pour obtenir le corps réel — " "géré depuis le commit `4c85b1f` (repli Scrapfly robuste).", "- Utilisé aussi par les scripts d'enrichissement " "(`enrich_stores.py`) pour les pages d'accueil en 403.", ""] (OUT / "scrapfly-transport.md").write_text("\n".join(md) + "\n", encoding="utf-8") def write_ecwid(con, registry) -> None: stores = [s for s in registry["stores"] if s.get("platform") == "ecwid"] rows = "\n".join( "| {} | `{}` | {} |".format( s.get("name") or s["id"], s["id"], "activée" if s.get("enabled") else "désactivée (pas d'endpoint)") for s in sorted(stores, key=lambda x: x["id"])) (OUT / "ecwid.md").write_text( ECWID_VERDICT.format(gen=GEN_NOTE, n=len(stores), rows=rows), encoding="utf-8") def write_discovery(registry) -> None: ss = registry["stores"] oc = collections.Counter(s.get("origin_class") for s in ss) plat = collections.Counter((s.get("platform") or "(vide)") for s in ss) src = collections.Counter() for s in ss: src.update(s.get("discovery_sources") or []) status = collections.Counter(s.get("status") for s in ss) md = ["# Découverte & registre (`data/stores.json`)", "", GEN_NOTE, "", f"Registre généré le **{registry.get('generated')}** — " f"**{registry.get('count')} boutiques** (champ `count`), toutes avec " "id (domaine canonique), plateforme, endpoint catalogue, classe " "d'origine, preuves et sources de découverte.", "", "## Classes d'origine (`origin_class`)", "", "| Classe | Boutiques | Signification |", "|---|---|---|"] signif = {"A": "fabrication/production au Québec attestée", "B": "transformation/assemblage au Québec", "C": "marque québécoise (fabrication partielle ou incertaine)", "D": "revendeur/distributeur québécois", "E": "à requalifier / preuve faible"} for k in sorted(oc): md.append(f"| {k} | {oc[k]} | {signif.get(k, '—')} |") md += ["", "Confiance : `origin_confidence` (0-1) + `origin_evidence` " "(texte de preuve, annuaire ou mention sur le site).", "", "## Annuaires & sources de découverte (top 15)", "", "| Source | Boutiques |", "|---|---|"] for name, n in src.most_common(15): md.append(f"| `{name}` | {n} |") md += ["", f"Statuts de vérification : " + ", ".join(f"`{k}` : {v}" for k, v in status.most_common()), "", "## Plateformes détectées au registre", "", "| Plateforme | Boutiques |", "|---|---|"] for p, n in plat.most_common(): md.append(f"| `{p}` | {n} |") md += ["", "## Re-sondage (`scripts/reprobe_stores.py`)", ""] hdr = module_header(ROOT / "scripts" / "reprobe_stores.py") if hdr: md += [hdr, ""] md += ["Le re-sondage est **additif** : il réactive des boutiques à " "0 produit (migrations de plateforme, Square Online devenu " "connectable en vague 2) sans jamais toucher aux boutiques déjà " "actives. Il met à jour `stores.json`, `data/verify_cache/`, " "`data/enriched/verified.jsonl` et la table `stores`.", ""] (OUT / "decouverte-registre.md").write_text("\n".join(md) + "\n", encoding="utf-8") def write_enrichment(con) -> None: logo, cover, ship, tot = con.execute( "SELECT SUM(logo_url IS NOT NULL AND logo_url<>'')," " SUM(cover_url IS NOT NULL AND cover_url<>'')," " SUM(shipping_info IS NOT NULL AND shipping_info<>'')," " COUNT(*) FROM stores").fetchone() n_cache = len(list((DATA / "enrich_cache").glob("*.json"))) ship_dir = DATA / "enrich_cache" / "shipping" n_ship = len(list(ship_dir.glob("*.json"))) if ship_dir.exists() else 0 md = ["# Enrichissement des boutiques", "", GEN_NOTE, "", "Deux scripts rejouables complètent la table `stores` (colonnes " "additives `logo_url`, `cover_url`, `description_meta`, " "`shipping_info`).", "", "## `scripts/enrich_stores.py` — logo / couverture / description", ""] hdr = module_header(ROOT / "scripts" / "enrich_stores.py") if hdr: md += [hdr, ""] md += [f"- Couverture actuelle : **logo {logo}/{tot}** ({pct(logo, tot)}), " f"**cover {cover}/{tot}** ({pct(cover, tot)}).", f"- Cache disque `data/enrich_cache/` : {n_cache} fichiers " "(page d'accueil analysée une seule fois ; Scrapfly en secours " "pour les 403).", "", "## `scripts/enrich_shipping.py` — politiques de livraison (vague 2)", ""] hdr = module_header(ROOT / "scripts" / "enrich_shipping.py") if hdr: md += [hdr, ""] md += [f"- Couverture actuelle : **shipping_info {ship}/{tot}** " f"({pct(ship, tot)}) — ciblé sur les boutiques productives " "(`product_count > 0`).", f"- Cache disque `data/enrich_cache/shipping/` : {n_ship} fichiers " "(échecs mémorisés pour ne pas re-marteler les sites).", ""] (OUT / "enrichissement-boutiques.md").write_text("\n".join(md) + "\n", encoding="utf-8") def write_index(con, registry, pstats) -> None: tot_prod, tot_stores = con.execute( "SELECT (SELECT COUNT(*) FROM products WHERE active=1)," " (SELECT COUNT(*) FROM stores WHERE enabled=1)").fetchone() md = ["# Fabri-Ka — Connecteurs (documentation standardisée)", "", GEN_NOTE, "", f"**{len(registry['stores'])} boutiques** au registre " f"(`data/stores.json`, généré le {registry.get('generated')}), " f"**{nfr(tot_stores)}** activées en base, **{nfr(tot_prod)} produits " "actifs**. Un connecteur par PLATEFORME e-commerce (dispatch " "`fabrika/connectors/__init__.py`), plus un transport de secours " "Scrapfly et deux pipelines transverses (découverte/registre, " "enrichissement).", "", "## Connecteurs-plateformes", "", "| Connecteur | Fiche | Boutiques (registre) | Avec produits | " "Produits actifs | Prix | Photos | Détails |", "|---|---|---|---|---|---|---|---|"] for key in GROUPS: g = group_stats(pstats, GROUPS[key]) reg, en, prod = store_counts(con, registry, GROUPS[key]) md.append(f"| {FICHES[key]['titre']} | [{key}]({key}.md) | {reg} | " f"{prod} | {nfr(g['produits'])} | " f"{pct(g['prix'], g['produits'])} | " f"{pct(g['photos'], g['produits'])} | " f"{pct(g['details'], g['produits'])} |") md += ["| Transport Scrapfly | [scrapfly-transport](scrapfly-transport.md) " "| — | — | — | — | — | — |", "| Ecwid (verdict : non couvert) | [ecwid](ecwid.md) | " f"{sum(1 for s in registry['stores'] if s.get('platform') == 'ecwid')} " "| 0 | 0 | — | — | — |", "", "Pipelines transverses : [découverte & registre]" "(decouverte-registre.md) · [enrichissement boutiques]" "(enrichissement-boutiques.md).", "", "## Top 15 boutiques par volume", "", "| Boutique | Domaine | Plateforme | Produits | Dernier sync |", "|---|---|---|---|---|"] for r in con.execute("SELECT id, name, platform, product_count, last_sync " "FROM stores ORDER BY product_count DESC LIMIT 15"): md.append(f"| {r['name'] or r['id']} | `{r['id']}` | {r['platform']} | " f"{nfr(r['product_count'])} | {fmt_ts(r['last_sync'])} |") md += ["", "## Gotchas transverses", "", "- **Shopify curl anti-TLS + verrou 0,7 s** : voir " "[shopify](shopify.md).", "- **Scrapfly `large_object`** : les grosses réponses arrivent en " "deux temps — voir [scrapfly-transport](scrapfly-transport.md).", "- **FTS par lots** (`fabrika/db.py`) : la purge/réinsertion de " "l'index `products_fts` se fait par lots de 500 uid (1 balayage " "par lot au lieu de N deletes unitaires).", "- **Verrou BD** (`fabrika/ingest.py`) : écritures sérialisées via " "`threading.Lock` — les syncs multi-boutiques sont parallèles côté " "réseau, séquentiels côté SQLite.", ""] (OUT / "INDEX.md").write_text("\n".join(md) + "\n", encoding="utf-8") def main() -> None: OUT.mkdir(parents=True, exist_ok=True) for old in OUT.glob("*.md"): old.unlink() registry = load_registry() con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True) con.row_factory = sqlite3.Row pstats = db_platform_stats(con) for key in GROUPS: write_platform(key, con, registry, pstats) write_scrapfly(con) write_ecwid(con, registry) write_discovery(registry) write_enrichment(con) write_index(con, registry, pstats) con.close() print(f"[gen_connector_docs] {len(GROUPS)} fiches plateformes + scrapfly + " f"ecwid + découverte + enrichissement + INDEX → {OUT}") if __name__ == "__main__": main()