""" Snapshot recipe pages locally for offline inspection and future parsing tests. Run explicitly (not part of CI by default): python -m scripts.save_recipe_pages This will download each URL in scripts/recipe_urls.py and save into: tests/sample_files/recipes/ For each URL, it writes: - .html The raw page HTML - .meta.json JSON metadata (url, fetched_at, status, final_url, error) Notes: - Uses the same browser-like headers as our scraper. - Respects redirects; stores final_url. - Skips writing HTML on hard errors but still writes a .meta.json with the error. - Creates parent folders as needed. """ import asyncio import json import os import re from datetime import datetime, timezone from pathlib import Path from typing import Dict, Tuple import httpx from scripts.recipe_urls import URLS from recipes.scraping import DEFAULT_HEADERS, HEADER_PROFILES, _fallback_urls, fetch_first_2xx_html, looks_like_jsonld OUT_DIR = Path("tests/sample_files/recipes") def _safe_slug_from_url(url: str) -> str: # Keep domain and last path segment(s) for readability; replace non-word with '-' from urllib.parse import urlparse p = urlparse(url) host = p.netloc.replace(":", "-") path = p.path.rstrip("/") if not path: seg = "index" else: # use last two segments if available to avoid collisions like /recipe/ vs /recipe-2/ parts = [s for s in path.split("/") if s] seg = "-".join(parts[-2:]) if len(parts) >= 2 else parts[-1] raw = f"{host}-{seg}".lower() slug = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-") return slug or "page" async def _fetch_best_html(client: httpx.AsyncClient, url: str) -> Tuple[str, Dict]: meta: Dict = { "url": url, "fetched_at": datetime.now(timezone.utc).isoformat(), "status": None, "final_url": None, "error": None, "profile": None, "content_type": None, "content_encoding": None, "candidate": None, } visited: set[str] = set() candidates = list(_fallback_urls(url)) for cand in candidates: if cand in visited: continue visited.add(cand) html, prof_meta = await fetch_first_2xx_html(client, cand, safe_accept_encoding=True) # Merge selected metadata meta.update({ "status": int(prof_meta.get("status") or 0) or None, "final_url": prof_meta.get("final_url"), "content_type": prof_meta.get("content_type"), "content_encoding": prof_meta.get("content_encoding"), "profile": prof_meta.get("profile"), "request_headers": prof_meta.get("request_headers"), "candidate": cand, "error": prof_meta.get("error"), }) if html and looks_like_jsonld(html): return html, meta # If html is present, keep as a fallback in case later candidates fail if html: fallback_html = html fallback_meta = dict(meta) continue # Nothing succeeded; return empty payload with last meta return "", meta async def main() -> None: OUT_DIR.mkdir(parents=True, exist_ok=True) async with httpx.AsyncClient(http2=True) as client: sem = asyncio.Semaphore(6) async def worker(u: str): async with sem: html, meta = await _fetch_best_html(client, u) slug = _safe_slug_from_url(u) html_path = OUT_DIR / f"{slug}.html" meta_path = OUT_DIR / f"{slug}.meta.json" try: # Always write metadata meta_path.write_text(json.dumps(meta, indent=2)) # Only persist HTML on successful fetch if meta.get("status") and 200 <= int(meta["status"]) < 300 and html: html_path.write_text(html) print(f"saved: {u} -> {html_path.name} (status={meta.get('status')}, error={meta.get('error')})") except Exception as e: print(f"failed to save for {u}: {type(e).__name__}: {e}") await asyncio.gather(*(worker(u) for u in URLS)) if __name__ == "__main__": asyncio.run(main())