Files
Meal-Planner/scripts/seed_recipes.py
T
admin 2695ba0232
CI / backend (pytest + alembic) (push) Canceled after 0s
CI / frontend (build) (push) Canceled after 0s
feat(meals): enforce three lower-calorie dinners with media
2026-09-01 10:11:36 -07:00

166 lines
6.1 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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:
log.error(
"Retired: direct Spoonacular publication is disabled. "
"Use the staged Wikibooks import workflow."
)
return 2
def _legacy_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", "<no 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())