Files
Meal-Planner/scripts/enrich_recipes_spoonacular.py
adminandClaude Sonnet 4.6 b03e7f8070 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>
2026-05-11 06:56:14 -07:00

121 lines
3.5 KiB
Python

#!/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()