Certifications structurées (champ stores.certifications : Aliments du Québec, Produits du Québec, Économusée, Arrêts gourmands, Bio CARTV avec certificateur+date) + détection « présumée fermée » : fail_streak sur échecs consécutifs, seuil 12 syncs (~3 j), produits dépubliés, réintégration auto au premier sync réussi, backfills rejouables
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 changed files +241 −1
modified
fabrika/db.py
+15 −1
@@ -102,9 +102,23 @@ def connect() -> sqlite3.Connection: | ||
| 102 | 102 | for col, typ in (("shipping_info", "TEXT"), ("email", "TEXT"), |
| 103 | 103 | ("phone", "TEXT"), ("postal_prefix", "TEXT"), |
| 104 | 104 | ("lat", "REAL"), ("lng", "REAL"), |
| 105 | − ("store_kind", "TEXT")): | |
| 105 | + ("store_kind", "TEXT"), | |
| 106 | + # vague 4 : certifications structurées (JSON) + | |
| 107 | + # détection des ateliers présumés fermés | |
| 108 | + ("certifications", "TEXT"), | |
| 109 | + ("fail_streak", "INTEGER DEFAULT 0"), | |
| 110 | + ("presumed_closed", "INTEGER DEFAULT 0")): | |
| 106 | 111 | if col not in scols: |
| 107 | 112 | con.execute(f"ALTER TABLE stores ADD COLUMN {col} {typ}") |
| 113 | + # auto-inscription des artisans (« Inscrire mon atelier ») : | |
| 114 | + # quarantaine de validation, jamais publiée sans geste manuel | |
| 115 | + con.execute("""CREATE TABLE IF NOT EXISTS inscriptions ( | |
| 116 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 117 | + ts REAL, ip TEXT, name TEXT, metier TEXT, description TEXT, | |
| 118 | + address TEXT, website TEXT, email TEXT, phone TEXT, | |
| 119 | + socials TEXT, photos TEXT, | |
| 120 | + status TEXT DEFAULT 'pending', -- pending|approved|rejected | |
| 121 | + reviewed_at REAL, review_note TEXT)""") | |
| 108 | 122 | return con |
| 109 | 123 | except sqlite3.OperationalError as exc: |
| 110 | 124 | last_exc = exc |
modified
fabrika/ingest.py
+22 −0
@@ -19,6 +19,11 @@ from .connectors import connector_for | ||
| 19 | 19 | |
| 20 | 20 | REGISTRY_PATH = Path(__file__).resolve().parent.parent / "data" / "stores.json" |
| 21 | 21 | |
| 22 | +# Nombre d'échecs de sync CONSÉCUTIFS avant de présumer l'atelier fermé | |
| 23 | +# (12 × 6 h = ~3 jours sans jamais répondre). Statut réversible : un seul | |
| 24 | +# sync réussi le remet à zéro et réactive la boutique. | |
| 25 | +CLOSE_AFTER_FAILS = 12 | |
| 26 | + | |
| 22 | 27 | _db_lock = threading.Lock() |
| 23 | 28 | |
| 24 | 29 | |
@@ -39,6 +44,20 @@ def sync_store(store: dict) -> dict: | ||
| 39 | 44 | with _db_lock: |
| 40 | 45 | con = db.connect() |
| 41 | 46 | db.log_sync(con, sid, 0, 0, 0, 0, "error", str(exc)) |
| 47 | + # compteur d'échecs consécutifs -> « présumée fermée » | |
| 48 | + con.execute("UPDATE stores SET fail_streak=COALESCE(fail_streak,0)+1 " | |
| 49 | + "WHERE id=?", (sid,)) | |
| 50 | + row = con.execute("SELECT fail_streak, COALESCE(presumed_closed,0) " | |
| 51 | + "FROM stores WHERE id=?", (sid,)).fetchone() | |
| 52 | + if row and row[0] >= CLOSE_AFTER_FAILS and not row[1]: | |
| 53 | + con.execute("UPDATE stores SET presumed_closed=1, product_count=0 " | |
| 54 | + "WHERE id=?", (sid,)) | |
| 55 | + con.execute("UPDATE products SET active=0 WHERE store_id=?", (sid,)) | |
| 56 | + db.log_sync(con, sid, 0, 0, 0, 0, "presumed_closed", | |
| 57 | + f"{row[0]} échecs consécutifs — produits dépubliés " | |
| 58 | + "(réintégration automatique au premier sync réussi)") | |
| 59 | + print(f" ⚠ {sid}: présumée fermée ({row[0]} échecs consécutifs)", | |
| 60 | + flush=True) | |
| 42 | 61 | con.commit(); con.close() |
| 43 | 62 | return {"store": sid, "status": "error", "message": str(exc)[:200]} |
| 44 | 63 | with _db_lock: |
@@ -46,6 +65,9 @@ def sync_store(store: dict) -> dict: | ||
| 46 | 65 | db.upsert_store(con, store) |
| 47 | 66 | added, updated, removed = db.sync_products(con, sid, store.get("name") or sid, products) |
| 48 | 67 | db.log_sync(con, sid, len(products), added, updated, removed, "ok") |
| 68 | + # sync réussi : la boutique répond -> on efface l'ardoise | |
| 69 | + con.execute("UPDATE stores SET fail_streak=0, presumed_closed=0 WHERE id=?", | |
| 70 | + (sid,)) | |
| 49 | 71 | con.commit(); con.close() |
| 50 | 72 | return {"store": sid, "status": "ok", "found": len(products), |
| 51 | 73 | "added": added, "updated": updated, "removed": removed} |
added
scripts/backfill_certifications.py
+138 −0
@@ -0,0 +1,138 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Champ structuré `certifications` sur les fiches boutiques (vague 4). | |
| 3 | + | |
| 4 | +Deux gisements : | |
| 5 | + 1. Les sources de découverte qui SONT des certifications/labels vérifiés | |
| 6 | + (Aliments du Québec, Les Produits du Québec, Économusée, Arrêts | |
| 7 | + gourmands) -> label dérivé de discovery_sources. | |
| 8 | + 2. Le répertoire public CARTV/SIPAB des entreprises certifiées bio | |
| 9 | + (data/raw/cartv_bio.jsonl, moissonné par wave4_discovery.py) -> | |
| 10 | + appariement par domaine (fort) puis par nom normalisé unique | |
| 11 | + (prudent), avec certificateur + date. | |
| 12 | + | |
| 13 | +Écrit dans la DB (stores.certifications, JSON) ET dans data/stores.json | |
| 14 | +(champ `certifications`) — ⚠ sérialiser avec les autres écrivains du | |
| 15 | +registre. Rejouable (fusion sans doublon par `label`). | |
| 16 | + | |
| 17 | +Usage : .venv/bin/python scripts/backfill_certifications.py | |
| 18 | +""" | |
| 19 | +import json | |
| 20 | +import re | |
| 21 | +import sys | |
| 22 | +import unicodedata | |
| 23 | +from pathlib import Path | |
| 24 | + | |
| 25 | +ROOT = Path(__file__).resolve().parent.parent | |
| 26 | +sys.path.insert(0, str(ROOT)) | |
| 27 | +sys.path.insert(0, str(ROOT / "scripts")) | |
| 28 | + | |
| 29 | +from fabrika import db # noqa: E402 | |
| 30 | +from aggregate import norm_domain # noqa: E402 | |
| 31 | + | |
| 32 | +SOURCE_LABELS = { | |
| 33 | + "alimentsduquebec_ent": ("Aliments du Québec", | |
| 34 | + "Certification Aliments du Québec (adhérent vérifié)"), | |
| 35 | + "lesproduitsduquebec_ent": ("Les Produits du Québec", | |
| 36 | + "Certification Les Produits du Québec"), | |
| 37 | + "artisansaloeuvre": ("Économusée", "Accréditation réseau Économusée / " | |
| 38 | + "Artisans à l'œuvre"), | |
| 39 | + "arretsgourmands": ("Arrêt gourmand certifié", | |
| 40 | + "Arrêt gourmand certifié (Chaudière-Appalaches)"), | |
| 41 | +} | |
| 42 | + | |
| 43 | +STOP = {"inc", "enr", "ltee", "les", "la", "le", "de", "du", "des", "et"} | |
| 44 | + | |
| 45 | + | |
| 46 | +def norm_name(name): | |
| 47 | + s = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode().lower() | |
| 48 | + return " ".join(t for t in re.findall(r"[a-z0-9]+", s) if t not in STOP) | |
| 49 | + | |
| 50 | + | |
| 51 | +def main(): | |
| 52 | + con = db.connect() | |
| 53 | + stores = {r["id"]: dict(r) for r in con.execute( | |
| 54 | + "SELECT id, name, discovery_sources, certifications FROM stores")} | |
| 55 | + certs = {sid: {c["label"]: c for c in json.loads(s.get("certifications") or "[]")} | |
| 56 | + for sid, s in stores.items()} | |
| 57 | + | |
| 58 | + # 1) labels dérivés des sources de découverte certifiantes | |
| 59 | + n_src = 0 | |
| 60 | + for sid, s in stores.items(): | |
| 61 | + for src in json.loads(s.get("discovery_sources") or "[]"): | |
| 62 | + if src in SOURCE_LABELS: | |
| 63 | + label, detail = SOURCE_LABELS[src] | |
| 64 | + if label not in certs[sid]: | |
| 65 | + certs[sid][label] = {"label": label, "detail": detail, | |
| 66 | + "source": src} | |
| 67 | + n_src += 1 | |
| 68 | + | |
| 69 | + # 2) répertoire CARTV bio | |
| 70 | + cartv_path = ROOT / "data" / "raw" / "cartv_bio.jsonl" | |
| 71 | + n_dom = n_name = 0 | |
| 72 | + if cartv_path.exists(): | |
| 73 | + recs = [json.loads(l) for l in open(cartv_path)] | |
| 74 | + by_dom = {} | |
| 75 | + for r in recs: | |
| 76 | + for w in r.get("websites", []): | |
| 77 | + d = norm_domain(w) | |
| 78 | + if d: | |
| 79 | + by_dom.setdefault(d, r) | |
| 80 | + # index nom normalisé -> fiches CARTV (pour l'appariement prudent) | |
| 81 | + by_name = {} | |
| 82 | + for r in recs: | |
| 83 | + by_name.setdefault(norm_name(r["title"]), []).append(r) | |
| 84 | + name_to_sids = {} | |
| 85 | + for sid, s in stores.items(): | |
| 86 | + name_to_sids.setdefault(norm_name(s["name"]), []).append(sid) | |
| 87 | + | |
| 88 | + def add_bio(sid, r, how): | |
| 89 | + label = "Biologique — CARTV" | |
| 90 | + if label in certs[sid]: | |
| 91 | + return 0 | |
| 92 | + certs[sid][label] = { | |
| 93 | + "label": label, | |
| 94 | + "detail": "Appellation biologique du Québec (répertoire public " | |
| 95 | + "CARTV/SIPAB)", | |
| 96 | + "certifier": r.get("certifier") or None, | |
| 97 | + "since": r.get("cert_date") or None, | |
| 98 | + "operations": r.get("operations") or [], | |
| 99 | + "source": "cartv_bio", "match": how, | |
| 100 | + } | |
| 101 | + return 1 | |
| 102 | + | |
| 103 | + for sid in stores: | |
| 104 | + if sid in by_dom: | |
| 105 | + n_dom += add_bio(sid, by_dom[sid], "domaine") | |
| 106 | + for nname, rs in by_name.items(): | |
| 107 | + if len(rs) != 1 or not nname or len(nname) < 6: | |
| 108 | + continue | |
| 109 | + sids = name_to_sids.get(nname, []) | |
| 110 | + if len(sids) == 1 and "Biologique — CARTV" not in certs[sids[0]]: | |
| 111 | + n_name += add_bio(sids[0], rs[0], "nom_unique") | |
| 112 | + | |
| 113 | + # écriture DB | |
| 114 | + n_stores = 0 | |
| 115 | + for sid, cmap in certs.items(): | |
| 116 | + if not cmap: | |
| 117 | + continue | |
| 118 | + con.execute("UPDATE stores SET certifications=? WHERE id=?", | |
| 119 | + (json.dumps(list(cmap.values()), ensure_ascii=False), sid)) | |
| 120 | + n_stores += 1 | |
| 121 | + con.commit(); con.close() | |
| 122 | + | |
| 123 | + # écriture registre (champ informatif, la DB fait foi pour l'API) | |
| 124 | + reg_path = ROOT / "data" / "stores.json" | |
| 125 | + reg = json.load(open(reg_path)) | |
| 126 | + for s in reg["stores"]: | |
| 127 | + cmap = certs.get(s["id"]) | |
| 128 | + if cmap: | |
| 129 | + s["certifications"] = list(cmap.values()) | |
| 130 | + json.dump(reg, open(reg_path, "w"), ensure_ascii=False, indent=1) | |
| 131 | + | |
| 132 | + print(f"certifications : {n_stores} boutiques porteuses " | |
| 133 | + f"(+{n_src} labels de sources, +{n_dom} bio par domaine, " | |
| 134 | + f"+{n_name} bio par nom unique)") | |
| 135 | + | |
| 136 | + | |
| 137 | +if __name__ == "__main__": | |
| 138 | + main() | |
added
scripts/backfill_closures.py
+66 −0
@@ -0,0 +1,66 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Backfill du statut « présumée fermée » depuis l'historique sync_log. | |
| 3 | + | |
| 4 | +Le mécanisme durable vit dans fabrika/ingest.py (compteur fail_streak, | |
| 5 | +seuil CLOSE_AFTER_FAILS, réintégration automatique au premier sync réussi). | |
| 6 | +Ce script rejoue l'historique existant : pour chaque boutique activée, on | |
| 7 | +compte les échecs consécutifs depuis le dernier sync réussi ; au-delà du | |
| 8 | +seuil, statut appliqué + produits dépubliés (réversible). | |
| 9 | + | |
| 10 | +Usage : .venv/bin/python scripts/backfill_closures.py [--dry-run] | |
| 11 | +""" | |
| 12 | +import argparse | |
| 13 | +import sys | |
| 14 | +from pathlib import Path | |
| 15 | + | |
| 16 | +ROOT = Path(__file__).resolve().parent.parent | |
| 17 | +sys.path.insert(0, str(ROOT)) | |
| 18 | + | |
| 19 | +from fabrika import db # noqa: E402 | |
| 20 | +from fabrika.ingest import CLOSE_AFTER_FAILS # noqa: E402 | |
| 21 | + | |
| 22 | + | |
| 23 | +def main(): | |
| 24 | + ap = argparse.ArgumentParser() | |
| 25 | + ap.add_argument("--dry-run", action="store_true") | |
| 26 | + args = ap.parse_args() | |
| 27 | + | |
| 28 | + con = db.connect() | |
| 29 | + stores = con.execute( | |
| 30 | + "SELECT id, name, product_count FROM stores " | |
| 31 | + "WHERE enabled=1 AND catalog_endpoint<>''").fetchall() | |
| 32 | + closed, updated = [], 0 | |
| 33 | + for sid, name, pc in stores: | |
| 34 | + rows = con.execute( | |
| 35 | + "SELECT status FROM sync_log WHERE store_id=? " | |
| 36 | + "ORDER BY ts DESC LIMIT 100", (sid,)).fetchall() | |
| 37 | + streak = 0 | |
| 38 | + for (status,) in rows: | |
| 39 | + if status == "error": | |
| 40 | + streak += 1 | |
| 41 | + elif status == "ok": | |
| 42 | + break | |
| 43 | + # presumed_closed / autres statuts : neutres | |
| 44 | + con.execute("UPDATE stores SET fail_streak=? WHERE id=?", (streak, sid)) | |
| 45 | + updated += 1 | |
| 46 | + if streak >= CLOSE_AFTER_FAILS: | |
| 47 | + closed.append((sid, name, streak, pc)) | |
| 48 | + if not args.dry_run: | |
| 49 | + con.execute("UPDATE stores SET presumed_closed=1, product_count=0 " | |
| 50 | + "WHERE id=?", (sid,)) | |
| 51 | + con.execute("UPDATE products SET active=0 WHERE store_id=?", (sid,)) | |
| 52 | + db.log_sync(con, sid, 0, 0, 0, 0, "presumed_closed", | |
| 53 | + f"backfill historique : {streak} échecs consécutifs") | |
| 54 | + if not args.dry_run: | |
| 55 | + con.commit() | |
| 56 | + con.close() | |
| 57 | + print(f"fail_streak recalculé pour {updated} boutiques") | |
| 58 | + print(f"présumées fermées ({'dry-run' if args.dry_run else 'appliqué'}) : " | |
| 59 | + f"{len(closed)}") | |
| 60 | + for sid, name, streak, pc in closed: | |
| 61 | + print(f" - {sid} «{name}» : {streak} échecs consécutifs, " | |
| 62 | + f"{pc} produits dépubliés") | |
| 63 | + | |
| 64 | + | |
| 65 | +if __name__ == "__main__": | |
| 66 | + main() | |
| 67 | ||