# ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/_flipp.py : socle commun des bannières « circulaire seulement » # Beaucoup d'épiceries de proximité (affiliés Metro et Sobeys) n'ont aucun # catalogue en ligne : leur seule donnée de prix est la circulaire hebdo, # hébergée sur Flipp. Le backend Flipp (backflipp.wishabi.com) est une API # JSON publique sans clé ni anti-bot : # 1. /flipp/flyers?locale=fr-ca&postal_code=… -> circulaires de la zone # (filtrer côté client sur merchant_id — le paramètre d'URL ne filtre pas) # 2. /flipp/flyers/{flyer_id}?locale=fr-ca -> items avec prix # Une seule circulaire provinciale par bannière par semaine (vérifié sur # 4 codes postaux) : un poste montréalais suffit. Le flyer_id change chaque # semaine — toujours résolu dynamiquement via l'étape 1. # Les items n'ont ni SKU ni catégorie ni unité (« /lb ») : external_id = # slug stable marque+nom+format (continuité du price_log d'une semaine à # l'autre), catégorie déduite du nom du produit. # ----------------------------------------------------------------------------- from __future__ import annotations import re from ..schema import Product, clean_text, strip_accents from .base import BaseConnector LIST_URL = ("https://backflipp.wishabi.com/flipp/flyers" "?locale=fr-ca&postal_code={postal}") FLYER_URL = "https://backflipp.wishabi.com/flipp/flyers/{flyer_id}?locale=fr-ca" ITEM_URL = "https://backflipp.wishabi.com/flipp/items/{item_id}?locale=fr-ca" # format dans le nom : « (550 à 675 g) », « 2 L », « 9,1 ou 12,7 kg »… _SIZE_IN_NAME = re.compile( r"((?:\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b", re.IGNORECASE) _SLUG_RE = re.compile(r"[^a-z0-9]+") # Catégorisation par mots du nom (les items Flipp n'ont pas de taxonomie). # Motifs à frontière de mot sur le nom FRANÇAIS sans accents ; l'ordre compte # (les motifs composés/désambiguïsants d'abord). Faute de correspondance : # default_category du flyer, sinon « Autres ». _NAME_RULES: list[tuple[re.Pattern, str]] = [(re.compile(rf"\b(?:{pat})\b"), cat) for pat, cat in [ # désambiguïsations prioritaires (r"eaux? de (?:parfum|toilette|cologne)|parfums?|cremes? adhesives?|" r"cremes? (?:pour )?(?:le )?(?:corps|mains?|visage)", "Santé et beauté"), (r"eaux? de javel|javel", "Maison et entretien"), (r"beurres? d.arachides?|tartinades?|vinaigre\w*", "Garde-manger"), (r"cremes? glacees?|sorbets?|surgele\w*|congele\w*|pizzas?", "Surgelés"), (r"chocolat\w*|croustilles?|chips|biscuits?|bonbons?|craquelins?|" r"barres? tendres|popcorn|mais souffle|arachides?|noix|gommes?|" r"friandises?|collations?", "Collations et confiseries"), (r"jus|eaux?|boissons?|cafes?|thes?|tisanes?|sodas?|cola|bieres?|vins?|" r"cidres?|limonades?|smoothies?|kombucha", "Boissons"), (r"fromages?|cheddar|mozzarella|feta|brie|parmesan|jambons?|salami|" r"pepperoni|prosciutto|charcuteries?", "Charcuteries et fromages"), (r"lait|laits|yogourts?|oeufs?|margarines?|beurres?|cremes?", "Produits laitiers et œufs"), (r"saumons?|truites?|tilapia|morue|aiglefin|sole|thons?|crevettes?|" r"homards?|petoncles?|poissons?|fruits de mer|calmars?", "Poissons et fruits de mer"), (r"boeuf|poulets?|porc|veau|agneau|dindes?|poitrines?|cuisses?|" r"biftecks?|steaks?|cotelettes?|saucisses?|tournedos|rotis?|hache\w*|" r"bacon|viandes?", "Viandes et volailles"), (r"pains?|baguettes?|bagels?|muffins?|tortillas?|croissants?|brioches?|" r"gateau\w*|tarte\w*|patisseries?", "Boulangerie"), (r"peches?|pommes?|bananes?|fraises?|framboises?|bleuets?|raisins?|" r"melons?|ananas|celeris?|laitues?|salades?|carottes?|brocolis?|" r"chou\w*|poivrons?|tomates?|concombres?|oignons?|patates?|mais|" r"avocats?|citrons?|limes?|cerises?|prunes?|poires?|mangues?|kiwis?|" r"champignons?|epinards?|asperges?|courge\w*|legumes?|fruits?", "Fruits et légumes"), (r"cereales?|pates?|riz|sauces?|conserves?|soupes?|bouillons?|huiles?|" r"vinaigre\w*|farines?|sucres?|ketchup|mayonnaise|moutarde|confitures?|" r"miel|gruau|granola|tofu|legumineuses?|epices?", "Garde-manger"), (r"repas|sushis?|sandwich\w*|pret[- ]a[- ]manger", "Prêt-à-manger"), (r"vitamines?|supplements?|proteines?|probiotiques?|collagene|curcuma|" r"chlorophylle|omega", "Bio et santé"), (r"couches?|bebes?", "Bébé"), (r"chats?|chiens?|litieres?|animaux", "Animaux"), (r"papiers?|essuie[- ]tout|lessives?|detergents?|savons?|nettoyants?|" r"javel|assouplissants?", "Maison et entretien"), (r"shampoing\w*|shampooing\w*|dentifrices?|deodorants?|hygiene", "Santé et beauté"), ]] def _categorize(name_fr: str, default: str) -> str: key = strip_accents(name_fr).lower() for rx, cat in _NAME_RULES: if rx.search(key): return cat return default or "Autres" # Rayons « épicerie » de la taxonomie canonique — pour les bannières dont la # circulaire mêle alimentaire et non-alimentaire (pharmacies : cosmétiques, # médicaments…), keep_categories = GROCERY_CATEGORIES ne retient que le # alimentaire. Conservateur : un item non catégorisé (« Autres ») est écarté. GROCERY_CATEGORIES = frozenset({ "Fruits et légumes", "Viandes et volailles", "Poissons et fruits de mer", "Charcuteries et fromages", "Produits laitiers et œufs", "Boulangerie", "Surgelés", "Garde-manger", "Boissons", "Collations et confiseries", "Prêt-à-manger", }) class FlippConnector(BaseConnector): """Base des circulaires Flipp — sous-classes : source_id, merchant_id, flyer_page (URL humaine de la circulaire, pour le lien produit). `default_categories` associe un nom de flyer à une catégorie par défaut (ex. Rachelle-Béry « Espace Santé » -> « Bio et santé »).""" merchant_id: int = 0 postal_code: str = "H2X1Y4" # Montréal — capte les flyers provinciaux flyer_page: str = "" default_categories: dict[str, str] = {} # Certaines bannières publient PLUSIEURS circulaires sous le même # merchant_id (ex. Supermarché PA : « Weekly Flyer » + « Nature Flyer »). # Si non vide, seuls les flyers dont le nom contient ce texte (insensible # à la casse) sont retenus. flyer_name_filter: str = "" # Si non vide : ne garder que les items dont la catégorie déduite est dans # cet ensemble (ex. GROCERY_CATEGORIES pour les circulaires de pharmacie). keep_categories: frozenset[str] = frozenset() # -- récupération ----------------------------------------------------------- def _flyers(self) -> list[dict]: """Circulaires courantes de la bannière (résolution hebdo du flyer_id).""" data = self.get(LIST_URL.format(postal=self.postal_code), headers={"Accept": "application/json"}).json() flyers = [f for f in (data.get("flyers") or []) if f.get("merchant_id") == self.merchant_id] if self.flyer_name_filter: needle = self.flyer_name_filter.lower() flyers = [f for f in flyers if needle in str(f.get("name") or "").lower()] # Grandes bannières : Flipp publie souvent la semaine COURANTE et la # semaine À VENIR. On trie par valid_from croissant : en cas de slug # dupliqué entre les deux semaines, le prix de la semaine courante # gagne (le pipeline garde la première occurrence d'un uid). flyers.sort(key=lambda f: str(f.get("valid_from") or "")) return flyers def fetch(self) -> list[Product]: products: list[Product] = [] for flyer in self._flyers(): data = self.get(FLYER_URL.format(flyer_id=flyer["id"]), headers={"Accept": "application/json"}).json() for item in data.get("items") or []: prod = self._item_to_product(item, flyer) if prod is not None: products.append(prod) return products # -- extraction d'un item ---------------------------------------------------- def _item_to_product(self, item: dict, flyer: dict) -> Product | None: price = self._price(item.get("price")) if price is None: # vignette non-produit (« Scene offre ») ou multi-format return None # nom bilingue « PÊCHES ONTARIO | ONTARIO PEACHES » -> FR + EN en mot-clé parts = [clean_text(p) for p in str(item.get("name") or "").split("|")] parts = [p for p in parts if p] if not parts: return None name_fr, keywords = parts[0], parts[1:] brand = clean_text(item.get("brand") or "").replace(" | ", " / ") if brand and brand == brand.lower(): # Flipp met parfois tout en minuscules brand = brand.title() size_m = _SIZE_IN_NAME.search(name_fr) flyer_name = clean_text(flyer.get("name") or "") default_cat = self.default_categories.get(flyer_name, "") external_id = self._slug(brand, name_fr, size_m.group(1) if size_m else "") category = _categorize(name_fr, default_cat) # Filtre par rayon AVANT l'appel fiche item : les items écartés # (non alimentaires) ne coûtent aucune requête détail. if self.keep_categories and category not in self.keep_categories: return None # fiche item Flipp (petit texte de la vignette, prix régulier, % de # rabais) — mise en cache par id d'item Flipp : l'id change quand la # circulaire change, donc un seul appel par item et par semaine. detail: dict = {} try: detail = self.detail(external_id, str(item.get("id") or ""), lambda: self._item_detail(item.get("id"))) except Exception: # la fiche est un bonus, jamais bloquante detail = {} regular = self._price(detail.get("original_price")) if regular is not None and regular <= price: regular = None return Product( source=self.source_id, external_id=external_id, url=self.flyer_page, name=name_fr, brand=brand, category=category, category_raw=flyer_name, size_label=size_m.group(1) if size_m else "", price=price, regular_price=regular, price_label=f"{item.get('price')} $", on_sale=True, # une circulaire = les spéciaux de la semaine keywords=keywords, details={k: v for k, v in { "flyer_id": flyer.get("id"), "flyer_name": flyer_name, "valid_from": item.get("valid_from") or flyer.get("valid_from"), "valid_to": item.get("valid_to") or flyer.get("valid_to"), "discount_pct": item.get("discount") or detail.get("percent_off"), "dollars_off": detail.get("dollars_off"), "fine_print": self._fine_print(item, detail) or None, }.items() if v is not None}, images=[u.replace("http://", "https://") for u in [item.get("cutout_image_url")] if u], ) def _item_detail(self, item_id) -> dict: """Fiche item Flipp (/flipp/items/{id}) réduite aux champs utiles. C'est là que vivent le petit texte de la vignette (description, « +tx », limites/disclaimer) et le prix régulier (original_price). """ if not item_id: return {} data = self.get(ITEM_URL.format(item_id=item_id), headers={"Accept": "application/json"}).json() it = data.get("item") or data or {} keep = ("description", "price_text", "disclaimer_text", "sale_story", "original_price", "percent_off", "dollars_off") return {k: it.get(k) for k in keep if it.get(k) not in (None, "", [])} @staticmethod def _fine_print(item: dict, detail: dict | None = None) -> str: """Petit texte de la vignette (limites, « +tx », « Sans carte… ») : text_areas de l'item + champs texte de la fiche /flipp/items/{id}, dédupliqués, en une seule chaîne lisible.""" bits: list[str] = [] for ta in item.get("text_areas") or []: if isinstance(ta, str): bits.append(ta) elif isinstance(ta, dict): txt = ta.get("text") or ta.get("value") or "" if txt: bits.append(str(txt)) for src in (item, detail or {}): for key in ("description", "price_text", "disclaimer_text", "sale_story", "pre_price_text", "post_price_text"): val = src.get(key) if val: bits.append(str(val)) cleaned = [clean_text(b) for b in bits] uniq = list(dict.fromkeys(b for b in cleaned if b)) return " · ".join(uniq)[:300] @staticmethod def _price(raw) -> float | None: """« 5.99 », « 0.3 », 5.99 -> float ; vide/0 -> None (jamais inventé).""" try: value = float(str(raw).strip().replace(",", ".")) except (TypeError, ValueError): return None return value if 0 < value <= 2000 else None @staticmethod def _slug(brand: str, name_fr: str, size: str) -> str: """Identifiant stable d'une semaine à l'autre (l'id d'item Flipp change) : le même produit garde le même uid et son historique price_log.""" raw = strip_accents(f"{brand} {name_fr} {size}").lower() return _SLUG_RE.sub("-", raw).strip("-")[:80]