Public Access
Scraper: remove price guard in map_product so produce items without a catalog price (e.g. Fresh Garlic, Lime sold by weight) are saved to grocery_item with current_price=NULL rather than skipped. Matcher: - Add exact-name fast path: build a lowercase-trimmed name→index map and skip fuzzy search entirely when the ingredient name matches a grocery item exactly. Lime → Lime (confidence 1.0), Garlic → Fresh Garlic from fuzzy (confidence 1.0). - Add exclusion words: juice, gelatin to prevent beverage/dessert products from matching cooking ingredients. - Increase fuzzy candidate limit 20→100 so exact-name items buried in large tie groups are not missed. - Add 'juice' to exclusion: prevents '100% Lime Juice' from winning over plain 'Lime'. Result: all recipe ingredients now match correct Lucky CA products or show '—' (no match); zero category cross-contamination remaining. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
330 lines
12 KiB
Python
330 lines
12 KiB
Python
"""Lucky California / Swiftly product API client.
|
|
|
|
Replaces the prior Playwright + HTML coupon-card path (R2-A) with the
|
|
underlying JSON API the website itself calls. The API exposes the full
|
|
inventory per category — no rendering, no Chromium, and far more items
|
|
than the visible coupon strip (256 in `Product/meat_seafood` vs the 11
|
|
the HTML parser saw).
|
|
|
|
Two endpoints (no public docs; reverse-engineered from the network panel
|
|
on luckysupermarkets.com — see ``.agent/context.md`` "Swiftly API"):
|
|
|
|
GET https://luckysupermarkets.com/categories
|
|
→ HTML page; anchors with ``class="swiftlyCouponCategory"`` carry
|
|
``href="/categories/Product%2F<slug>"``.
|
|
|
|
GET https://prod.swiftlyapi.net/search/api/v1/products/categories
|
|
?cat=<slug>&store=<store_id>&limit=10000
|
|
Authorization: Bearer <jwt>
|
|
→ ``{"products": {"info": {...}, "items": [...], "facets": [...]}}``
|
|
|
|
The bearer JWT is minted on demand via ``swiftly_auth.get_token()``
|
|
(Firebase REST anon-signUp; cached for ~55min/hour). On 401 — which
|
|
should not happen with a freshly minted token — we raise
|
|
``SwiftlyAuthError`` so the background runner records an error_message
|
|
pointing at the auto-mint spec.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import time
|
|
import urllib.parse
|
|
from datetime import datetime
|
|
from decimal import Decimal, InvalidOperation
|
|
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
|
|
|
import requests
|
|
from bs4 import BeautifulSoup
|
|
|
|
from app.config import settings
|
|
from app.services.swiftly_auth import (
|
|
SwiftlyAuthMintError,
|
|
get_token as _get_swiftly_token,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SwiftlyAuthError(Exception):
|
|
"""Raised when the Swiftly API returns 401.
|
|
|
|
The exception message is surfaced verbatim to the ScrapeLog row by
|
|
``_run_scrape_in_background``; keep it actionable.
|
|
"""
|
|
|
|
|
|
_USER_AGENT = (
|
|
"MealPlannerBot/1.0 (+https://mealplanner.local; contact peter@research.bike)"
|
|
)
|
|
|
|
_AUTH_ERROR_MESSAGE = (
|
|
"Swiftly returned 401 with a freshly minted Firebase JWT — Lucky may have "
|
|
"tightened anon-auth restrictions. See docs/specs/2026-05-06-swiftly-token-auto-mint.md."
|
|
)
|
|
|
|
|
|
class LuckyCaliforniaScraper:
|
|
"""Swiftly product-API client for the Lucky California banner.
|
|
|
|
The class name is preserved (``LuckyCaliforniaScraper``) so existing
|
|
import sites in ``app.scraper.__init__`` and
|
|
``ScraperService._do_scrape`` keep working. Internally it is no
|
|
longer a ``BaseScraper`` subclass: that class wires Playwright +
|
|
``_get`` retry-with-swallow, neither of which we want here. The
|
|
client uses two ``requests.Session`` objects so the bearer header
|
|
is scoped strictly to the API host (the public categories page
|
|
is unauthenticated).
|
|
"""
|
|
|
|
SOURCE = "lucky_california"
|
|
SLUG_HREF_RE = re.compile(
|
|
r'<a\b(?=[^>]*\bclass="swiftlyCouponCategory")(?=[^>]*\bhref="([^"]+)")[^>]*>',
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
bearer_token: Optional[str] = None,
|
|
store_id: Optional[str] = None,
|
|
api_base: Optional[str] = None,
|
|
categories_url: Optional[str] = None,
|
|
rate_limit_seconds: float = 0.75,
|
|
timeout: int = 60,
|
|
) -> None:
|
|
# When ``bearer_token`` is None, fetch_category() mints via Firebase
|
|
# REST through ``swiftly_auth.get_token()``. Tests pin a fixed token
|
|
# to exercise the 401 path without going through Firebase.
|
|
self.bearer_token = bearer_token
|
|
self.store_id = store_id or settings.LUCKY_STORE_ID
|
|
self.api_base = (api_base or settings.SWIFTLY_API_BASE).rstrip("/")
|
|
self.categories_url = categories_url or settings.SWIFTLY_CATEGORIES_URL
|
|
self.rate_limit_seconds = rate_limit_seconds
|
|
self.timeout = timeout
|
|
self._last_request = 0.0
|
|
|
|
self.public_session = requests.Session()
|
|
self.public_session.headers.update(
|
|
{
|
|
"User-Agent": _USER_AGENT,
|
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
"Accept-Language": "en-US,en;q=0.5",
|
|
}
|
|
)
|
|
|
|
self.api_session = requests.Session()
|
|
self.api_session.headers.update(
|
|
{
|
|
"User-Agent": _USER_AGENT,
|
|
"Accept": "application/json",
|
|
}
|
|
)
|
|
# base_url retained for code paths that still introspect it.
|
|
self.base_url = "https://luckysupermarkets.com"
|
|
|
|
# ------------------------------------------------------------------
|
|
# Lifecycle (no-op; preserves cleanup() contract from BaseScraper).
|
|
# ------------------------------------------------------------------
|
|
def cleanup(self) -> None:
|
|
try:
|
|
self.public_session.close()
|
|
finally:
|
|
self.api_session.close()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public scrape entrypoints
|
|
# ------------------------------------------------------------------
|
|
def scrape(self) -> Dict[str, Any]:
|
|
"""Synchronous top-level scrape.
|
|
|
|
Kept for the legacy ``ScraperService.run_scrape`` path used by
|
|
tests. Walks every category and returns ``{"items": [...], ...}``
|
|
with mapped product dicts ready for ``_save_grocery_item``.
|
|
"""
|
|
started = datetime.now().isoformat()
|
|
items: List[Dict[str, Any]] = list(self.fetch_all())
|
|
return {
|
|
"source": self.SOURCE,
|
|
"scrape_type": "weekly_ad",
|
|
"started_at": started,
|
|
"completed_at": datetime.now().isoformat(),
|
|
"items_scraped": len(items),
|
|
"items": items,
|
|
"status": "success",
|
|
}
|
|
|
|
def fetch_all(self) -> Iterator[Dict[str, Any]]:
|
|
"""Yield mapped product dicts across every discovered category."""
|
|
slugs = self.discover_categories()
|
|
logger.info("Swiftly: discovered %d categories", len(slugs))
|
|
for slug in slugs:
|
|
try:
|
|
products = self.fetch_category(slug)
|
|
except SwiftlyAuthError:
|
|
# Hard fail — token must be refreshed before any further work.
|
|
raise
|
|
except requests.RequestException as exc:
|
|
logger.warning("Swiftly: skipping %s after error: %s", slug, exc)
|
|
continue
|
|
aisle = self._aisle_from_slug(slug)
|
|
for product in products:
|
|
mapped = self.map_product(product, aisle=aisle, source_slug=slug)
|
|
if mapped is not None:
|
|
yield mapped
|
|
|
|
# ------------------------------------------------------------------
|
|
# Category discovery
|
|
# ------------------------------------------------------------------
|
|
def discover_categories(self) -> List[str]:
|
|
"""Fetch the categories page and return the list of API slugs.
|
|
|
|
The slugs look like ``Product/meat_seafood``. Order is preserved
|
|
from the HTML (which is the order shown to the user).
|
|
"""
|
|
self._rate_limit()
|
|
resp = self.public_session.get(self.categories_url, timeout=self.timeout)
|
|
resp.raise_for_status()
|
|
return self.parse_categories_html(resp.text)
|
|
|
|
@classmethod
|
|
def parse_categories_html(cls, html: str) -> List[str]:
|
|
"""Pure parser used by tests against a saved fixture."""
|
|
slugs: List[str] = []
|
|
seen: set[str] = set()
|
|
for href in cls.SLUG_HREF_RE.findall(html):
|
|
m = re.match(r"^/categories/(.+)$", href)
|
|
if not m:
|
|
continue
|
|
slug = urllib.parse.unquote(m.group(1))
|
|
if slug not in seen:
|
|
seen.add(slug)
|
|
slugs.append(slug)
|
|
return slugs
|
|
|
|
# ------------------------------------------------------------------
|
|
# Per-category fetch
|
|
# ------------------------------------------------------------------
|
|
def fetch_category(self, slug: str) -> List[Dict[str, Any]]:
|
|
"""Fetch every product in a category. Raises ``SwiftlyAuthError`` on 401."""
|
|
token = self.bearer_token if self.bearer_token else _get_swiftly_token()
|
|
|
|
self._rate_limit()
|
|
url = f"{self.api_base}/search/api/v1/products/categories"
|
|
params = {"cat": slug, "store": self.store_id, "limit": 10000}
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
resp = self.api_session.get(
|
|
url, params=params, headers=headers, timeout=self.timeout
|
|
)
|
|
if resp.status_code == 401:
|
|
raise SwiftlyAuthError(_AUTH_ERROR_MESSAGE)
|
|
resp.raise_for_status()
|
|
payload = resp.json()
|
|
return self.parse_category_response(payload)
|
|
|
|
@staticmethod
|
|
def parse_category_response(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
"""Pure parser used by tests against a saved fixture."""
|
|
if not isinstance(payload, dict):
|
|
return []
|
|
products = payload.get("products") or {}
|
|
items = products.get("items")
|
|
if not isinstance(items, list):
|
|
return []
|
|
return items
|
|
|
|
# ------------------------------------------------------------------
|
|
# Field mapping
|
|
# ------------------------------------------------------------------
|
|
@classmethod
|
|
def map_product(
|
|
cls,
|
|
product: Dict[str, Any],
|
|
*,
|
|
aisle: Optional[str] = None,
|
|
source_slug: Optional[str] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Convert one Swiftly product dict to a grocery_item-ready dict.
|
|
|
|
Returns ``None`` for products with no parseable price or no name —
|
|
those are usually placeholder/unavailable rows.
|
|
"""
|
|
external_id = product.get("id")
|
|
name = (product.get("name") or "").strip()
|
|
if not name:
|
|
return None
|
|
|
|
price_block = (product.get("price") or {}).get("ok") or {}
|
|
reg_price, reg_unit = cls._parse_price(price_block.get("regPriceText"))
|
|
promo = price_block.get("promoArea") or {}
|
|
sale_price, sale_unit = cls._parse_price(promo.get("promoText"))
|
|
|
|
unit = sale_unit or reg_unit
|
|
is_on_sale = sale_price is not None and reg_price is not None and sale_price < reg_price
|
|
# current_price = "what the customer pays today" → sale_price when on sale.
|
|
current_price = sale_price if is_on_sale else reg_price
|
|
|
|
image_url: Optional[str] = None
|
|
primary_image = product.get("primaryImage")
|
|
if isinstance(primary_image, dict):
|
|
image_url = primary_image.get("url")
|
|
|
|
brand = product.get("brand")
|
|
description = product.get("description")
|
|
|
|
return {
|
|
"external_id": str(external_id) if external_id is not None else None,
|
|
"source": cls.SOURCE,
|
|
"name": name[:300],
|
|
"brand": (brand or None),
|
|
"description": description,
|
|
"current_price": current_price,
|
|
"regular_price": reg_price,
|
|
"sale_price": sale_price,
|
|
"is_on_sale": bool(is_on_sale),
|
|
"unit": unit,
|
|
"aisle": aisle,
|
|
"image_url": image_url,
|
|
"product_url": None, # Swiftly does not expose a public product URL
|
|
"scraped_at": datetime.now().isoformat(),
|
|
"scraped_url": f"{cls.__name__}:{source_slug}" if source_slug else None,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Helpers
|
|
# ------------------------------------------------------------------
|
|
@staticmethod
|
|
def _parse_price(text: Optional[str]) -> Tuple[Optional[Decimal], Optional[str]]:
|
|
"""Extract ``(price, unit)`` from strings like ``"$3.49 /lb"``.
|
|
|
|
Returns ``(None, None)`` when ``text`` is empty or unparseable.
|
|
"""
|
|
if not text:
|
|
return None, None
|
|
m = re.search(r"\$\s*([\d,]+(?:\.\d+)?)", text)
|
|
if not m:
|
|
return None, None
|
|
raw = m.group(1).replace(",", "")
|
|
try:
|
|
price = Decimal(raw)
|
|
except (InvalidOperation, ValueError):
|
|
return None, None
|
|
unit_match = re.search(r"/\s*([A-Za-z]+)", text)
|
|
unit = unit_match.group(1).lower() if unit_match else None
|
|
return price, unit
|
|
|
|
@staticmethod
|
|
def _aisle_from_slug(slug: str) -> Optional[str]:
|
|
"""``Product/meat_seafood`` → ``meat_seafood``."""
|
|
if not slug:
|
|
return None
|
|
if "/" in slug:
|
|
return slug.rsplit("/", 1)[1]
|
|
return slug
|
|
|
|
def _rate_limit(self) -> None:
|
|
elapsed = time.time() - self._last_request
|
|
if elapsed < self.rate_limit_seconds:
|
|
time.sleep(self.rate_limit_seconds - elapsed)
|
|
self._last_request = time.time()
|