SPB Git

spb/fabri-ka

Public

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

19.7 KB · 336 lines python
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    # --- vague 3 (annuaires frais, scripts/wave3_discovery.py)57    "mauriciemiam": ("A", 0.85, "Membre MIAM Mauricie — identifiant régional des produits agroalimentaires de la Mauricie", "epicerie"),58    "gardemangerduquebec": ("A", 0.85, "Producteur/transformateur membre du Garde-Manger du Québec (agroalimentaire Montérégie)", "epicerie"),59    "gardemangerduquebec_det": ("C", 0.6, "Détaillant/marché/restaurant membre du Garde-Manger du Québec (Montérégie) — revend des produits d'ici", "epicerie"),60    "terroiretsaveurs": ("A", 0.75, "Producteur/artisan du répertoire Terroir et Saveurs (Association de l'Agrotourisme et du Tourisme Gourmand du Québec)", "epicerie"),61    "lespagesvertes": ("E", 0.4, "Entreprise écoresponsable répertoriée par Les Pages Vertes (économie verte QC) — fabrication à confirmer", None),62    # --- vague 4 (expansion, scripts/wave4_discovery.py — 2026-08-19)63    "cartv_bio": ("A", 0.9, "Entreprise certifiée biologique du Québec (répertoire public CARTV/SIPAB)", None),64    "ctaq": ("A", 0.85, "Transformateur alimentaire membre du CTAQ (Conseil de la transformation alimentaire du Québec)", "epicerie"),65    "vendors_collectifs": ("A", 0.7, "Marque québécoise vendue par un collectif d'artisans connecté (Paperole/Galerie Iris/Wachiya) — site officiel résolu et validé", None),66    "cibim": ("A", 0.85, "Boulangerie artisanale membre de la CIBIM", "boulangerie"),67    "canardduquebec": ("A", 0.9, "Producteur membre des Éleveurs de canards et d'oies du Québec", "viande_poisson"),68    "lebongoutfraisdesiles": ("A", 0.85, "Producteur/transformateur membre du Bon goût frais des Îles-de-la-Madeleine", "epicerie"),69    "acheterquebecois_mtl": ("A", 0.55, "Répertorié « Fabriqué à Montréal » sur acheterquebecois.ca", None),70    "retest_curl_2026_08_19": ("C", 0.7, "Place de marché/revendeur de produits québécois re-testé via curl", "epicerie"),71    "auto_inscription": ("D", 0.7, "Atelier auto-inscrit via www.fabri-ka.com (validé manuellement avant publication)", None),72    # --- vague 5 (expansion sectorielle, scripts/wave5_discovery.py — 2026-08-21)73    "modemtl": ("A", 0.75, "Marque de mode locale répertoriée par ModeMTL (répertoire de la mode montréalaise)", "mode"),74    "boeufdici": ("A", 0.85, "Producteur membre du répertoire Bœuf d'ici (vente de bœuf à la ferme)", "viande_poisson"),75    "afmq": ("A", 0.85, "Fabricant de meubles membre de l'AFMQ (Association des fabricants de meubles du Québec)", "maison"),76    "apiculteursduquebec": ("A", 0.9, "Entreprise apicole membre des Apiculteurs et apicultrices du Québec (route du miel)", "erable"),77    "vendors_epipresto": ("E", 0.4, "Vendeur de la place de marché EPIPRESTO (épiceries et producteurs QC) — nature du vendeur à vérifier", None),78}7980# sources dont TOUS les membres sont québécois (une preuve QC suffit)81QC_ONLY_SOURCES = {82    "alimentsduquebec_ent", "lesproduitsduquebec_ent", "lesproduitsduquebec_det",83    "erabledici", "fromagesdici", "cidreduquebec", "vinsduquebec",84    "distilleriesduquebec", "ambq_profils", "zoneboreale", "createursdesaveurs",85    "croquezoutaouais", "arretsgourmands", "goutezy", "laurentidesjenmange",86    "goutcotenord", "gaspesiegourmande", "charlevoix", "wikipedia_lists",87    "acheterquebecois", "ideecadeauquebec", "icq_resolved", "signelocal",88    "saveursbsl", "goutezat", "search_sweep_cmaq", "search_sweep_vendors",89    "manual_additions",90    "mauriciemiam", "gardemangerduquebec", "gardemangerduquebec_det",91    "terroiretsaveurs",92    # vague 4 : répertoires exclusivement québécois (les vendors_collectifs93    # restent HORS liste : résolution de domaine heuristique -> preuve QC94    # exigée sur le site)95    "cartv_bio", "ctaq", "cibim", "canardduquebec", "lebongoutfraisdesiles",96    "acheterquebecois_mtl", "auto_inscription",97    # vague 5 : répertoires exclusivement québécois (vendors_epipresto reste98    # HORS liste : résolution de domaine heuristique + vendeurs hétérogènes99    # -> preuve QC exigée sur le site)100    "modemtl", "boeufdici", "afmq", "apiculteursduquebec",101}102# ⚠️ PAS dans la liste : artisansaloeuvre (réseau Économusée international),103# marchespublics (liens hétérogènes), search sweeps (vérification nécessaire),104# lespagesvertes (répertoire vert surtout QC mais sans garantie — signal QC105# sur le site exigé)106107# région certaine induite par la source (annuaires régionaux)108SOURCE_REGION = {109    "zoneboreale": "Saguenay–Lac-Saint-Jean",110    "createursdesaveurs": "Estrie",111    "croquezoutaouais": "Outaouais",112    "arretsgourmands": "Chaudière-Appalaches",113    "goutezy": "Centre-du-Québec",114    "laurentidesjenmange": "Laurentides",115    "goutcotenord": "Côte-Nord",116    "charlevoix": "Capitale-Nationale",117    "gaspesiegourmande": "Gaspésie–Îles-de-la-Madeleine",118    "saveursbsl": "Bas-Saint-Laurent",119    "goutezat": "Abitibi-Témiscamingue",120    "mauriciemiam": "Mauricie",121    "gardemangerduquebec": "Montérégie",122    "gardemangerduquebec_det": "Montérégie",123    "lebongoutfraisdesiles": "Gaspésie–Îles-de-la-Madeleine",124    "acheterquebecois_mtl": "Montréal",125    "modemtl": "Montréal",126}127128PRIORITY = ["modemtl", "boeufdici", "afmq", "apiculteursduquebec",129            "cartv_bio", "ctaq", "cibim", "canardduquebec", "lebongoutfraisdesiles",130            "erabledici", "fromagesdici", "cidreduquebec", "vinsduquebec",131            "distilleriesduquebec", "ambq_profils", "artisansaloeuvre",132            "alimentsduquebec_ent", "lesproduitsduquebec_ent",133            "zoneboreale", "createursdesaveurs", "croquezoutaouais",134            "arretsgourmands", "goutezy", "laurentidesjenmange", "goutcotenord",135            "gaspesiegourmande", "charlevoix", "mauriciemiam",136            "gardemangerduquebec", "search_sweep_cmaq", "terroiretsaveurs",137            "lesproduitsduquebec_det", "gardemangerduquebec_det",138            "signelocal", "wikipedia_lists",139            "saveursbsl", "goutezat", "search_sweep_vendors",140            "acheterquebecois", "ideecadeauquebec", "icq_resolved",141            "search_sweep_food", "search_sweep_nonfood", "search_sweep_regions",142            "search_sweep_urban", "search_sweep_manuf", "search_sweep_bijoux",143            "vendors_collectifs", "acheterquebecois_mtl",144            "retest_curl_2026_08_19", "auto_inscription",145            "lespagesvertes", "manual_additions", "vendors_epipresto",146            "marchespublics"]147148REGION_CANON = {149    "Saguenay": "Saguenay–Lac-Saint-Jean", "Lac-Saint-Jean": "Saguenay–Lac-Saint-Jean",150    "Montreal": "Montréal", "Abitibi": "Abitibi-Témiscamingue",151    "Témiscamingue": "Abitibi-Témiscamingue", "Gaspésie": "Gaspésie–Îles-de-la-Madeleine",152    "Îles-de-la-Madeleine": "Gaspésie–Îles-de-la-Madeleine",153    "Cantons-de-l'Est": "Estrie",154}155ALL_REGIONS = ["Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale",156               "Mauricie", "Estrie", "Montréal", "Outaouais", "Abitibi-Témiscamingue",157               "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine",158               "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides",159               "Montérégie", "Centre-du-Québec"]160161162try:163    _GEO = json.load(open(os.path.join(ENR, "geo_map.json")))164    FSA_MAP = {k: re.sub(r"\s*\(\d+\)$", "", v) for k, v in _GEO["fsa"].items()}165except FileNotFoundError:166    FSA_MAP = {}167168169def region_from_postal(cand, ver):170    for p in (cand.get("postal_prefix"), (ver.get("qc_postal") or "")[:3] or None):171        if p and p.upper() in FSA_MAP:172            return FSA_MAP[p.upper()]173    return ""174175176def pick_region(cand):177    for s in cand.get("sources", []):178        if s in SOURCE_REGION:179            return SOURCE_REGION[s]180    raw = cand.get("regions_mentioned", [])181    if len(raw) > 3:      # une page qui mentionne >3 régions = menu de navigation182        return ""183    regions = [REGION_CANON.get(r, r) for r in raw]184    regions = [r for r in regions if r in ALL_REGIONS]185    if not regions:186        return ""187    # préfère une région non-Montréal si plusieurs sont mentionnées (les pages188    # d'annuaires mentionnent souvent "Montréal" dans la navigation)189    non_mtl = [r for r in regions if r != "Montréal"]190    return (non_mtl or regions)[0]191192193def clean_name(cand, ver):194    for n in cand.get("names", []):195        n = n.strip()196        if 2 < len(n) < 80 and not n.lower().startswith(("répertoire", "aliments d", "produits appr")):197            return n198    t = (ver.get("title") or "").split("|")[0].split("–")[0].split("—")[0].strip()199    return t[:80] if t else cand["domain"]200201202def classify(cand, ver):203    """-> (class, confidence, evidence)"""204    src = next((s for s in PRIORITY if s in cand.get("sources", [])), None)205    if src:206        cls, conf, ev, _ = SOURCE_PRIORS[src]207    else:208        cls, conf, ev = "E", 0.3, "Découvert sans certification connue"209    if ver.get("made_in_qc_wording"):210        ev += " ; le site affiche une mention de fabrication québécoise"211        if cls in ("E", "C"):212            cls, conf = "A", max(conf, 0.6)213        else:214            conf = min(0.98, conf + 0.05)215    qc_signals = sum(bool(ver.get(k)) for k in ("qc_postal", "qc_phone", "tld_quebec", "mentions_quebec"))216    if qc_signals == 0 and cls != "E":217        conf = max(0.2, conf - 0.25)218        ev += " ; aucun signal de localisation québécoise détecté sur le site (à vérifier)"219    return cls, round(conf, 2), ev220221222def main():223    cands = {}224    with open(os.path.join(ENR, "candidates.jsonl")) as f:225        for line in f:226            c = json.loads(line)227            cands[c["domain"]] = c228    vers = {}229    with open(os.path.join(ENR, "verified.jsonl")) as f:230        for line in f:231            v = json.loads(line)232            vers[v["domain"]] = v233234    # Overrides manuels du registre précédent, préservés d'un rebuild à235    # l'autre (audits et réparations faits directement dans stores.json) :236    # - disabled_reason -> l'entrée reste désactivée (audit 2026-08-23 :237    #   91 boutiques Wix sans app Stores, un rebuild les réactivait toutes)238    # - timeout -> délai par boutique (ex. canadel.com : Store API à ~50 s/page)239    # - manual_platform: true -> plateforme/endpoint/url constatés à la main240    #   (ex. rose.ca migrée Woo -> Shopify boutique.rose.ca) prioritaires sur241    #   verified.jsonl tant que celui-ci n'est pas re-sondé242    prev_path = os.path.join(ROOT, "data", "stores.json")243    prev = {}244    if os.path.exists(prev_path):245        with open(prev_path) as f:246            prev = {s["id"]: s for s in json.load(f).get("stores", [])}247248    stores, seen_final = [], {}249    for dom, cand in sorted(cands.items()):250        ver = vers.get(dom, {})251        if not ver.get("active"):252            continue253        # canonicalise: redirections (ex: ancien domaine -> nouveau)254        final_dom = ver.get("final_domain") or dom255        if final_dom in seen_final:256            # fusionne la provenance dans l'entrée canonique257            for s in cand.get("sources", []):258                if s not in seen_final[final_dom]["discovery_sources"]:259                    seen_final[final_dom]["discovery_sources"].append(s)260            continue261        # garde-fou localisation : preuve QC sur le site OU source exclusivement QC262        qc_signal = any(ver.get(k) for k in ("qc_postal", "qc_phone", "tld_quebec",263                                             "mentions_quebec", "made_in_qc_wording"))264        qc_source = any(s in QC_ONLY_SOURCES for s in cand.get("sources", []))265        if not qc_signal and not qc_source:266            continue      # ex. économusées hors-Québec, liens externes non-QC267        cls, conf, ev = classify(cand, ver)268        default_cat = next((SOURCE_PRIORS[s][3] for s in PRIORITY269                            if s in cand.get("sources", []) and SOURCE_PRIORS[s][3]), None)270        platform = ver.get("platform") or ""271        catalog_endpoint = ver.get("catalog_endpoint") or ""272        if platform == "wix" and not catalog_endpoint:273            # connecteur Wix Stores : jeton public via /_api/v1/access-tokens274            catalog_endpoint = "/_api/wix-ecommerce-storefront-web/api"275        store = {276            "id": final_dom,277            "name": clean_name(cand, ver),278            # racine canonique : les endpoints catalogue sont relatifs à la racine279            "url": (lambda fu: f"{fu.scheme}://{fu.netloc}")(280                __import__("urllib.parse", fromlist=["urlparse"]).urlparse(281                    ver.get("final_url") or f"https://{final_dom}")),282            "platform": platform,283            "catalog_endpoint": catalog_endpoint,284            "city": "",285            "region": pick_region(cand) or region_from_postal(cand, ver),286            "postal_prefix": cand.get("postal_prefix") or (ver.get("qc_postal") or "")[:3] or None,287            "phone": cand.get("phone") or ver.get("qc_phone"),288            "origin_class": cls,289            "origin_confidence": conf,290            "origin_evidence": ev,291            "categories": [default_cat] if default_cat else [],292            "socials": (cand.get("socials") or [])[:4] or ver.get("socials", []),293            "discovery_sources": cand.get("sources", []),294            "discovery_source_urls": cand.get("source_pages", [])[:5],295            "language": ver.get("language"),296            "ecommerce": bool(ver.get("has_cart") or catalog_endpoint),297            "verification_date": ver.get("checked_at") or str(date.today()),298            "status": "verified" if (conf >= 0.6 and ver.get("mentions_quebec")) else "probable",299            "enabled": bool(catalog_endpoint),300        }301        old = prev.get(final_dom)302        if old:303            if old.get("disabled_reason"):304                store["enabled"] = False305                store["disabled_reason"] = old["disabled_reason"]306            if "timeout" in old:307                store["timeout"] = old["timeout"]308            if old.get("manual_platform"):309                store["manual_platform"] = True310                for k in ("platform", "catalog_endpoint", "url"):311                    if old.get(k):312                        store[k] = old[k]313                store["enabled"] = old.get("enabled", store["enabled"])314        seen_final[final_dom] = store315        stores.append(store)316317    out = {"generated": str(date.today()), "count": len(stores), "stores": stores}318    with open(os.path.join(ROOT, "data", "stores.json"), "w") as f:319        json.dump(out, f, ensure_ascii=False, indent=1)320321    n_conn = sum(1 for s in stores if s["catalog_endpoint"])322    n_ecom = sum(1 for s in stores if s["ecommerce"])323    by_class = {}324    for s in stores:325        by_class[s["origin_class"]] = by_class.get(s["origin_class"], 0) + 1326    by_region = {}327    for s in stores:328        by_region[s["region"] or "(inconnue)"] = by_region.get(s["region"] or "(inconnue)", 0) + 1329    print(f"registre: {len(stores)} boutiques actives | ecommerce: {n_ecom} | connectables: {n_conn}")330    print("classes:", json.dumps(by_class, ensure_ascii=False))331    print("régions:", json.dumps(dict(sorted(by_region.items(), key=lambda x: -x[1])), ensure_ascii=False, indent=1))332333334if __name__ == "__main__":335    main()336
Files · 9f0b379f746232cdbfc2b15ddcf9df30a5c71418
Menu

Theme