Public Access
feat: Spoonacular recipe enrichment — images + descriptions for 25/30 recipes
- Added SPOONACULAR_API_KEY to docker-compose.yml (backend + scheduler) - scripts/enrich_recipes_spoonacular.py: searches Spoonacular by recipe name, updates image_url and description; 402 quota guard exits cleanly - 25 of 30 null recipes now enriched; 5 remain (quota exhausted for today) - Remaining: Caprese Pasta, Creamy Tuscan Chicken, Sheet-Pan Chicken Thighs, Loaded Veggie Quesadillas, Zucchini and Spinach Frittata Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD}@db:5432/mealplanner
|
||||
- SENDGRID_API_KEY=${SENDGRID_API_KEY}
|
||||
- SPOONACULAR_API_KEY=${SPOONACULAR_API_KEY}
|
||||
- LUCKY_CA_URL=${LUCKY_CA_URL:-https://luckysupermarkets.com}
|
||||
- LUCKY_STORE_ID=${LUCKY_STORE_ID:-757}
|
||||
- SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net}
|
||||
@@ -40,6 +41,7 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD}@db:5432/mealplanner
|
||||
- SENDGRID_API_KEY=${SENDGRID_API_KEY}
|
||||
- SPOONACULAR_API_KEY=${SPOONACULAR_API_KEY}
|
||||
- LUCKY_CA_URL=${LUCKY_CA_URL:-https://luckysupermarkets.com}
|
||||
- LUCKY_STORE_ID=${LUCKY_STORE_ID:-757}
|
||||
- SWIFTLY_API_BASE=${SWIFTLY_API_BASE:-https://prod.swiftlyapi.net}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enrich recipes with images and descriptions from Spoonacular.
|
||||
|
||||
Reads SPOONACULAR_API_KEY and DATABASE_URL from environment.
|
||||
|
||||
Run inside the backend container:
|
||||
docker cp scripts/enrich_recipes_spoonacular.py mealplanner-backend-1:/app/
|
||||
docker compose --env-file .env.test exec backend python /app/enrich_recipes_spoonacular.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import requests
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SPOONACULAR_KEY = os.environ.get("SPOONACULAR_API_KEY", "")
|
||||
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
SEARCH_URL = "https://api.spoonacular.com/recipes/complexSearch"
|
||||
RATE_LIMIT_SECONDS = 0.5 # 2 req/s — well under 150/day free tier limit
|
||||
|
||||
|
||||
def strip_html(raw: str) -> str:
|
||||
return re.sub(r"<[^>]+>", "", raw or "").strip()
|
||||
|
||||
|
||||
def search_recipe(name: str) -> dict | None:
|
||||
resp = requests.get(
|
||||
SEARCH_URL,
|
||||
params={
|
||||
"query": name,
|
||||
"number": 3,
|
||||
"addRecipeInformation": True,
|
||||
"apiKey": SPOONACULAR_KEY,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code == 402:
|
||||
log.error("Spoonacular quota exhausted — stop and retry tomorrow.")
|
||||
sys.exit(1)
|
||||
resp.raise_for_status()
|
||||
results = resp.json().get("results", [])
|
||||
return results[0] if results else None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not SPOONACULAR_KEY:
|
||||
log.error("SPOONACULAR_API_KEY not set")
|
||||
sys.exit(1)
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as conn:
|
||||
recipes = conn.execute(
|
||||
text(
|
||||
"SELECT id, name, image_url, description "
|
||||
"FROM recipe "
|
||||
"WHERE image_url IS NULL OR description IS NULL "
|
||||
"ORDER BY name"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
log.info("Recipes to enrich: %d", len(recipes))
|
||||
|
||||
updated = 0
|
||||
no_match = 0
|
||||
|
||||
engine2 = create_engine(DATABASE_URL)
|
||||
with engine2.connect() as conn:
|
||||
for recipe_id, name, existing_image, existing_desc in recipes:
|
||||
log.info("[%d/%d] %s", updated + no_match + 1, len(recipes), name)
|
||||
|
||||
try:
|
||||
match = search_recipe(name)
|
||||
except requests.RequestException as exc:
|
||||
log.warning(" API error: %s — skipping", exc)
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
if not match:
|
||||
log.info(" no Spoonacular match")
|
||||
no_match += 1
|
||||
time.sleep(RATE_LIMIT_SECONDS)
|
||||
continue
|
||||
|
||||
new_image = existing_image or match.get("image") or None
|
||||
raw_summary = match.get("summary", "")
|
||||
new_desc = existing_desc or (strip_html(raw_summary) or None)
|
||||
|
||||
conn.execute(
|
||||
text(
|
||||
"UPDATE recipe SET image_url = :img, description = :desc WHERE id = :id"
|
||||
),
|
||||
{"img": new_image, "desc": new_desc, "id": recipe_id},
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
img_ok = "✓" if new_image else "—"
|
||||
desc_len = len(new_desc) if new_desc else 0
|
||||
log.info(" image=%s desc=%d chars", img_ok, desc_len)
|
||||
updated += 1
|
||||
time.sleep(RATE_LIMIT_SECONDS)
|
||||
|
||||
log.info(
|
||||
"Done — updated: %d no-match: %d already-complete: %d",
|
||||
updated,
|
||||
no_match,
|
||||
len(recipes) - updated - no_match,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user