#!/usr/bin/env python3 """Phase 2 — coordonnées boutiques : courriel, téléphone, réseaux sociaux. Pour chaque boutique productive sans courriel : 1. page d'accueil : mailto:, JSON-LD Organization/LocalBusiness (email, telephone, sameAs), liens Instagram/Facebook du HTML ; 2. sinon une page contact usuelle (/contact, /pages/contact, /nous-joindre, /contactez-nous, /pages/nous-joindre, /a-propos). Max 2 requêtes réseau par boutique, throttle 0,5 s par worker (Shopify : verrou global 0,7 s comme le connecteur). Cache disque data/enrich_cache/contacts/.json (échecs inclus). Écrit : stores.email, stores.phone (si vide) ; réseaux fusionnés dans le registre (socials) sans doublon. Usage : .venv/bin/python scripts/enrich_contacts.py [--cap 400] [--workers 6] """ import argparse import concurrent.futures as cf import html as _html import json import os import re import sys import threading import time import requests ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) CACHE = os.path.join(ROOT, "data", "enrich_cache", "contacts") os.makedirs(CACHE, exist_ok=True) from fabrika import db as fdb # noqa: E402 HDRS = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} CONTACT_PATHS = ["/pages/contact", "/contact", "/nous-joindre", "/contactez-nous", "/pages/nous-joindre", "/pages/contactez-nous", "/contact-us"] EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") BAD_EMAIL = re.compile(r"(sentry|example|wixpress|\.png|\.jpe?g|\.gif|\.webp|\.svg" r"|@2x|@3x|schema\.org|sentry-next|\.js$|\.css$|no-?reply)", re.I) MAILTO_RE = re.compile(r'mailto:([^"\'?>\s]+)', re.I) PHONE_RE = re.compile(r"(?:\+1[ .-]?)?\(?\b([2-9]\d{2})\)?[ .-]?(\d{3})[ .-]?(\d{4})\b") SOCIAL_RE = re.compile(r'https?://(?:www\.)?(?:instagram\.com|facebook\.com)/[A-Za-z0-9_.\-/%]+', re.I) LD_RE = re.compile(r']+application/ld\+json[^>]*>(.*?)', re.I | re.S) _SHOPIFY_LOCK = threading.Lock() _last_shopify = [0.0] def fetch(url, shopify=False, timeout=15): if shopify: with _SHOPIFY_LOCK: wait = 0.7 - (time.time() - _last_shopify[0]) if wait > 0: time.sleep(wait) _last_shopify[0] = time.time() try: r = requests.get(url, headers=HDRS, timeout=timeout, allow_redirects=True) if r.status_code == 200 and len(r.text) > 200: return r.text except Exception: pass return None def harvest(html_text: str, rec: dict) -> None: """Extrait courriel / téléphone / réseaux d'un HTML (JSON-LD d'abord).""" for m in LD_RE.finditer(html_text): try: data = json.loads(m.group(1).strip()) except Exception: continue stack = data if isinstance(data, list) else [data] while stack: node = stack.pop() if isinstance(node, list): stack.extend(node) continue if not isinstance(node, dict): continue stack.extend(v for v in node.values() if isinstance(v, (dict, list))) if not rec.get("email") and isinstance(node.get("email"), str): e = node["email"].replace("mailto:", "").strip() if EMAIL_RE.fullmatch(e) and not BAD_EMAIL.search(e): rec["email"] = e if not rec.get("phone") and isinstance(node.get("telephone"), str): rec["phone"] = node["telephone"].strip()[:30] for u in (node.get("sameAs") or []) if isinstance(node.get("sameAs"), list) else []: if isinstance(u, str) and SOCIAL_RE.match(u): rec.setdefault("socials", []).append(u.rstrip("/")) if not rec.get("email"): m = MAILTO_RE.search(html_text) if m: e = _html.unescape(m.group(1)).strip() if EMAIL_RE.fullmatch(e) and not BAD_EMAIL.search(e): rec["email"] = e if not rec.get("email"): for e in EMAIL_RE.findall(html_text[:200000]): if not BAD_EMAIL.search(e): rec["email"] = e break if not rec.get("phone"): m = PHONE_RE.search(re.sub(r"<[^>]+>", " ", html_text[:150000])) if m: rec["phone"] = f"{m.group(1)} {m.group(2)}-{m.group(3)}" for u in SOCIAL_RE.findall(html_text[:200000]): if "/sharer" in u or "/share?" in u or "/plugins" in u: continue rec.setdefault("socials", []).append(u.rstrip("/")) def work(store): dom = store["id"] cpath = os.path.join(CACHE, dom + ".json") if os.path.exists(cpath): return json.load(open(cpath)) base = (store["url"] or f"https://{dom}").rstrip("/") sh = store["platform"] == "shopify" rec = {"domain": dom, "email": None, "phone": None, "socials": [], "checked_at": time.strftime("%Y-%m-%d"), "requests": 0} html_text = fetch(base + "/", shopify=sh) rec["requests"] += 1 if html_text: harvest(html_text, rec) if not rec["email"]: # une seule page contact : la première trouvée dans le HTML d'accueil m = re.search(r'href="([^"]*(?:contact|nous-joindre|joindre)[^"]*)"', html_text, re.I) path = None if m: href = _html.unescape(m.group(1)) if href.startswith("/"): path = href elif dom in href: path = "/" + href.split(dom, 1)[1].lstrip("/") if not path: path = CONTACT_PATHS[0] if not sh else "/pages/contact" page = fetch(base + path, shopify=sh) rec["requests"] += 1 if page: harvest(page, rec) rec["socials"] = sorted(set(rec["socials"]))[:6] json.dump(rec, open(cpath, "w"), ensure_ascii=False) return rec def main(): ap = argparse.ArgumentParser() ap.add_argument("--cap", type=int, default=400) ap.add_argument("--workers", type=int, default=6) ap.add_argument("--exclude-platform", default=None, help="saute une plateforme (ex. shopify pendant un cycle de sync)") args = ap.parse_args() con = fdb.connect() extra = "AND platform<>? " if args.exclude_platform else "" params = ([args.exclude_platform] if args.exclude_platform else []) + [args.cap] rows = [dict(r) for r in con.execute( "SELECT id, url, platform FROM stores WHERE product_count > 0 " f"AND (email IS NULL OR email='') {extra}ORDER BY product_count DESC LIMIT ?", params)] con.close() print(f"[contacts] {len(rows)} boutiques ciblées (cap {args.cap})", flush=True) results, done, nreq = [], 0, 0 with cf.ThreadPoolExecutor(args.workers) as ex: for rec in ex.map(work, rows): results.append(rec) nreq += rec.get("requests", 0) done += 1 if done % 50 == 0: print(f" {done}/{len(rows)}", flush=True) con = fdb.connect() n_email = n_phone = 0 for rec in results: if rec.get("email"): con.execute("UPDATE stores SET email=? WHERE id=?", (rec["email"], rec["domain"])) n_email += 1 if rec.get("phone"): con.execute("UPDATE stores SET phone=CASE WHEN COALESCE(phone,'')='' " "THEN ? ELSE phone END WHERE id=?", (rec["phone"], rec["domain"])) n_phone += 1 con.commit(); con.close() print(f"[contacts] courriels : {n_email}/{len(results)} | téléphones : {n_phone} " f"| requêtes réseau : {nreq}") if __name__ == "__main__": main()