#!/usr/bin/env python3 """Vague 2 — re-sondage des boutiques à 0 produit (enabled=0 ou plateforme vide). Beaucoup de boutiques ont migré de plateforme depuis la découverte initiale (ex. WordPress vitrine -> Shopify, Wix -> WooCommerce), et les boutiques Square Online sont maintenant connectables (connecteur square.py, vague 2). Pour chaque cible : 1. sondes légères d'endpoints catalogue : Shopify /products.json?limit=1 (curl, throttle 0,7 s global) WooCommerce /wp-json/wc/store/v1/products Wix Stores /_api/v1/access-tokens (app Stores) Squarespace /shop|/boutique|/store ?format=json Square Online user_id/site_id du HTML + /app/store/api/v13 (total>0) 2. sinon, détection de plateforme via le HTML d'accueil (signatures/generator) — plateforme notée mais boutique laissée désactivée (pas d'endpoint). Met à jour : data/stores.json (platform/catalog_endpoint/enabled/ecommerce), data/verify_cache/.json, data/enriched/verified.jsonl (patch des domaines touchés) et la table stores (upsert). Additif : ne touche jamais aux boutiques déjà actives. Usage : python3 scripts/reprobe_stores.py [--cap 400] [--dry-run] """ import argparse import concurrent.futures as cf import json import os import subprocess import sys import threading import time import requests ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) sys.path.insert(0, os.path.join(ROOT, "scripts")) CACHE = os.path.join(ROOT, "data", "verify_cache") os.makedirs(CACHE, exist_ok=True) for line in open(os.path.join(ROOT, ".env")).read().splitlines(): if "=" in line and not line.startswith("#"): k, _, v = line.partition("=") os.environ.setdefault(k.strip(), v.strip()) from verify import detect_platform, HDRS # noqa: E402 from fabrika import db as fdb # noqa: E402 from fabrika.connectors.square import extract_ids, API_PATH as SQUARE_API # noqa: E402 WIX_STORES_APP = "1380b703-ce81-ff05-f115-39571d94dfcd" # throttle global Shopify (même règle que le connecteur : 0,7 s entre requêtes) _SHOPIFY_LOCK = threading.Lock() _last_shopify = [0.0] STEP_DELAY = 0.5 # politesse entre sondes sur un même domaine def curl_get(url, timeout=15): p = subprocess.run(["curl", "-sS", "-L", "--compressed", "--max-time", str(timeout), "-A", HDRS["User-Agent"].split(" FabriKaBot")[0], "-w", "\n%{http_code}", url], capture_output=True, text=True, errors="replace") body, _, code = p.stdout.rpartition("\n") return (int(code) if code.isdigit() else 0), body def probe_shopify(base): with _SHOPIFY_LOCK: wait = 0.7 - (time.time() - _last_shopify[0]) if wait > 0: time.sleep(wait) _last_shopify[0] = time.time() code, body = curl_get(f"{base}/products.json?limit=1") if code == 200 and body.lstrip().startswith("{") and '"products"' in body[:200]: return "shopify", "/products.json" return None def probe_woo(base): try: r = requests.get(f"{base}/wp-json/wc/store/v1/products?per_page=1", headers=HDRS, timeout=12) if r.status_code == 200 and r.text.strip().startswith("["): return "woocommerce", "/wp-json/wc/store/v1/products" except Exception: pass return None def probe_wix(base): try: r = requests.get(f"{base}/_api/v1/access-tokens", headers=HDRS, timeout=12, allow_redirects=True) if r.status_code == 200 and WIX_STORES_APP in (r.text or ""): return "wix", "/_api/wix-ecommerce-storefront-web/api" except Exception: pass return None def probe_squarespace(base): for path in ("/shop", "/boutique", "/store"): try: r = requests.get(f"{base}{path}?format=json-pretty", headers=HDRS, timeout=10) if r.status_code == 200 and '"items"' in r.text[:5000]: return "squarespace", path + "?format=json" except Exception: pass return None def probe_square(base, html): """html d'accueil déjà téléchargé (peut être vide -> re-fetch).""" if not html: try: r = requests.get(base, headers=HDRS, timeout=15, allow_redirects=True) html = r.text if r.status_code == 200 else "" except Exception: return None user, site = extract_ids(html or "") if not (user and site): return None try: r = requests.get(f"{base}{SQUARE_API}/editor/users/{user}/sites/{site}" f"/products?page=1&per_page=1", headers=HDRS, timeout=15) if r.status_code == 200: data = r.json() total = ((data.get("meta") or {}).get("pagination") or {}).get("total", 0) if total and int(total) > 0: return "square", SQUARE_API except Exception: pass return None def probe_store(store): """Retourne (store_id, platform, endpoint, detected_platform) — endpoint None si rien.""" sid = store["id"] base = (store.get("url") or f"https://{sid}").rstrip("/") homepage = "" try: r = requests.get(base, headers=HDRS, timeout=15, allow_redirects=True) if r.status_code == 200: homepage = r.text except Exception: pass hint = (store.get("platform") or "").lower() detected = detect_platform(homepage[:400000], {}) if homepage else None # ordre des sondes : plateforme connue/détectée d'abord, puis le reste order = ["shopify", "woocommerce", "wix", "squarespace", "square"] for pref in (hint, detected): if pref in order: order.remove(pref) order.insert(0, pref) probes = {"shopify": lambda: probe_shopify(base), "woocommerce": lambda: probe_woo(base), "wix": lambda: probe_wix(base), "squarespace": lambda: probe_squarespace(base), "square": lambda: probe_square(base, homepage)} for i, name in enumerate(order): if i: time.sleep(STEP_DELAY) res = probes[name]() if res: return sid, res[0], res[1], detected return sid, None, None, detected def main(): ap = argparse.ArgumentParser() ap.add_argument("--cap", type=int, default=400) ap.add_argument("--skip", type=int, default=0, help="saute les N premières cibles (déjà sondées par une vague précédente)") ap.add_argument("--skip-stamped", action="store_true", help="ignore les boutiques portant déjà un last_reprobe") ap.add_argument("--only-file", default=None, help="restreint les cibles aux ids listés dans ce fichier (un par ligne)") ap.add_argument("--workers", type=int, default=8) ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() reg_path = os.path.join(ROOT, "data", "stores.json") reg = json.load(open(reg_path)) by_id = {s["id"]: s for s in reg["stores"]} targets = [s for s in reg["stores"] if not s.get("enabled", True) or not s.get("platform")] # faux positifs de découverte avérés (origin_confidence 0, ex. entreprises de # services sans boutique) : ne jamais les réactiver par simple sonde d'endpoint targets = [s for s in targets if (s.get("origin_confidence") or 0) > 0] if args.skip_stamped: targets = [s for s in targets if not s.get("last_reprobe")] if args.only_file: only = {line.strip() for line in open(args.only_file) if line.strip()} targets = [s for s in targets if s["id"] in only] # priorité : Square (nouveau connecteur), puis origin_class A, B, … def key(s): plat_rank = 0 if (s.get("platform") or "") == "square" else 1 return (plat_rank, s.get("origin_class") or "E", s["id"]) targets.sort(key=key) targets = targets[args.skip:args.skip + args.cap] print(f"[reprobe] {len(targets)} boutiques ciblées (skip {args.skip}, cap {args.cap})", flush=True) reactivated, platform_notes = [], [] with cf.ThreadPoolExecutor(args.workers) as ex: for sid, plat, ep, detected in ex.map(probe_store, targets): if ep: reactivated.append((sid, plat, ep)) print(f" + {sid} -> {plat} {ep}", flush=True) elif detected and detected != (by_id[sid].get("platform") or ""): platform_notes.append((sid, detected)) if args.dry_run: print(f"[dry-run] réactivables: {len(reactivated)}, plateformes corrigées: {len(platform_notes)}") return # 1) stores.json stamp = time.strftime("%Y-%m-%d") for s in targets: # trace de sondage (pour les vagues suivantes) by_id[s["id"]]["last_reprobe"] = stamp touched = set() for sid, plat, ep in reactivated: s = by_id[sid] s["platform"], s["catalog_endpoint"] = plat, ep s["enabled"], s["ecommerce"] = True, True touched.add(sid) for sid, detected in platform_notes: by_id[sid]["platform"] = detected # info seulement, reste désactivée touched.add(sid) json.dump(reg, open(reg_path, "w"), ensure_ascii=False, indent=1) # 2) verify_cache (durabilité pour les rebuilds du registre) for sid in touched: s = by_id[sid] cpath = os.path.join(CACHE, sid + ".json") try: rec = json.load(open(cpath)) except Exception: rec = {"domain": sid, "active": True, "final_domain": sid} rec["platform"] = s.get("platform") or rec.get("platform") rec["catalog_endpoint"] = s.get("catalog_endpoint") or rec.get("catalog_endpoint") rec["active"] = True json.dump(rec, open(cpath, "w")) # 3) verified.jsonl — patch des domaines touchés vpath = os.path.join(ROOT, "data", "enriched", "verified.jsonl") if os.path.exists(vpath): lines = [] for line in open(vpath): try: rec = json.loads(line) except Exception: lines.append(line.rstrip("\n")) continue if rec.get("domain") in touched or rec.get("final_domain") in touched: sid = rec.get("final_domain") or rec["domain"] if sid in by_id: rec["platform"] = by_id[sid].get("platform") or rec.get("platform") rec["catalog_endpoint"] = (by_id[sid].get("catalog_endpoint") or rec.get("catalog_endpoint")) rec["active"] = True lines.append(json.dumps(rec, ensure_ascii=False)) with open(vpath, "w") as f: f.write("\n".join(lines) + "\n") # 4) base SQLite con = fdb.connect() for sid in touched: fdb.upsert_store(con, by_id[sid]) con.commit(); con.close() per_plat = {} for _, plat, _ in reactivated: per_plat[plat] = per_plat.get(plat, 0) + 1 print(f"[reprobe] réactivées: {len(reactivated)} {json.dumps(per_plat, ensure_ascii=False)}" f" | plateformes corrigées (sans endpoint): {len(platform_notes)}") if reactivated: print("[reprobe] à synchroniser : python run.py sync " + " ".join(sid for sid, _, _ in reactivated[:50]) + (" …" if len(reactivated) > 50 else "")) if __name__ == "__main__": main()