spb/fabri-ka Public
Agrégateur de produits québécois — www.fabri-ka.com
HTML 57.9%
Python 18.6%
TypeScript 15.6%
CSS 7.8%
1#!/usr/bin/env python32"""Build the Fabri-Ka store registry (data/stores.json) + research dataset.34Merges:5 data/enriched/candidates.jsonl (discovery provenance)6 data/enriched/verified.jsonl (liveness, platform, QC signals)7Applies origin classification A–E with evidence, assigns region,8and emits the canonical registry used by fabrika/ingest.py.9"""10import json11import os12import re13from datetime import date1415ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))16ENR = os.path.join(ROOT, "data", "enriched")1718# --- source priors: (origin_class, confidence, evidence phrase, default category)19SOURCE_PRIORS = {20 "alimentsduquebec_ent": ("A", 0.85, "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", "epicerie"),21 "lesproduitsduquebec_ent": ("A", 0.85, "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", None),22 "lesproduitsduquebec_det": ("C", 0.75, "Détaillant partenaire Les Produits du Québec (revend des produits québécois)", None),23 "erabledici": ("A", 0.95, "Érablière recensée par les Producteurs et productrices acéricoles du Québec (erabledici.ca)", "erable"),24 "fromagesdici": ("A", 0.95, "Fromagerie recensée par Fromages d'ici (fabrication au Québec)", "fromage_laitier"),25 "cidreduquebec": ("A", 0.95, "Cidrerie membre des Producteurs de cidre du Québec", "alcool"),26 "vinsduquebec": ("A", 0.95, "Vignoble membre du Conseil des vins du Québec", "alcool"),27 "distilleriesduquebec": ("A", 0.9, "Distillerie recensée par Distilleries du Québec", "alcool"),28 "artisansaloeuvre": ("A", 0.9, "Artisan certifié du réseau Économusée / Artisans à l'œuvre (production artisanale sur place)", "art"),29 "marchespublics": ("E", 0.4, "Lien trouvé sur la fiche d'un marché public québécois (AMPQ) — nature du lien à vérifier", None),30 "acheterquebecois": ("A", 0.55, "Répertorié comme produit québécois sur acheterquebecois.ca", None),31 "ideecadeauquebec": ("A", 0.55, "Entreprise québécoise répertoriée par Idée Cadeau Québec", None),32 "signelocal": ("A", 0.8, "Marque vendue par Signé Local (produits faits au Québec)", None),33 "ambq_profils": ("A", 0.95, "Microbrasserie membre de l'AMBQ (brassage au Québec)", "alcool"),34 "wikipedia_lists": ("A", 0.6, "Recensé dans une liste Wikipédia de producteurs québécois", "alcool"),35 "icq_resolved": ("A", 0.55, "Entreprise québécoise répertoriée par Idée Cadeau Québec", None),36 "search_sweep_food": ("A", 0.5, "Trouvé par balayage de recherche ciblé (producteur alimentaire QC)", None),37 "search_sweep_nonfood": ("A", 0.5, "Trouvé par balayage de recherche ciblé (fabricant/artisan QC)", None),38 "zoneboreale": ("A", 0.85, "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", "epicerie"),39 "createursdesaveurs": ("A", 0.85, "Membre Créateurs de saveurs Cantons-de-l'Est (CIBLE Estrie)", "epicerie"),40 "croquezoutaouais": ("A", 0.85, "Entreprise membre de Croquez Outaouais (agroalimentaire Outaouais)", "epicerie"),41 "arretsgourmands": ("A", 0.8, "Arrêt gourmand certifié (Chaudière-Appalaches)", "epicerie"),42 "goutezy": ("A", 0.85, "Entreprise membre de Goûtez-y! (agroalimentaire Centre-du-Québec)", "epicerie"),43 "laurentidesjenmange": ("A", 0.85, "Membre Laurentides j'en mange (agroalimentaire Laurentides)", "epicerie"),44 "goutcotenord": ("A", 0.85, "Artisan membre Le Goût de la Côte-Nord", "epicerie"),45 "charlevoix": ("A", 0.7, "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", "epicerie"),46 "gaspesiegourmande": ("A", 0.85, "Producteur/transformateur membre de Gaspésie Gourmande", "epicerie"),47 "search_sweep_regions": ("A", 0.5, "Trouvé par balayage ciblé des régions sous-couvertes (Nord-du-Québec/Abitibi/BSL/Côte-Nord)", None),48 "search_sweep_vendors": ("A", 0.7, "Marque québécoise vendue par Signé Local ou Boutique Métiers d'art — site officiel résolu", None),49 "search_sweep_urban": ("A", 0.5, "Fabricant/artisan urbain trouvé par balayage dense (Montréal/Québec/Laval)", None),50 "search_sweep_manuf": ("A", 0.5, "Manufacturier québécois trouvé par balayage manufacturier ciblé", "industriel"),51 "search_sweep_cmaq": ("A", 0.85, "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", "art"),52 "saveursbsl": ("A", 0.7, "Producteur dont les produits sont vendus par Saveurs du Bas-Saint-Laurent", "epicerie"),53 "goutezat": ("A", 0.7, "Producteur dont les produits sont vendus par Goûtez l'Abitibi-Témiscamingue", "epicerie"),54 "manual_additions": ("D", 0.7, "Boutique québécoise ajoutée manuellement (validée à la main)", None),55 "search_sweep_bijoux": ("A", 0.55, "Bijouterie/joaillerie québécoise trouvée par balayage dédié bijoux", "bijoux"),56}5758# sources dont TOUS les membres sont québécois (une preuve QC suffit)59QC_ONLY_SOURCES = {60 "alimentsduquebec_ent", "lesproduitsduquebec_ent", "lesproduitsduquebec_det",61 "erabledici", "fromagesdici", "cidreduquebec", "vinsduquebec",62 "distilleriesduquebec", "ambq_profils", "zoneboreale", "createursdesaveurs",63 "croquezoutaouais", "arretsgourmands", "goutezy", "laurentidesjenmange",64 "goutcotenord", "gaspesiegourmande", "charlevoix", "wikipedia_lists",65 "acheterquebecois", "ideecadeauquebec", "icq_resolved", "signelocal",66 "saveursbsl", "goutezat", "search_sweep_cmaq", "search_sweep_vendors",67 "manual_additions",68}69# ⚠️ PAS dans la liste : artisansaloeuvre (réseau Économusée international),70# marchespublics (liens hétérogènes), search sweeps (vérification nécessaire)7172# région certaine induite par la source (annuaires régionaux)73SOURCE_REGION = {74 "zoneboreale": "Saguenay–Lac-Saint-Jean",75 "createursdesaveurs": "Estrie",76 "croquezoutaouais": "Outaouais",77 "arretsgourmands": "Chaudière-Appalaches",78 "goutezy": "Centre-du-Québec",79 "laurentidesjenmange": "Laurentides",80 "goutcotenord": "Côte-Nord",81 "charlevoix": "Capitale-Nationale",82 "gaspesiegourmande": "Gaspésie–Îles-de-la-Madeleine",83 "saveursbsl": "Bas-Saint-Laurent",84 "goutezat": "Abitibi-Témiscamingue",85}8687PRIORITY = ["erabledici", "fromagesdici", "cidreduquebec", "vinsduquebec",88 "distilleriesduquebec", "ambq_profils", "artisansaloeuvre",89 "alimentsduquebec_ent", "lesproduitsduquebec_ent",90 "zoneboreale", "createursdesaveurs", "croquezoutaouais",91 "arretsgourmands", "goutezy", "laurentidesjenmange", "goutcotenord",92 "gaspesiegourmande", "charlevoix", "search_sweep_cmaq",93 "lesproduitsduquebec_det", "signelocal", "wikipedia_lists",94 "saveursbsl", "goutezat", "search_sweep_vendors",95 "acheterquebecois", "ideecadeauquebec", "icq_resolved",96 "search_sweep_food", "search_sweep_nonfood", "search_sweep_regions",97 "search_sweep_urban", "search_sweep_manuf", "search_sweep_bijoux",98 "manual_additions", "marchespublics"]99100REGION_CANON = {101 "Saguenay": "Saguenay–Lac-Saint-Jean", "Lac-Saint-Jean": "Saguenay–Lac-Saint-Jean",102 "Montreal": "Montréal", "Abitibi": "Abitibi-Témiscamingue",103 "Témiscamingue": "Abitibi-Témiscamingue", "Gaspésie": "Gaspésie–Îles-de-la-Madeleine",104 "Îles-de-la-Madeleine": "Gaspésie–Îles-de-la-Madeleine",105 "Cantons-de-l'Est": "Estrie",106}107ALL_REGIONS = ["Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale",108 "Mauricie", "Estrie", "Montréal", "Outaouais", "Abitibi-Témiscamingue",109 "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine",110 "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides",111 "Montérégie", "Centre-du-Québec"]112113114try:115 _GEO = json.load(open(os.path.join(ENR, "geo_map.json")))116 FSA_MAP = {k: re.sub(r"\s*\(\d+\)$", "", v) for k, v in _GEO["fsa"].items()}117except FileNotFoundError:118 FSA_MAP = {}119120121def region_from_postal(cand, ver):122 for p in (cand.get("postal_prefix"), (ver.get("qc_postal") or "")[:3] or None):123 if p and p.upper() in FSA_MAP:124 return FSA_MAP[p.upper()]125 return ""126127128def pick_region(cand):129 for s in cand.get("sources", []):130 if s in SOURCE_REGION:131 return SOURCE_REGION[s]132 raw = cand.get("regions_mentioned", [])133 if len(raw) > 3: # une page qui mentionne >3 régions = menu de navigation134 return ""135 regions = [REGION_CANON.get(r, r) for r in raw]136 regions = [r for r in regions if r in ALL_REGIONS]137 if not regions:138 return ""139 # préfère une région non-Montréal si plusieurs sont mentionnées (les pages140 # d'annuaires mentionnent souvent "Montréal" dans la navigation)141 non_mtl = [r for r in regions if r != "Montréal"]142 return (non_mtl or regions)[0]143144145def clean_name(cand, ver):146 for n in cand.get("names", []):147 n = n.strip()148 if 2 < len(n) < 80 and not n.lower().startswith(("répertoire", "aliments d", "produits appr")):149 return n150 t = (ver.get("title") or "").split("|")[0].split("–")[0].split("—")[0].strip()151 return t[:80] if t else cand["domain"]152153154def classify(cand, ver):155 """-> (class, confidence, evidence)"""156 src = next((s for s in PRIORITY if s in cand.get("sources", [])), None)157 if src:158 cls, conf, ev, _ = SOURCE_PRIORS[src]159 else:160 cls, conf, ev = "E", 0.3, "Découvert sans certification connue"161 if ver.get("made_in_qc_wording"):162 ev += " ; le site affiche une mention de fabrication québécoise"163 if cls in ("E", "C"):164 cls, conf = "A", max(conf, 0.6)165 else:166 conf = min(0.98, conf + 0.05)167 qc_signals = sum(bool(ver.get(k)) for k in ("qc_postal", "qc_phone", "tld_quebec", "mentions_quebec"))168 if qc_signals == 0 and cls != "E":169 conf = max(0.2, conf - 0.25)170 ev += " ; aucun signal de localisation québécoise détecté sur le site (à vérifier)"171 return cls, round(conf, 2), ev172173174def main():175 cands = {}176 with open(os.path.join(ENR, "candidates.jsonl")) as f:177 for line in f:178 c = json.loads(line)179 cands[c["domain"]] = c180 vers = {}181 with open(os.path.join(ENR, "verified.jsonl")) as f:182 for line in f:183 v = json.loads(line)184 vers[v["domain"]] = v185186 stores, seen_final = [], {}187 for dom, cand in sorted(cands.items()):188 ver = vers.get(dom, {})189 if not ver.get("active"):190 continue191 # canonicalise: redirections (ex: ancien domaine -> nouveau)192 final_dom = ver.get("final_domain") or dom193 if final_dom in seen_final:194 # fusionne la provenance dans l'entrée canonique195 for s in cand.get("sources", []):196 if s not in seen_final[final_dom]["discovery_sources"]:197 seen_final[final_dom]["discovery_sources"].append(s)198 continue199 # garde-fou localisation : preuve QC sur le site OU source exclusivement QC200 qc_signal = any(ver.get(k) for k in ("qc_postal", "qc_phone", "tld_quebec",201 "mentions_quebec", "made_in_qc_wording"))202 qc_source = any(s in QC_ONLY_SOURCES for s in cand.get("sources", []))203 if not qc_signal and not qc_source:204 continue # ex. économusées hors-Québec, liens externes non-QC205 cls, conf, ev = classify(cand, ver)206 default_cat = next((SOURCE_PRIORS[s][3] for s in PRIORITY207 if s in cand.get("sources", []) and SOURCE_PRIORS[s][3]), None)208 platform = ver.get("platform") or ""209 catalog_endpoint = ver.get("catalog_endpoint") or ""210 if platform == "wix" and not catalog_endpoint:211 # connecteur Wix Stores : jeton public via /_api/v1/access-tokens212 catalog_endpoint = "/_api/wix-ecommerce-storefront-web/api"213 store = {214 "id": final_dom,215 "name": clean_name(cand, ver),216 # racine canonique : les endpoints catalogue sont relatifs à la racine217 "url": (lambda fu: f"{fu.scheme}://{fu.netloc}")(218 __import__("urllib.parse", fromlist=["urlparse"]).urlparse(219 ver.get("final_url") or f"https://{final_dom}")),220 "platform": platform,221 "catalog_endpoint": catalog_endpoint,222 "city": "",223 "region": pick_region(cand) or region_from_postal(cand, ver),224 "postal_prefix": cand.get("postal_prefix") or (ver.get("qc_postal") or "")[:3] or None,225 "phone": cand.get("phone") or ver.get("qc_phone"),226 "origin_class": cls,227 "origin_confidence": conf,228 "origin_evidence": ev,229 "categories": [default_cat] if default_cat else [],230 "socials": (cand.get("socials") or [])[:4] or ver.get("socials", []),231 "discovery_sources": cand.get("sources", []),232 "discovery_source_urls": cand.get("source_pages", [])[:5],233 "language": ver.get("language"),234 "ecommerce": bool(ver.get("has_cart") or catalog_endpoint),235 "verification_date": ver.get("checked_at") or str(date.today()),236 "status": "verified" if (conf >= 0.6 and ver.get("mentions_quebec")) else "probable",237 "enabled": bool(catalog_endpoint),238 }239 seen_final[final_dom] = store240 stores.append(store)241242 out = {"generated": str(date.today()), "count": len(stores), "stores": stores}243 with open(os.path.join(ROOT, "data", "stores.json"), "w") as f:244 json.dump(out, f, ensure_ascii=False, indent=1)245246 n_conn = sum(1 for s in stores if s["catalog_endpoint"])247 n_ecom = sum(1 for s in stores if s["ecommerce"])248 by_class = {}249 for s in stores:250 by_class[s["origin_class"]] = by_class.get(s["origin_class"], 0) + 1251 by_region = {}252 for s in stores:253 by_region[s["region"] or "(inconnue)"] = by_region.get(s["region"] or "(inconnue)", 0) + 1254 print(f"registre: {len(stores)} boutiques actives | ecommerce: {n_ecom} | connectables: {n_conn}")255 print("classes:", json.dumps(by_class, ensure_ascii=False))256 print("régions:", json.dumps(dict(sorted(by_region.items(), key=lambda x: -x[1])), ensure_ascii=False, indent=1))257258259if __name__ == "__main__":260 main()261