feat(scripts): Sprint 15 round 2 — seed_recipes_round2.py (50 gap-filling queries)

Round 1 (commit a3c89bf) imported 18 recipes before hitting
the 50-pt/day Spoonacular free-tier cap. User direction
(2026-06-07): 'please add more meals to the potential list' /
'Pull in more recipes so we have a larger sample to generate
from.'

This is a fresh-quota run with a different query list focused
on cuisines and meal types the round 1 list didn't cover:
Indian (8) + Thai (6) + Chinese regional (6) + Soups & stews
(6) + Salads (6) + Sandwiches/wraps (5) + Breakfast (5) +
German/European (4) + French (4) = 50 queries.

Same shape as scripts/seed_recipes.py: hits Spoonacular's
complexSearch directly (avoids the broken backend route and
the backend's quota counter), POSTs top hits to the backend's
/api/recipes/import. Idempotent (409 on duplicate), 1.5 sec
sleep, stops on 402.

Result: 18 imported today. 12 queries returned no hits from
Spoonacular's free-tier index (e.g. 'chana masala', 'thai
basil chicken', 'dan dan noodles' — these are absent or
premium-only). 1 query ('wedge salad') hit 402 mid-import.

DB went 49 -> 67 total recipes (37 Spoonacular + 30 manual).
LLM test (Sprint 13 endpoint, week 2026-07-20, prompt
'variety, mix of cuisines, family-friendly, no repeats'):
  {picked_count: 0, filled_count: 21, failed_count: 0}
The library now covers all 21 slots of a week (was 19/21 +
2 failed in round 1). 4 weeks of planning now has a real
library to pick from with 1.25x rotation.

Re-running scripts/seed_recipes.py (round 1) tomorrow will
add 30+ more — its query list has gaps the round 1 cap didn't
reach (American + Mediterranean cuisines).
This commit is contained in:
2026-06-07 16:20:20 -07:00
parent 0668d40444
commit 97b84a7cd8
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Sprint 15 — Round 2: Seed 50 more family-friendly recipes.
User direction (2026-06-06): "please add more meals to the potential
list" / "Pull in more recipes so we have a larger sample to generate
from."
This is a follow-up to scripts/seed_recipes.py (round 1) which
imported 18 recipes before hitting the 50-pt/day free-tier cap. This
round focuses on cuisines and meal types the round 1 list didn't
cover: Indian, Thai, Chinese regional, soups/stews, salads,
sandwiches/wraps, breakfast, German/European, French.
Same idempotent behavior as round 1: 409 from /api/recipes/import
means already imported; the script logs and continues.
Cost: 50 × complexSearch (1.10 pts) + 50 × /information (1 pt) =
105 pts, ~2 days on free tier. Today: run until the cap hits.
Run on the deploy host:
ssh docker-willester
cd /home/peter/MealPlanner
set -a && source .env && set +a
python3 scripts/seed_recipes_round2.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_round2")
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
QUERIES: list[str] = [
# Indian (8)
"chicken tikka masala", "butter chicken", "palak paneer", "chana masala",
"biryani", "dal", "samosa", "naan",
# Thai (6)
"green curry", "massaman curry", "tom yum soup", "mango sticky rice",
"papaya salad", "thai basil chicken",
# Chinese regional (6)
"mapo tofu", "hot and sour soup", "scallion pancakes", "soup dumplings",
"beef noodle soup", "dan dan noodles",
# Soups & stews (6)
"french onion soup", "clam chowder", "chicken noodle soup", "tomato soup",
"lentil soup", "butternut squash soup",
# Salads (6)
"caesar salad", "cobb salad", "nicoise salad", "wedge salad",
"pasta salad", "quinoa salad",
# Sandwiches / wraps (5)
"banh mi", "reuben sandwich", "club sandwich", "french dip", "gyro wrap",
# Breakfast (5)
"eggs benedict", "pancakes", "french toast", "omelette", "breakfast burrito",
# German / European (4)
"schnitzel", "spaetzle", "sauerbraten", "beef rouladen",
# French (4)
"coq au vin", "ratatouille", "beef bourguignon", "quiche lorraine",
]
def search_top_hit(query: str) -> Optional[dict]:
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) -> str:
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 (round 2) 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)
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
log.error("Backend quota exhausted. Stop at %d/%d.", i, len(QUERIES))
break
elif 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())