""" Manual harness to exercise recipes.parse_recipe against specific public URLs. This WILL NOT run in CI or with `make test` — run it explicitly: make install # first time python -m scripts.manual_parse_recipes Notes: - Creates/bootstraps a local SQLite DB (./data/doof.sqlite) for lookups. - Does not persist recipes; it just prints parsed results. - Network access required to fetch pages. """ import asyncio from types import SimpleNamespace from typing import Any, Dict, List from db import connect, create from recipes import parse_recipe from scripts.recipe_urls import URLS import os # This harness streams results as they complete and keeps per-URL logs grouped. def _fmt(v) -> str: if v is None: return "-" return str(v) def _print_recipe(r) -> None: print("\n=== Parsed Recipe ===") print(f"name: {_fmt(getattr(r, 'name', None))}") print(f"serves: {_fmt(getattr(r, 'serves', None))}") print(f"link: {_fmt(getattr(r, 'link', None))}") imgs = getattr(r, "image_urls", []) or [] if imgs: print(f"images[0]: {imgs[0]}") ings = getattr(r, "ingredients", []) or [] print(f"ingredients: {len(ings)}") for ing in ings[:10]: # Each is an Ingredient model with line/name/quantity/unit n = getattr(ing, "name", "") q = getattr(ing, "quantity", "") u = getattr(ing, "unit", "") line = getattr(ing, "line", "") print(f" - {n} ({q} {u}) :: {line}") async def _parse_one(conn, url: str, dump_dir: str | None) -> Dict[str, Any]: # Minimal created_by object the parser expects (id, display_name) created_by = SimpleNamespace(id=1, display_name="Manual Tester") logs: List[str] = [] def _log(msg: str): # Collect per-URL logs; we'll print them later in a grouped section logs.append(msg) try: r = await parse_recipe(conn, created_by, url, log=_log, dump_dir=dump_dir) except Exception as e: return {"url": url, "recipe": None, "logs": logs, "error": str(e)} return {"url": url, "recipe": r, "logs": logs, "error": None} async def main() -> None: conn = await connect() # Ensure schema exists for product matching in ingredient parsing await create(conn) try: dump_dir = os.environ.get("SCRAPER_DUMP_DIR") or None concurrency = min(8, len(URLS)) sem = asyncio.Semaphore(concurrency) # Consume all available slots and stagger release initially to avoid bursts for _ in range(concurrency): await sem.acquire() # Release all slots with slight delays to avoid thundering herd for i in range(concurrency): asyncio.get_event_loop().call_later(i * 0.5, sem.release) async def _worker(u: str): async with sem: return await _parse_one(conn, u, dump_dir) start_time = asyncio.get_event_loop().time() tasks = [asyncio.create_task(_worker(url)) for url in URLS] # Stream results as they arrive; print per-URL blocks to avoid jumbled output totals = {"total": 0, "parsed": 0, "errors": 0, "no_recipe": 0} for task in asyncio.as_completed(tasks): res = await task url = res["url"] totals["total"] += 1 print("\n==============================") print(f"Result: {url}") print("==============================") if res.get("error"): totals["errors"] += 1 print(f"ERROR: failed to parse {url}:") print(f" {res['error']}") r = res.get("recipe") if r is None and not res.get("error"): totals["no_recipe"] += 1 print(f"WARN: no recipe data found at {url}") if r is not None: totals["parsed"] += 1 _print_recipe(r) # Logs logs: List[str] = res.get("logs") or [] # Print logs for failures only to reduce noise; clip to a reasonable limit if (r is None or res.get("error")) and logs: clip = logs[:200] print("-- logs --") for line in clip: print(f" dbg: {line}") if len(logs) > len(clip): print(f" .. ({len(logs) - len(clip)} more lines clipped) ..") print("\n=== Summary ===") print(f"Total URLs processed: {totals['total']}") print(f"Successfully parsed: {totals['parsed']}") print(f"Errors: {totals['errors']}") print(f"No recipe found: {totals['no_recipe']}") print(f"Time elapsed: {asyncio.get_event_loop().time() - start_time:.2f} seconds") print(f"Remaining URLs: {len(URLS) - totals['total']}") finally: await conn.close() if __name__ == "__main__": asyncio.run(main())