Public Access
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:
Executable
+154
@@ -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())
|
||||||
Reference in New Issue
Block a user