spb/food-ka Public
Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 57.7%
TypeScript 24.9%
CSS 16.7%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Food-Ka — connecteur Akhavan (akhavanfood.com — supermarché moyen-oriental,3# Montréal / NDG)4# Auteur : Simon-Pierre Boucher — contact@spboucher.ai5# WooCommerce : l'API Store publique (/wp-json/wc/store/v1/products) répond6# sans authentification — noms, prix (en cents, minor units), solde, stock,7# catégories et images. ~700 produits au total ; on pagine par 100 avec un8# plafond max_pages pour garder la synchro modeste. Aucun rendu JS requis.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import html as _html13import re1415from bs4 import BeautifulSoup1617from ..schema import Product, normalize_category1819BASE = "https://akhavanfood.com"20API = f"{BASE}/wp-json/wc/store/v1/products"2122from .base import BaseConnector2324# format dans le nom : « Mix Shoor – Kambiz – 670 g (شور مخلوط) » -> « 670 g »25_SIZE_IN_NAME_RE = re.compile(26 r"(\d+(?:[.,]\d+)?)\s*(kg|g|gr|ml|l|lb|lbs|oz)\b", re.IGNORECASE)272829def _minor(value: str | None, unit: int) -> float | None:30 """Prix WooCommerce Store API : chaîne en unités mineures (« 599 » = 5,99 $)."""31 if not value:32 return None33 try:34 price = int(value) / (10 ** unit)35 except ValueError:36 return None37 return price if price > 0 else None383940class AkhavanConnector(BaseConnector):41 source_id = "akhavan"4243 per_page: int = 10044 max_pages: int = 4 # 4 x 100 = ~400 produits par synchro4546 def _parse_item(self, item: dict) -> Product | None:47 name = _html.unescape(item.get("name") or "").strip()48 if not name or not item.get("id"):49 return None5051 prices = item.get("prices") or {}52 unit = int(prices.get("currency_minor_unit") or 2)53 price = _minor(prices.get("price"), unit)54 regular = _minor(prices.get("regular_price"), unit)55 if price is None:56 return None # pas de prix affiché = produit inutilisable5758 cats = [_html.unescape(c.get("name") or "")59 for c in (item.get("categories") or [])]60 category_raw = " / ".join(c for c in cats if c)6162 brands = item.get("brands") or []63 brand = _html.unescape(brands[0].get("name") or "") if brands else ""6465 m = _SIZE_IN_NAME_RE.search(name)66 size_label = ""67 if m:68 u = m.group(2).lower()69 size_label = f"{m.group(1)} {'g' if u == 'gr' else u}"7071 desc_html = item.get("short_description") or ""72 description = BeautifulSoup(desc_html, "html.parser").get_text(73 " ", strip=True) if desc_html else ""7475 images = [img.get("src") for img in (item.get("images") or [])76 if img.get("src")]7778 return Product(79 source=self.source_id,80 external_id=str(item["id"]),81 url=item.get("permalink") or f"{BASE}/?p={item['id']}",82 name=name,83 brand=brand,84 category=normalize_category(category_raw),85 category_raw=category_raw,86 size_label=size_label,87 price=price,88 regular_price=regular if regular and price and regular > price else None,89 on_sale=bool(item.get("on_sale")),90 in_stock=item.get("is_in_stock"),91 description=description,92 images=images[:3],93 )9495 def fetch(self) -> list[Product]:96 products: list[Product] = []97 seen: set[str] = set()98 for page in range(1, self.max_pages + 1):99 try:100 data = self.get(101 API, params={"per_page": self.per_page, "page": page}).json()102 except Exception: # une page qui casse ne bloque pas le reste103 break104 if not isinstance(data, list) or not data:105 break106 for item in data:107 prod = self._parse_item(item)108 if prod is not None and prod.uid not in seen:109 seen.add(prod.uid)110 products.append(prod)111 if len(data) < self.per_page:112 break113 return products114