Public Access
166 lines
6.1 KiB
Python
Executable File
166 lines
6.1 KiB
Python
Executable File
#!/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())
|