diff --git a/docker-compose.yml b/docker-compose.yml index 856fae5..6f11b4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/scripts/enrich_recipes_spoonacular.py b/scripts/enrich_recipes_spoonacular.py new file mode 100644 index 0000000..7ebfc14 --- /dev/null +++ b/scripts/enrich_recipes_spoonacular.py @@ -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()