# ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/_woocommerce.py : socle commun des épiceries sur WooCommerce # L'API publique "Store API" expose le catalogue en JSON sans clé : # /wp-json/wc/store/v1/products?per_page=100&page=N. Les prix y sont en # unités mineures (cents) — "1299" + currency_minor_unit=2 -> 12,99 $. # ----------------------------------------------------------------------------- from __future__ import annotations import html import re from ..schema import Product, normalize_category from .base import BaseConnector _TAG_RE = re.compile(r"<[^>]+>") # format dans le nom du produit : "Yogourt nature (650 g)", "Huile 2 x 750 ml" _SIZE_IN_NAME_RE = re.compile( r"(\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|mg|lb|lbs|oz|ml|cl|l|un|unités?)\b", re.IGNORECASE) def _strip_html(text: str | None, max_len: int = 500) -> str: """Retire balises et entités HTML d'un champ WooCommerce, tronque (~500).""" if not text: return "" clean = html.unescape(_TAG_RE.sub(" ", text)) clean = re.sub(r"\s+", " ", clean).strip() if len(clean) > max_len: clean = clean[:max_len].rsplit(" ", 1)[0] + "…" return clean def _minor(value: str | int | None, minor_unit: int) -> float | None: """Convertit un prix Store API (unités mineures) en dollars : "1299" -> 12.99.""" if value in (None, ""): return None try: return round(int(value) / (10 ** minor_unit), 2) except (TypeError, ValueError): return None class WooStoreConnector(BaseConnector): """Base des épiceries WooCommerce — sous-classes : source_id et domain.""" domain: str = "" # ex. "bocoboco.ca" max_pages: int = 8 # 100 produits/page per_page: int = 100 def _page(self, page: int) -> list[dict]: url = (f"https://{self.domain}/wp-json/wc/store/v1/products" f"?per_page={self.per_page}&page={page}") resp = self.get(url, headers={"Accept": "application/json"}) data = resp.json() return data if isinstance(data, list) else [] def _to_product(self, raw: dict) -> Product | None: pid = raw.get("id") name = html.unescape(_TAG_RE.sub("", raw.get("name") or "")).strip() if not pid or not name: return None prices = raw.get("prices") or {} minor_unit = int(prices.get("currency_minor_unit") or 2) price = _minor(prices.get("price"), minor_unit) on_sale = bool(raw.get("on_sale")) regular = _minor(prices.get("regular_price"), minor_unit) if on_sale else None if regular is not None and price is not None and regular <= price: regular = None # solde annoncé mais prix identiques : pas un rabais # taxonomie : première catégorie = catégorie brute, les autres en mots-clés cats = [c.get("name") or "" for c in raw.get("categories") or [] if isinstance(c, dict)] cats = [html.unescape(c).strip() for c in cats if c] category_raw = cats[0] if cats else "" keywords = cats[1:] # format : extrait du nom s'il y figure (ex. "Tofu ferme 454 g") m = _SIZE_IN_NAME_RE.search(name) size_label = m.group(0).strip() if m else "" return Product( source=self.source_id, external_id=str(pid), url=raw.get("permalink") or f"https://{self.domain}/?p={pid}", name=name, brand="", category=normalize_category(category_raw), category_raw=category_raw, size_label=size_label, price=price, regular_price=regular, on_sale=on_sale and regular is not None, in_stock=(bool(raw["is_in_stock"]) if raw.get("is_in_stock") is not None else None), description=_strip_html(raw.get("short_description") or raw.get("description")), keywords=keywords, images=[img["src"] for img in raw.get("images") or [] if isinstance(img, dict) and img.get("src")], ) # -- contrat ------------------------------------------------------------------ def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() for page in range(1, self.max_pages + 1): try: batch = self._page(page) except Exception: # fin de pagination ou site en dérangement break if not batch: break for raw in batch: try: prod = self._to_product(raw) except Exception: continue if prod and prod.uid not in seen: seen.add(prod.uid) products.append(prod) return products