SPB Git

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%
14.2 KB · 261 lines python
Raw Blame History
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