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