#!/usr/bin/env python3 """Phase 2 — taxonomie métier réelle des boutiques Shopify via /collections.json. 1 requête par boutique Shopify productive (curl, throttle global 0,7 s) : la liste des collections (titres + handles + published) est la taxonomie métier déclarée par le marchand. On la mappe vers la taxonomie Fabri-Ka (infer_category sur les titres de collections) pour : - assigner des catégories boutique (registre + DB) quand il n'y en a pas ; - alimenter l'héritage de catégorie d'enrich_products.py (produits « autre ») ; - consigner la table de correspondance collection -> catégorie (data/collections_taxonomy.json, rejouable et auditable). WooCommerce : PAS de requête réseau — la Store API renvoie déjà les catégories par produit (champ product_type), l'endpoint /wc/store/v1/products/categories serait redondant (décision consignée). Cache : data/enrich_cache/collections/.json. Usage : .venv/bin/python scripts/enrich_collections.py [--cap 700] """ import argparse import concurrent.futures as cf import json import os import subprocess import sys import threading import time from collections import Counter ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) CACHE = os.path.join(ROOT, "data", "enrich_cache", "collections") os.makedirs(CACHE, exist_ok=True) from fabrika import db as fdb # noqa: E402 from fabrika.schema import infer_category # noqa: E402 UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/126 Safari/537.36") _LOCK = threading.Lock() _last = [0.0] # titres de collections sans signal métier NOISE = {"accueil", "home", "all", "tous", "tout", "frontpage", "nouveautés", "nouveautes", "new", "soldes", "sale", "promotions", "promo", "cadeaux", "gifts", "idées cadeaux", "best sellers", "meilleurs vendeurs"} def fetch_collections(dom: str, base: str) -> dict: cpath = os.path.join(CACHE, dom + ".json") if os.path.exists(cpath): return json.load(open(cpath)) with _LOCK: wait = 0.7 - (time.time() - _last[0]) if wait > 0: time.sleep(wait) _last[0] = time.time() rec = {"domain": dom, "collections": [], "checked_at": time.strftime("%Y-%m-%d")} for attempt in range(4): # retry 429 avec backoff long p = subprocess.run(["curl", "-sS", "--compressed", "-L", "--max-time", "25", "-A", UA, "-w", "\n%{http_code}", f"{base}/collections.json?limit=250"], capture_output=True, text=True, errors="replace") body, _, code = p.stdout.rpartition("\n") if code == "200": try: cols = json.loads(body).get("collections", []) rec["collections"] = [{"handle": c.get("handle", ""), "title": c.get("title", ""), "products_count": c.get("products_count")} for c in cols] rec.pop("error", None) except Exception as exc: rec["error"] = str(exc)[:120] break rec["error"] = f"HTTP {code}" if code != "429": break time.sleep(10 * (attempt + 1)) json.dump(rec, open(cpath, "w"), ensure_ascii=False) return rec def main(): ap = argparse.ArgumentParser() ap.add_argument("--cap", type=int, default=700) ap.add_argument("--retry-errors", action="store_true", help="purge du cache les fiches en erreur (ex. 429) avant la passe") args = ap.parse_args() if args.retry_errors: import glob n = 0 for f in glob.glob(os.path.join(CACHE, "*.json")): try: if json.load(open(f)).get("error"): os.remove(f); n += 1 except Exception: pass print(f"[collections] {n} caches en erreur purgés") con = fdb.connect() rows = [dict(r) for r in con.execute( "SELECT id, url FROM stores WHERE platform='shopify' AND product_count>0 " "ORDER BY product_count DESC LIMIT ?", (args.cap,))] con.close() print(f"[collections] {len(rows)} boutiques Shopify ciblées", flush=True) results, done = [], 0 with cf.ThreadPoolExecutor(4) as ex: for rec in ex.map(lambda s: fetch_collections(s["id"], (s["url"] or "").rstrip("/")), rows): results.append(rec) done += 1 if done % 100 == 0: print(f" {done}/{len(rows)}", flush=True) # table de correspondance collection -> catégorie Fabri-Ka (consignée) mapping = {} store_cats = {} n_cols = 0 for rec in results: cats = Counter() for c in rec.get("collections", []): title = (c.get("title") or "").strip() if not title or title.lower() in NOISE: continue n_cols += 1 cat = infer_category(title) mapping[title] = cat if cat != "autre": cats[cat] += max(1, int(c.get("products_count") or 1)) if cats: store_cats[rec["domain"]] = [k for k, _ in cats.most_common(3)] json.dump({"generated": time.strftime("%Y-%m-%d"), "note": "titres de collections Shopify -> taxonomie Fabri-Ka (infer_category)", "mapping": dict(sorted(mapping.items()))}, open(os.path.join(ROOT, "data", "collections_taxonomy.json"), "w"), ensure_ascii=False, indent=1) print(f"[collections] {n_cols} collections vues, {len(mapping)} mappées, " f"{len(store_cats)} boutiques avec catégories déduites") # registre + DB : catégories boutique si absentes reg_path = os.path.join(ROOT, "data", "stores.json") reg = json.load(open(reg_path)) n_reg = 0 con = fdb.connect() for s in reg["stores"]: cats = store_cats.get(s["id"]) if cats and not s.get("categories"): s["categories"] = cats con.execute("UPDATE stores SET categories=? WHERE id=?", (json.dumps(cats, ensure_ascii=False), s["id"])) n_reg += 1 con.commit(); con.close() json.dump(reg, open(reg_path, "w"), ensure_ascii=False, indent=1) print(f"[collections] catégories boutique assignées (registre+DB) : {n_reg}") if __name__ == "__main__": main()