Public Access
- 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>
121 lines
3.5 KiB
Python
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()
|