Add GroceryLink schema model and grocery_links field to RecipeResult. Introduce GroceryLinkBuilder service (Amazon Fresh, Walmart, Instacart) using env-var affiliate tags; no links emitted when tags are absent. Wire link builder into RecipeEngine.suggest() for levels 1-2. Add test_grocery_links_free_tier to verify structure contract. 35 tests passing.
73 lines
2.8 KiB
Python
73 lines
2.8 KiB
Python
"""
|
|
GroceryLinkBuilder — affiliate deeplinks for missing ingredient grocery lists.
|
|
|
|
Free tier: URL construction only (Amazon Fresh, Walmart, Instacart).
|
|
Paid+: live product search API (stubbed — future task).
|
|
|
|
Config (env vars, all optional — missing = retailer disabled):
|
|
AMAZON_AFFILIATE_TAG — e.g. "circuitforge-20"
|
|
INSTACART_AFFILIATE_ID — e.g. "circuitforge"
|
|
WALMART_AFFILIATE_ID — e.g. "circuitforge" (Impact affiliate network)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from urllib.parse import quote_plus
|
|
|
|
from app.models.schemas.recipe import GroceryLink
|
|
|
|
|
|
def _amazon_link(ingredient: str, tag: str) -> GroceryLink:
|
|
q = quote_plus(ingredient)
|
|
url = f"https://www.amazon.com/s?k={q}&i=amazonfresh&tag={tag}"
|
|
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=url)
|
|
|
|
|
|
def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
|
|
q = quote_plus(ingredient)
|
|
# Walmart Impact affiliate deeplink pattern
|
|
url = f"https://goto.walmart.com/c/{affiliate_id}/walmart?u=https://www.walmart.com/search?q={q}"
|
|
return GroceryLink(ingredient=ingredient, retailer="Walmart Grocery", url=url)
|
|
|
|
|
|
def _instacart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
|
|
q = quote_plus(ingredient)
|
|
url = f"https://www.instacart.com/store/s?k={q}&aff={affiliate_id}"
|
|
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=url)
|
|
|
|
|
|
class GroceryLinkBuilder:
|
|
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
|
|
self._tier = tier
|
|
self._has_byok = has_byok
|
|
self._amazon_tag = os.environ.get("AMAZON_AFFILIATE_TAG", "")
|
|
self._instacart_id = os.environ.get("INSTACART_AFFILIATE_ID", "")
|
|
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "")
|
|
|
|
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
|
"""Build affiliate deeplinks for a single ingredient.
|
|
|
|
Free tier: URL construction only.
|
|
Paid+: would call live product search APIs (stubbed).
|
|
"""
|
|
links: list[GroceryLink] = []
|
|
|
|
if self._amazon_tag:
|
|
links.append(_amazon_link(ingredient, self._amazon_tag))
|
|
if self._walmart_id:
|
|
links.append(_walmart_link(ingredient, self._walmart_id))
|
|
if self._instacart_id:
|
|
links.append(_instacart_link(ingredient, self._instacart_id))
|
|
|
|
# Paid+: live API stub (future task)
|
|
# if self._tier in ("paid", "premium") and not self._has_byok:
|
|
# links.extend(self._search_kroger_api(ingredient))
|
|
|
|
return links
|
|
|
|
def build_all(self, ingredients: list[str]) -> list[GroceryLink]:
|
|
"""Build links for a list of ingredients."""
|
|
links: list[GroceryLink] = []
|
|
for ingredient in ingredients:
|
|
links.extend(self.build_links(ingredient))
|
|
return links
|