#!/usr/bin/env python3 """Sprint 15 — Seed ~50 family-friendly recipes from Spoonacular. User direction (2026-06-05): "Lets build out recipes for the coming 4 weeks in advance. In order to do this, lets add more recipes to the list of available ones." The script hits Spoonacular's complexSearch directly (avoids the Sprint 12 /api/recipes/search route, which would otherwise count the search against the backend's daily quota). For each query, it takes the top hit and POSTs to the local backend's /api/recipes/import endpoint (which does the 1-pt /information call, the idempotent ingredient upserts, and the Recipe insert). Idempotent: a 409 from the import endpoint means the recipe is already in the local library; the script logs and continues. Cost: 50 × complexSearch (~1.10 pts each = 55 pts direct) + 50 × /information (1 pt each = 50 pts, charged to backend counter) = ~105 Spoonacular pts total. Free tier is 150/day; backend counter has a 140-pt safety cap (140/150). Run on the deploy host: ssh docker-willester cd /home/peter/MealPlanner source .env && export $(cut -d= -f1 .env | xargs) # exports SPOONACULAR_API_KEY python3 scripts/seed_recipes.py """ from __future__ import annotations import json import logging import os import sys import time from typing import Optional import requests logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("seed_recipes") SPOONACULAR_KEY = os.environ.get("SPOONACULAR_API_KEY", "") BACKEND_BASE = os.environ.get("MEALPLANNER_BACKEND", "http://localhost:8082") SEARCH_URL = "https://api.spoonacular.com/recipes/complexSearch" IMPORT_URL = f"{BACKEND_BASE}/api/recipes/import" SLEEP_BETWEEN_QUERIES_S = 1.5 # 1 search + 1 import = ~2 pts / 1.5s = well under any rate limit QUERIES: list[str] = [ # Italian (10) "chicken parmesan", "spaghetti carbonara", "lasagna", "minestrone soup", "pesto pasta", "chicken piccata", "mushroom risotto", "caprese salad", "italian wedding soup", "eggplant parmesan", # Mexican (10) "chicken tacos", "beef enchiladas", "black bean burritos", "shrimp fajitas", "chicken quesadilla", "taco salad", "sopa de tortilla", "carnitas", "chicken tortilla soup", "huevos rancheros", # Asian (10) "chicken stir fry", "beef and broccoli", "pad thai", "fried rice", "teriyaki salmon", "tofu curry", "chow mein", "spring rolls", "pho", "kung pao chicken", # American (10) "chili", "meatloaf", "mac and cheese", "BBQ chicken", "pot roast", "shepherd's pie", "chicken pot pie", "beef stew", "burgers", "pulled pork", # Mediterranean / Middle Eastern (10) "chicken shawarma", "falafel", "hummus bowl", "greek salad", "lamb kebabs", "tabbouleh", "roasted vegetable wrap", "couscous", "stuffed peppers", "baked falafel", ] def search_top_hit(query: str) -> Optional[dict]: """Hit Spoonacular's complexSearch directly, return the top hit (or None).""" try: resp = requests.get( SEARCH_URL, params={ "query": query, "number": 1, "addRecipeInformation": "true", "instructionsRequired": "true", "apiKey": SPOONACULAR_KEY, }, timeout=20, ) except requests.RequestException as exc: log.warning("search request failed for %r: %s", query, exc) return None if resp.status_code == 402: log.error("Spoonacular quota exhausted mid-run; stopping.") sys.exit(1) if resp.status_code != 200: log.warning("search %r: HTTP %d %s", query, resp.status_code, resp.text[:200]) return None results = resp.json().get("results", []) return results[0] if results else None def import_recipe(external_id: str, query: str) -> str: """POST to the backend's /api/recipes/import. Returns a status string.""" try: resp = requests.post( IMPORT_URL, json={"external_id": str(external_id), "external_source": "spoonacular"}, timeout=30, ) except requests.RequestException as exc: return f"request_error: {exc}" if resp.status_code == 201: body = resp.json() return f"imported id={body.get('id')} name={body.get('name')!r}" if resp.status_code == 409: return "duplicate (already in library)" if resp.status_code == 503: return "quota_exhausted" return f"HTTP {resp.status_code}: {resp.text[:200]}" def main() -> int: if not SPOONACULAR_KEY: log.error("SPOONACULAR_API_KEY not set. export it from /home/peter/MealPlanner/.env first.") return 2 log.info("Seeding %d recipes via %s", len(QUERIES), BACKEND_BASE) stats = {"imported": 0, "duplicate": 0, "no_hit": 0, "error": 0, "quota_exhausted": 0} for i, q in enumerate(QUERIES, start=1): hit = search_top_hit(q) if not hit: log.info("[%2d/%d] %-25r no hit", i, len(QUERIES), q) stats["no_hit"] += 1 else: ext_id = hit["id"] name = hit.get("title", "") result = import_recipe(ext_id, q) log.info("[%2d/%d] %-25r ext=%s %s name=%r", i, len(QUERIES), q, ext_id, result, name) if result.startswith("imported"): stats["imported"] += 1 elif "duplicate" in result: stats["duplicate"] += 1 elif "quota" in result: stats["quota_exhausted"] += 1 return_summary_after = None if "quota" in result: log.error("Backend quota exhausted. Stop at %d/%d to leave room for re-runs.", i, len(QUERIES)) stats["quota_exhausted"] += 1 break if result.startswith("HTTP") or result.startswith("request_error"): stats["error"] += 1 time.sleep(SLEEP_BETWEEN_QUERIES_S) log.info("DONE. stats=%s", json.dumps(stats)) return 0 if __name__ == "__main__": sys.exit(main())