Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 53.9%
TypeScript 24%
CSS 14.9%
JavaScript 5.8%
HTML 1.4%
1# -----------------------------------------------------------------------------2# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/_flipp.py : socle commun des bannières « circulaire seulement »5# Beaucoup d'épiceries de proximité (affiliés Metro et Sobeys) n'ont aucun6# catalogue en ligne : leur seule donnée de prix est la circulaire hebdo,7# hébergée sur Flipp. Le backend Flipp (backflipp.wishabi.com) est une API8# JSON publique sans clé ni anti-bot :9# 1. /flipp/flyers?locale=fr-ca&postal_code=… -> circulaires de la zone10# (filtrer côté client sur merchant_id — le paramètre d'URL ne filtre pas)11# 2. /flipp/flyers/{flyer_id}?locale=fr-ca -> items avec prix12# Une seule circulaire provinciale par bannière par semaine (vérifié sur13# 4 codes postaux) : un poste montréalais suffit. Le flyer_id change chaque14# semaine — toujours résolu dynamiquement via l'étape 1.15# Les items n'ont ni SKU ni catégorie ni unité (« /lb ») : external_id =16# slug stable marque+nom+format (continuité du price_log d'une semaine à17# l'autre), catégorie déduite du nom du produit.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import re2223from ..schema import Product, clean_text, strip_accents24from .base import BaseConnector2526LIST_URL = ("https://backflipp.wishabi.com/flipp/flyers"27 "?locale=fr-ca&postal_code={postal}")28FLYER_URL = "https://backflipp.wishabi.com/flipp/flyers/{flyer_id}?locale=fr-ca"29ITEM_URL = "https://backflipp.wishabi.com/flipp/items/{item_id}?locale=fr-ca"3031# format dans le nom : « (550 à 675 g) », « 2 L », « 9,1 ou 12,7 kg »…32_SIZE_IN_NAME = re.compile(33 r"((?:\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b",34 re.IGNORECASE)35_SLUG_RE = re.compile(r"[^a-z0-9]+")3637# Catégorisation par mots du nom (les items Flipp n'ont pas de taxonomie).38# Motifs à frontière de mot sur le nom FRANÇAIS sans accents ; l'ordre compte39# (les motifs composés/désambiguïsants d'abord). Faute de correspondance :40# default_category du flyer, sinon « Autres ».41_NAME_RULES: list[tuple[re.Pattern, str]] = [(re.compile(rf"\b(?:{pat})\b"), cat) for pat, cat in [42 # désambiguïsations prioritaires43 (r"eaux? de (?:parfum|toilette|cologne)|parfums?|cremes? adhesives?|"44 r"cremes? (?:pour )?(?:le )?(?:corps|mains?|visage)", "Santé et beauté"),45 (r"eaux? de javel|javel", "Maison et entretien"),46 (r"beurres? d.arachides?|tartinades?|vinaigre\w*", "Garde-manger"),47 (r"cremes? glacees?|sorbets?|surgele\w*|congele\w*|pizzas?", "Surgelés"),48 (r"chocolat\w*|croustilles?|chips|biscuits?|bonbons?|craquelins?|"49 r"barres? tendres|popcorn|mais souffle|arachides?|noix|gommes?|"50 r"friandises?|collations?", "Collations et confiseries"),51 (r"jus|eaux?|boissons?|cafes?|thes?|tisanes?|sodas?|cola|bieres?|vins?|"52 r"cidres?|limonades?|smoothies?|kombucha", "Boissons"),53 (r"fromages?|cheddar|mozzarella|feta|brie|parmesan|jambons?|salami|"54 r"pepperoni|prosciutto|charcuteries?", "Charcuteries et fromages"),55 (r"lait|laits|yogourts?|oeufs?|margarines?|beurres?|cremes?",56 "Produits laitiers et œufs"),57 (r"saumons?|truites?|tilapia|morue|aiglefin|sole|thons?|crevettes?|"58 r"homards?|petoncles?|poissons?|fruits de mer|calmars?",59 "Poissons et fruits de mer"),60 (r"boeuf|poulets?|porc|veau|agneau|dindes?|poitrines?|cuisses?|"61 r"biftecks?|steaks?|cotelettes?|saucisses?|tournedos|rotis?|hache\w*|"62 r"bacon|viandes?", "Viandes et volailles"),63 (r"pains?|baguettes?|bagels?|muffins?|tortillas?|croissants?|brioches?|"64 r"gateau\w*|tarte\w*|patisseries?", "Boulangerie"),65 (r"peches?|pommes?|bananes?|fraises?|framboises?|bleuets?|raisins?|"66 r"melons?|ananas|celeris?|laitues?|salades?|carottes?|brocolis?|"67 r"chou\w*|poivrons?|tomates?|concombres?|oignons?|patates?|mais|"68 r"avocats?|citrons?|limes?|cerises?|prunes?|poires?|mangues?|kiwis?|"69 r"champignons?|epinards?|asperges?|courge\w*|legumes?|fruits?",70 "Fruits et légumes"),71 (r"cereales?|pates?|riz|sauces?|conserves?|soupes?|bouillons?|huiles?|"72 r"vinaigre\w*|farines?|sucres?|ketchup|mayonnaise|moutarde|confitures?|"73 r"miel|gruau|granola|tofu|legumineuses?|epices?", "Garde-manger"),74 (r"repas|sushis?|sandwich\w*|pret[- ]a[- ]manger", "Prêt-à-manger"),75 (r"vitamines?|supplements?|proteines?|probiotiques?|collagene|curcuma|"76 r"chlorophylle|omega", "Bio et santé"),77 (r"couches?|bebes?", "Bébé"),78 (r"chats?|chiens?|litieres?|animaux", "Animaux"),79 (r"papiers?|essuie[- ]tout|lessives?|detergents?|savons?|nettoyants?|"80 r"javel|assouplissants?", "Maison et entretien"),81 (r"shampoing\w*|shampooing\w*|dentifrices?|deodorants?|hygiene",82 "Santé et beauté"),83]]848586def _categorize(name_fr: str, default: str) -> str:87 key = strip_accents(name_fr).lower()88 for rx, cat in _NAME_RULES:89 if rx.search(key):90 return cat91 return default or "Autres"929394# Rayons « épicerie » de la taxonomie canonique — pour les bannières dont la95# circulaire mêle alimentaire et non-alimentaire (pharmacies : cosmétiques,96# médicaments…), keep_categories = GROCERY_CATEGORIES ne retient que le97# alimentaire. Conservateur : un item non catégorisé (« Autres ») est écarté.98GROCERY_CATEGORIES = frozenset({99 "Fruits et légumes", "Viandes et volailles", "Poissons et fruits de mer",100 "Charcuteries et fromages", "Produits laitiers et œufs", "Boulangerie",101 "Surgelés", "Garde-manger", "Boissons", "Collations et confiseries",102 "Prêt-à-manger",103})104105106class FlippConnector(BaseConnector):107 """Base des circulaires Flipp — sous-classes : source_id, merchant_id,108 flyer_page (URL humaine de la circulaire, pour le lien produit).109110 `default_categories` associe un nom de flyer à une catégorie par défaut111 (ex. Rachelle-Béry « Espace Santé » -> « Bio et santé »)."""112113 merchant_id: int = 0114 postal_code: str = "H2X1Y4" # Montréal — capte les flyers provinciaux115 flyer_page: str = ""116 default_categories: dict[str, str] = {}117 # Certaines bannières publient PLUSIEURS circulaires sous le même118 # merchant_id (ex. Supermarché PA : « Weekly Flyer » + « Nature Flyer »).119 # Si non vide, seuls les flyers dont le nom contient ce texte (insensible120 # à la casse) sont retenus.121 flyer_name_filter: str = ""122 # Si non vide : ne garder que les items dont la catégorie déduite est dans123 # cet ensemble (ex. GROCERY_CATEGORIES pour les circulaires de pharmacie).124 keep_categories: frozenset[str] = frozenset()125126 # -- récupération -----------------------------------------------------------127 def _flyers(self) -> list[dict]:128 """Circulaires courantes de la bannière (résolution hebdo du flyer_id)."""129 data = self.get(LIST_URL.format(postal=self.postal_code),130 headers={"Accept": "application/json"}).json()131 flyers = [f for f in (data.get("flyers") or [])132 if f.get("merchant_id") == self.merchant_id]133 if self.flyer_name_filter:134 needle = self.flyer_name_filter.lower()135 flyers = [f for f in flyers136 if needle in str(f.get("name") or "").lower()]137 # Grandes bannières : Flipp publie souvent la semaine COURANTE et la138 # semaine À VENIR. On trie par valid_from croissant : en cas de slug139 # dupliqué entre les deux semaines, le prix de la semaine courante140 # gagne (le pipeline garde la première occurrence d'un uid).141 flyers.sort(key=lambda f: str(f.get("valid_from") or ""))142 return flyers143144 def fetch(self) -> list[Product]:145 products: list[Product] = []146 for flyer in self._flyers():147 data = self.get(FLYER_URL.format(flyer_id=flyer["id"]),148 headers={"Accept": "application/json"}).json()149 for item in data.get("items") or []:150 prod = self._item_to_product(item, flyer)151 if prod is not None:152 products.append(prod)153 return products154155 # -- extraction d'un item ----------------------------------------------------156 def _item_to_product(self, item: dict, flyer: dict) -> Product | None:157 price = self._price(item.get("price"))158 if price is None: # vignette non-produit (« Scene offre ») ou multi-format159 return None160161 # nom bilingue « PÊCHES ONTARIO | ONTARIO PEACHES » -> FR + EN en mot-clé162 parts = [clean_text(p) for p in str(item.get("name") or "").split("|")]163 parts = [p for p in parts if p]164 if not parts:165 return None166 name_fr, keywords = parts[0], parts[1:]167168 brand = clean_text(item.get("brand") or "").replace(" | ", " / ")169 if brand and brand == brand.lower(): # Flipp met parfois tout en minuscules170 brand = brand.title()171172 size_m = _SIZE_IN_NAME.search(name_fr)173 flyer_name = clean_text(flyer.get("name") or "")174 default_cat = self.default_categories.get(flyer_name, "")175 external_id = self._slug(brand, name_fr, size_m.group(1) if size_m else "")176177 category = _categorize(name_fr, default_cat)178 # Filtre par rayon AVANT l'appel fiche item : les items écartés179 # (non alimentaires) ne coûtent aucune requête détail.180 if self.keep_categories and category not in self.keep_categories:181 return None182183 # fiche item Flipp (petit texte de la vignette, prix régulier, % de184 # rabais) — mise en cache par id d'item Flipp : l'id change quand la185 # circulaire change, donc un seul appel par item et par semaine.186 detail: dict = {}187 try:188 detail = self.detail(external_id, str(item.get("id") or ""),189 lambda: self._item_detail(item.get("id")))190 except Exception: # la fiche est un bonus, jamais bloquante191 detail = {}192193 regular = self._price(detail.get("original_price"))194 if regular is not None and regular <= price:195 regular = None196197 return Product(198 source=self.source_id,199 external_id=external_id,200 url=self.flyer_page,201 name=name_fr,202 brand=brand,203 category=category,204 category_raw=flyer_name,205 size_label=size_m.group(1) if size_m else "",206 price=price,207 regular_price=regular,208 price_label=f"{item.get('price')} $",209 on_sale=True, # une circulaire = les spéciaux de la semaine210 keywords=keywords,211 details={k: v for k, v in {212 "flyer_id": flyer.get("id"),213 "flyer_name": flyer_name,214 "valid_from": item.get("valid_from") or flyer.get("valid_from"),215 "valid_to": item.get("valid_to") or flyer.get("valid_to"),216 "discount_pct": item.get("discount") or detail.get("percent_off"),217 "dollars_off": detail.get("dollars_off"),218 "fine_print": self._fine_print(item, detail) or None,219 }.items() if v is not None},220 images=[u.replace("http://", "https://")221 for u in [item.get("cutout_image_url")] if u],222 )223224 def _item_detail(self, item_id) -> dict:225 """Fiche item Flipp (/flipp/items/{id}) réduite aux champs utiles.226227 C'est là que vivent le petit texte de la vignette (description,228 « +tx », limites/disclaimer) et le prix régulier (original_price).229 """230 if not item_id:231 return {}232 data = self.get(ITEM_URL.format(item_id=item_id),233 headers={"Accept": "application/json"}).json()234 it = data.get("item") or data or {}235 keep = ("description", "price_text", "disclaimer_text", "sale_story",236 "original_price", "percent_off", "dollars_off")237 return {k: it.get(k) for k in keep if it.get(k) not in (None, "", [])}238239 @staticmethod240 def _fine_print(item: dict, detail: dict | None = None) -> str:241 """Petit texte de la vignette (limites, « +tx », « Sans carte… ») :242 text_areas de l'item + champs texte de la fiche /flipp/items/{id},243 dédupliqués, en une seule chaîne lisible."""244 bits: list[str] = []245 for ta in item.get("text_areas") or []:246 if isinstance(ta, str):247 bits.append(ta)248 elif isinstance(ta, dict):249 txt = ta.get("text") or ta.get("value") or ""250 if txt:251 bits.append(str(txt))252 for src in (item, detail or {}):253 for key in ("description", "price_text", "disclaimer_text",254 "sale_story", "pre_price_text", "post_price_text"):255 val = src.get(key)256 if val:257 bits.append(str(val))258 cleaned = [clean_text(b) for b in bits]259 uniq = list(dict.fromkeys(b for b in cleaned if b))260 return " · ".join(uniq)[:300]261262 @staticmethod263 def _price(raw) -> float | None:264 """« 5.99 », « 0.3 », 5.99 -> float ; vide/0 -> None (jamais inventé)."""265 try:266 value = float(str(raw).strip().replace(",", "."))267 except (TypeError, ValueError):268 return None269 return value if 0 < value <= 2000 else None270271 @staticmethod272 def _slug(brand: str, name_fr: str, size: str) -> str:273 """Identifiant stable d'une semaine à l'autre (l'id d'item Flipp change) :274 le même produit garde le même uid et son historique price_log."""275 raw = strip_accents(f"{brand} {name_fr} {size}").lower()276 return _SLUG_RE.sub("-", raw).strip("-")[:80]277