Agrégateur de produits québécois — www.fabri-ka.com
- 385commits
- 1branches
- 0releases
- 89.9 MBon disk
- maindefault branch
- 4 days agolast push
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