Agrégateur de produits québécois — www.fabri-ka.com
Python 38.4%
HTML 30.3%
TypeScript 17.2%
CSS 11%
JavaScript 3.2%
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