SPB Git forge

spb/food-ka

Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

55commits 1branches 0releases
10.2 MBsize
maindefault branch
10 days agolast push
Python 53.9% TypeScript 24% CSS 14.9% JavaScript 5.8% HTML 1.4%
13.7 KB · 277 lines python
Raw Blame History
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