SPB Git forge

spb/fabri-ka

Public

Agrégateur de produits québécois — www.fabri-ka.com

217commits 1branches 0releases
66.1 MBsize
maindefault branch
3 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 19, 2026) parent df0f4fb

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