#!/usr/bin/env python3 """Build the Fabri-Ka store registry (data/stores.json) + research dataset. Merges: data/enriched/candidates.jsonl (discovery provenance) data/enriched/verified.jsonl (liveness, platform, QC signals) Applies origin classification A–E with evidence, assigns region, and emits the canonical registry used by fabrika/ingest.py. """ import json import os import re from datetime import date ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ENR = os.path.join(ROOT, "data", "enriched") # --- source priors: (origin_class, confidence, evidence phrase, default category) SOURCE_PRIORS = { "alimentsduquebec_ent": ("A", 0.85, "Entreprise adhérente Aliments du Québec (produits alimentaires préparés/cultivés au Québec)", "epicerie"), "lesproduitsduquebec_ent": ("A", 0.85, "Entreprise adhérente à la certification Les Produits du Québec (fabrication/conception vérifiée au Québec)", None), "lesproduitsduquebec_det": ("C", 0.75, "Détaillant partenaire Les Produits du Québec (revend des produits québécois)", None), "erabledici": ("A", 0.95, "Érablière recensée par les Producteurs et productrices acéricoles du Québec (erabledici.ca)", "erable"), "fromagesdici": ("A", 0.95, "Fromagerie recensée par Fromages d'ici (fabrication au Québec)", "fromage_laitier"), "cidreduquebec": ("A", 0.95, "Cidrerie membre des Producteurs de cidre du Québec", "alcool"), "vinsduquebec": ("A", 0.95, "Vignoble membre du Conseil des vins du Québec", "alcool"), "distilleriesduquebec": ("A", 0.9, "Distillerie recensée par Distilleries du Québec", "alcool"), "artisansaloeuvre": ("A", 0.9, "Artisan certifié du réseau Économusée / Artisans à l'œuvre (production artisanale sur place)", "art"), "marchespublics": ("E", 0.4, "Lien trouvé sur la fiche d'un marché public québécois (AMPQ) — nature du lien à vérifier", None), "acheterquebecois": ("A", 0.55, "Répertorié comme produit québécois sur acheterquebecois.ca", None), "ideecadeauquebec": ("A", 0.55, "Entreprise québécoise répertoriée par Idée Cadeau Québec", None), "signelocal": ("A", 0.8, "Marque vendue par Signé Local (produits faits au Québec)", None), "ambq_profils": ("A", 0.95, "Microbrasserie membre de l'AMBQ (brassage au Québec)", "alcool"), "wikipedia_lists": ("A", 0.6, "Recensé dans une liste Wikipédia de producteurs québécois", "alcool"), "icq_resolved": ("A", 0.55, "Entreprise québécoise répertoriée par Idée Cadeau Québec", None), "search_sweep_food": ("A", 0.5, "Trouvé par balayage de recherche ciblé (producteur alimentaire QC)", None), "search_sweep_nonfood": ("A", 0.5, "Trouvé par balayage de recherche ciblé (fabricant/artisan QC)", None), "zoneboreale": ("A", 0.85, "Entreprise membre de Zone Boréale (agroalimentaire Saguenay–Lac-Saint-Jean)", "epicerie"), "createursdesaveurs": ("A", 0.85, "Membre Créateurs de saveurs Cantons-de-l'Est (CIBLE Estrie)", "epicerie"), "croquezoutaouais": ("A", 0.85, "Entreprise membre de Croquez Outaouais (agroalimentaire Outaouais)", "epicerie"), "arretsgourmands": ("A", 0.8, "Arrêt gourmand certifié (Chaudière-Appalaches)", "epicerie"), "goutezy": ("A", 0.85, "Entreprise membre de Goûtez-y! (agroalimentaire Centre-du-Québec)", "epicerie"), "laurentidesjenmange": ("A", 0.85, "Membre Laurentides j'en mange (agroalimentaire Laurentides)", "epicerie"), "goutcotenord": ("A", 0.85, "Artisan membre Le Goût de la Côte-Nord", "epicerie"), "charlevoix": ("A", 0.7, "Entreprise de la Route des Saveurs de Charlevoix (Tourisme Charlevoix)", "epicerie"), "gaspesiegourmande": ("A", 0.85, "Producteur/transformateur membre de Gaspésie Gourmande", "epicerie"), "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), "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), "search_sweep_urban": ("A", 0.5, "Fabricant/artisan urbain trouvé par balayage dense (Montréal/Québec/Laval)", None), "search_sweep_manuf": ("A", 0.5, "Manufacturier québécois trouvé par balayage manufacturier ciblé", "industriel"), "search_sweep_cmaq": ("A", 0.85, "Artisan professionnel membre du Conseil des métiers d'art du Québec (répertoire officiel)", "art"), "saveursbsl": ("A", 0.7, "Producteur dont les produits sont vendus par Saveurs du Bas-Saint-Laurent", "epicerie"), "goutezat": ("A", 0.7, "Producteur dont les produits sont vendus par Goûtez l'Abitibi-Témiscamingue", "epicerie"), "manual_additions": ("D", 0.7, "Boutique québécoise ajoutée manuellement (validée à la main)", None), "search_sweep_bijoux": ("A", 0.55, "Bijouterie/joaillerie québécoise trouvée par balayage dédié bijoux", "bijoux"), } # sources dont TOUS les membres sont québécois (une preuve QC suffit) QC_ONLY_SOURCES = { "alimentsduquebec_ent", "lesproduitsduquebec_ent", "lesproduitsduquebec_det", "erabledici", "fromagesdici", "cidreduquebec", "vinsduquebec", "distilleriesduquebec", "ambq_profils", "zoneboreale", "createursdesaveurs", "croquezoutaouais", "arretsgourmands", "goutezy", "laurentidesjenmange", "goutcotenord", "gaspesiegourmande", "charlevoix", "wikipedia_lists", "acheterquebecois", "ideecadeauquebec", "icq_resolved", "signelocal", "saveursbsl", "goutezat", "search_sweep_cmaq", "search_sweep_vendors", "manual_additions", } # ⚠️ PAS dans la liste : artisansaloeuvre (réseau Économusée international), # marchespublics (liens hétérogènes), search sweeps (vérification nécessaire) # région certaine induite par la source (annuaires régionaux) SOURCE_REGION = { "zoneboreale": "Saguenay–Lac-Saint-Jean", "createursdesaveurs": "Estrie", "croquezoutaouais": "Outaouais", "arretsgourmands": "Chaudière-Appalaches", "goutezy": "Centre-du-Québec", "laurentidesjenmange": "Laurentides", "goutcotenord": "Côte-Nord", "charlevoix": "Capitale-Nationale", "gaspesiegourmande": "Gaspésie–Îles-de-la-Madeleine", "saveursbsl": "Bas-Saint-Laurent", "goutezat": "Abitibi-Témiscamingue", } PRIORITY = ["erabledici", "fromagesdici", "cidreduquebec", "vinsduquebec", "distilleriesduquebec", "ambq_profils", "artisansaloeuvre", "alimentsduquebec_ent", "lesproduitsduquebec_ent", "zoneboreale", "createursdesaveurs", "croquezoutaouais", "arretsgourmands", "goutezy", "laurentidesjenmange", "goutcotenord", "gaspesiegourmande", "charlevoix", "search_sweep_cmaq", "lesproduitsduquebec_det", "signelocal", "wikipedia_lists", "saveursbsl", "goutezat", "search_sweep_vendors", "acheterquebecois", "ideecadeauquebec", "icq_resolved", "search_sweep_food", "search_sweep_nonfood", "search_sweep_regions", "search_sweep_urban", "search_sweep_manuf", "search_sweep_bijoux", "manual_additions", "marchespublics"] REGION_CANON = { "Saguenay": "Saguenay–Lac-Saint-Jean", "Lac-Saint-Jean": "Saguenay–Lac-Saint-Jean", "Montreal": "Montréal", "Abitibi": "Abitibi-Témiscamingue", "Témiscamingue": "Abitibi-Témiscamingue", "Gaspésie": "Gaspésie–Îles-de-la-Madeleine", "Îles-de-la-Madeleine": "Gaspésie–Îles-de-la-Madeleine", "Cantons-de-l'Est": "Estrie", } ALL_REGIONS = ["Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale", "Mauricie", "Estrie", "Montréal", "Outaouais", "Abitibi-Témiscamingue", "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine", "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides", "Montérégie", "Centre-du-Québec"] try: _GEO = json.load(open(os.path.join(ENR, "geo_map.json"))) FSA_MAP = {k: re.sub(r"\s*\(\d+\)$", "", v) for k, v in _GEO["fsa"].items()} except FileNotFoundError: FSA_MAP = {} def region_from_postal(cand, ver): for p in (cand.get("postal_prefix"), (ver.get("qc_postal") or "")[:3] or None): if p and p.upper() in FSA_MAP: return FSA_MAP[p.upper()] return "" def pick_region(cand): for s in cand.get("sources", []): if s in SOURCE_REGION: return SOURCE_REGION[s] raw = cand.get("regions_mentioned", []) if len(raw) > 3: # une page qui mentionne >3 régions = menu de navigation return "" regions = [REGION_CANON.get(r, r) for r in raw] regions = [r for r in regions if r in ALL_REGIONS] if not regions: return "" # préfère une région non-Montréal si plusieurs sont mentionnées (les pages # d'annuaires mentionnent souvent "Montréal" dans la navigation) non_mtl = [r for r in regions if r != "Montréal"] return (non_mtl or regions)[0] def clean_name(cand, ver): for n in cand.get("names", []): n = n.strip() if 2 < len(n) < 80 and not n.lower().startswith(("répertoire", "aliments d", "produits appr")): return n t = (ver.get("title") or "").split("|")[0].split("–")[0].split("—")[0].strip() return t[:80] if t else cand["domain"] def classify(cand, ver): """-> (class, confidence, evidence)""" src = next((s for s in PRIORITY if s in cand.get("sources", [])), None) if src: cls, conf, ev, _ = SOURCE_PRIORS[src] else: cls, conf, ev = "E", 0.3, "Découvert sans certification connue" if ver.get("made_in_qc_wording"): ev += " ; le site affiche une mention de fabrication québécoise" if cls in ("E", "C"): cls, conf = "A", max(conf, 0.6) else: conf = min(0.98, conf + 0.05) qc_signals = sum(bool(ver.get(k)) for k in ("qc_postal", "qc_phone", "tld_quebec", "mentions_quebec")) if qc_signals == 0 and cls != "E": conf = max(0.2, conf - 0.25) ev += " ; aucun signal de localisation québécoise détecté sur le site (à vérifier)" return cls, round(conf, 2), ev def main(): cands = {} with open(os.path.join(ENR, "candidates.jsonl")) as f: for line in f: c = json.loads(line) cands[c["domain"]] = c vers = {} with open(os.path.join(ENR, "verified.jsonl")) as f: for line in f: v = json.loads(line) vers[v["domain"]] = v stores, seen_final = [], {} for dom, cand in sorted(cands.items()): ver = vers.get(dom, {}) if not ver.get("active"): continue # canonicalise: redirections (ex: ancien domaine -> nouveau) final_dom = ver.get("final_domain") or dom if final_dom in seen_final: # fusionne la provenance dans l'entrée canonique for s in cand.get("sources", []): if s not in seen_final[final_dom]["discovery_sources"]: seen_final[final_dom]["discovery_sources"].append(s) continue # garde-fou localisation : preuve QC sur le site OU source exclusivement QC qc_signal = any(ver.get(k) for k in ("qc_postal", "qc_phone", "tld_quebec", "mentions_quebec", "made_in_qc_wording")) qc_source = any(s in QC_ONLY_SOURCES for s in cand.get("sources", [])) if not qc_signal and not qc_source: continue # ex. économusées hors-Québec, liens externes non-QC cls, conf, ev = classify(cand, ver) default_cat = next((SOURCE_PRIORS[s][3] for s in PRIORITY if s in cand.get("sources", []) and SOURCE_PRIORS[s][3]), None) platform = ver.get("platform") or "" catalog_endpoint = ver.get("catalog_endpoint") or "" if platform == "wix" and not catalog_endpoint: # connecteur Wix Stores : jeton public via /_api/v1/access-tokens catalog_endpoint = "/_api/wix-ecommerce-storefront-web/api" store = { "id": final_dom, "name": clean_name(cand, ver), # racine canonique : les endpoints catalogue sont relatifs à la racine "url": (lambda fu: f"{fu.scheme}://{fu.netloc}")( __import__("urllib.parse", fromlist=["urlparse"]).urlparse( ver.get("final_url") or f"https://{final_dom}")), "platform": platform, "catalog_endpoint": catalog_endpoint, "city": "", "region": pick_region(cand) or region_from_postal(cand, ver), "postal_prefix": cand.get("postal_prefix") or (ver.get("qc_postal") or "")[:3] or None, "phone": cand.get("phone") or ver.get("qc_phone"), "origin_class": cls, "origin_confidence": conf, "origin_evidence": ev, "categories": [default_cat] if default_cat else [], "socials": (cand.get("socials") or [])[:4] or ver.get("socials", []), "discovery_sources": cand.get("sources", []), "discovery_source_urls": cand.get("source_pages", [])[:5], "language": ver.get("language"), "ecommerce": bool(ver.get("has_cart") or catalog_endpoint), "verification_date": ver.get("checked_at") or str(date.today()), "status": "verified" if (conf >= 0.6 and ver.get("mentions_quebec")) else "probable", "enabled": bool(catalog_endpoint), } seen_final[final_dom] = store stores.append(store) out = {"generated": str(date.today()), "count": len(stores), "stores": stores} with open(os.path.join(ROOT, "data", "stores.json"), "w") as f: json.dump(out, f, ensure_ascii=False, indent=1) n_conn = sum(1 for s in stores if s["catalog_endpoint"]) n_ecom = sum(1 for s in stores if s["ecommerce"]) by_class = {} for s in stores: by_class[s["origin_class"]] = by_class.get(s["origin_class"], 0) + 1 by_region = {} for s in stores: by_region[s["region"] or "(inconnue)"] = by_region.get(s["region"] or "(inconnue)", 0) + 1 print(f"registre: {len(stores)} boutiques actives | ecommerce: {n_ecom} | connectables: {n_conn}") print("classes:", json.dumps(by_class, ensure_ascii=False)) print("régions:", json.dumps(dict(sorted(by_region.items(), key=lambda x: -x[1])), ensure_ascii=False, indent=1)) if __name__ == "__main__": main()