cauldron/cauldron/discover_recipes.py
Sulkta 9328374cf3 Public-flip audit: env-driven paths, scrub audit-ticket prefixes, terser README
the host bind paths + LAN host pins replaced with env defaults. Repository URLs
→ git.sulkta.com. Audit-changelog scaffolding stripped from inline comments
(technical reasoning preserved). README sheds marketing scaffolding. AI-speak
in load-bearing prompts/SOULs left alone — that IS the product.
2026-05-27 11:42:56 -07:00

378 lines
15 KiB
Python

"""Discover v0.1 — scrape external recipe URLs into the discover corpus.
Pipeline per URL:
1. recipe_scrapers.scrape_me(url) → schema.org structured recipe
2. Reshape into a Mealie-ish dict (name, description, recipeYield,
recipeIngredient[{note}], recipeInstructions[{text}])
3. INSERT IGNORE into cauldron_discovered_recipes (UNIQUE on source_url)
4. forge.enrich_recipe(reshaped) → Hecate-tier metadata
5. Persist meta_json, flip status raw → enriched
Same daemon-thread + cancel + stuck-recovery pattern as enrich/sterilize.
Seed sources are hardcoded URL lists per source_seed (allrecipes-popular,
bbc-popular, smitten-kitchen-recent, ...). Alice supplies a seed name OR
a literal list of URLs via the admin endpoint. Either way, the runner
walks the list, scrape→insert→enrich each, and emits progress.
"""
from __future__ import annotations
import json
import logging
import threading
from urllib.parse import urlparse
import ipaddress as _ipaddr
import socket as _socket
from .db import DB
from .forge import Forge, ForgeError
log = logging.getLogger(__name__)
# Curated seed URL lists for v0.1 dogfood. Each is a small starter pack —
# we expand later by adding sitemap/category-page walkers. Keeping these
# manual lets v0.1 ship without a separate site-walker per source.
SEED_URLS: dict[str, list[str]] = {
"allrecipes-popular": [
"https://www.allrecipes.com/recipe/24074/alyssas-chicken/",
"https://www.allrecipes.com/recipe/229960/world-best-now-veggie-burgers/",
"https://www.allrecipes.com/recipe/16641/old-fashioned-mac-and-cheese/",
"https://www.allrecipes.com/recipe/8499082/instant-pot-pulled-pork/",
"https://www.allrecipes.com/recipe/220854/chef-johns-creamy-mushroom-pasta/",
"https://www.allrecipes.com/recipe/8514308/dr-pepper-pulled-pork/",
"https://www.allrecipes.com/recipe/16700/salisbury-steak/",
"https://www.allrecipes.com/recipe/8536048/oven-baked-bbq-chicken-thighs/",
],
"bbc-good-food": [
"https://www.bbcgoodfood.com/recipes/spaghetti-bolognese-recipe",
"https://www.bbcgoodfood.com/recipes/best-spaghetti-carbonara-recipe",
"https://www.bbcgoodfood.com/recipes/easy-chicken-curry",
"https://www.bbcgoodfood.com/recipes/chilli-con-carne-recipe",
"https://www.bbcgoodfood.com/recipes/perfect-roast-chicken",
"https://www.bbcgoodfood.com/recipes/chicken-tikka-masala",
"https://www.bbcgoodfood.com/recipes/sticky-toffee-pudding",
],
"smitten-kitchen": [
"https://smittenkitchen.com/2023/02/black-pepper-chicken/",
"https://smittenkitchen.com/2024/01/orecchiette-with-broccoli-rabe/",
"https://smittenkitchen.com/2023/09/baked-orzo-with-eggplant-and-mozzarella/",
"https://smittenkitchen.com/2022/12/cacio-e-pepe-soup-with-broccoli-rabe/",
"https://smittenkitchen.com/2022/05/spinach-chickpea-skillet/",
],
"pinch-of-yum": [
"https://pinchofyum.com/the-best-soft-chocolate-chip-cookies",
"https://pinchofyum.com/spicy-peanut-soba-noodle-salad",
"https://pinchofyum.com/best-chicken-marinade",
"https://pinchofyum.com/15-minute-meal-prep-cilantro-lime-chicken-and-cauliflower-rice",
"https://pinchofyum.com/pesto-cavatappi",
],
"half-baked-harvest": [
"https://www.halfbakedharvest.com/cajun-chicken-pasta/",
"https://www.halfbakedharvest.com/garlic-butter-creamed-spinach-salmon/",
"https://www.halfbakedharvest.com/spicy-pretzel-chicken/",
"https://www.halfbakedharvest.com/crispy-buffalo-chicken-tacos/",
"https://www.halfbakedharvest.com/butter-chicken-meatballs/",
],
}
def list_seeds() -> list[dict]:
"""For the /discover admin UI: name + count of curated URLs per seed."""
return [{"name": k, "count": len(v)} for k, v in SEED_URLS.items()]
def is_public_url(url: str) -> tuple[bool, str]:
"""SSRF guard: validate that `url` resolves to a non-private,
non-loopback, non-link-local IP. Returns (ok, reason).
Used by both `/api/discover/scrape-start` (pre-queue rejection) and
`_scrape_one` (defense-in-depth before fetch). Without this, any
session user could queue URLs pointing at the LAN, the docker
bridge, or cloud metadata endpoints (169.254.169.254, etc).
Strategy:
1. Parse the URL. Reject non-http(s) schemes.
2. Extract hostname. Reject if IP-literal pointing at private space.
3. Resolve via getaddrinfo (covers IPv4 + IPv6 + IDNs).
4. For each resolved address, reject if .is_private, .is_loopback,
.is_link_local, .is_multicast, .is_reserved, .is_unspecified.
Note: this is best-effort. A malicious resolver could DNS-rebind
between this check and the actual GET. recipe-scrapers also makes
its own HTTP calls in scrape_me — those bypass this guard. Acceptable
for v0.1 (LAN-only deployment, family OIDC). The right answer
long-term is a custom requests transport that re-validates per-
connection like Mealie's pkgs/safehttp."""
from urllib.parse import urlparse
try:
parsed = urlparse(url)
except Exception as e:
return (False, f"unparseable url: {e}")
if parsed.scheme not in ("http", "https"):
return (False, f"scheme not allowed: {parsed.scheme!r}")
host = parsed.hostname
if not host:
return (False, "no hostname in url")
# Reject IP-literals pointing into private / loopback / link-local /
# multicast / reserved space directly (saves a DNS roundtrip too).
try:
ip = _ipaddr.ip_address(host)
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
return (False, f"ip in restricted range: {ip}")
return (True, "")
except ValueError:
pass # not an IP literal — resolve via DNS
# Resolve and check every returned address.
try:
infos = _socket.getaddrinfo(host, None, type=_socket.SOCK_STREAM)
except _socket.gaierror as e:
return (False, f"dns resolution failed: {e}")
for info in infos:
addr = info[4][0]
try:
ip = _ipaddr.ip_address(addr)
except ValueError:
continue
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_multicast or ip.is_reserved or ip.is_unspecified):
return (False, f"resolves to restricted ip: {ip} (host={host})")
return (True, "")
def _slug_from_url(url: str) -> str | None:
"""Cheap slug fallback when the scraper doesn't expose one."""
try:
parts = [p for p in urlparse(url).path.split("/") if p]
return parts[-1][:255] if parts else None
except Exception:
return None
def _safe_call(fn, default=None):
"""recipe_scrapers raises various Exception subclasses for missing
fields. Swallow them per-field rather than aborting the whole scrape."""
try:
return fn()
except Exception:
return default
def _to_mealie_shape(scraper, source_url: str) -> dict:
"""Reshape a recipe_scrapers.AbstractScraper into the dict shape
forge.enrich_recipe expects (a Mealie recipe). Falls back gracefully
when individual fields are unavailable."""
title = _safe_call(scraper.title) or ""
description = _safe_call(getattr(scraper, "description", lambda: ""), "") or ""
yields = _safe_call(scraper.yields, "") or ""
image = _safe_call(scraper.image, "") or ""
ings_raw = _safe_call(scraper.ingredients, []) or []
ingredients = [
{"note": str(x).strip()}
for x in ings_raw
if x and str(x).strip()
]
# Prefer instructions_list when supported; some scrapers only expose
# the joined string.
steps_list: list[str] = []
instructions_list = _safe_call(getattr(scraper, "instructions_list", lambda: None), None)
if instructions_list:
steps_list = [str(s).strip() for s in instructions_list if s and str(s).strip()]
else:
joined = _safe_call(scraper.instructions, "") or ""
steps_list = [s.strip() for s in joined.split("\n") if s.strip()]
instructions = [{"text": s} for s in steps_list]
return {
"name": title,
"description": description,
"recipeYield": yields,
"image": image,
"source_url": source_url,
"recipeIngredient": ingredients,
"recipeInstructions": instructions,
}
def _scrape_one(url: str) -> tuple[dict, str | None] | None:
"""Scrape a single URL. Returns (mealie_shape_dict, image_url) on
success. Returns None on any unrecoverable scraper error."""
# SSRF defense-in-depth: even though /api/discover/scrape-start
# validates URLs at queue time, re-check here so any future caller
# (cron, admin script, future bulk runner) can't bypass it.
ok, reason = is_public_url(url)
if not ok:
log.warning("[discover] refusing private/restricted url %s (%s)", url, reason)
return None
try:
from recipe_scrapers import scrape_me # type: ignore
except ImportError:
log.exception("[discover] recipe_scrapers not installed")
return None
try:
scraper = scrape_me(url)
except Exception as e:
# scraper_exists_for is False or the request failed.
# Fall back to scrape_html with supported_only=False so unknown
# sites still get a JSON-LD/microdata pass.
try:
from recipe_scrapers import scrape_html # type: ignore
import requests as _rq
resp = _rq.get(
url,
timeout=15,
# allow_redirects=False: is_public_url validated the
# original host as public; a 30x to 127.0.0.1 / 169.254.x
# would otherwise route this scrape worker at internal
# services (LAN scanner, cloud metadata IMDS). Treat 30x
# as scrape failure rather than chase the redirect chain.
# The recipe_scrapers primary path has its own internal
# request chain that's a known residual — the docstring
# on is_public_url notes the long-term answer is a
# custom requests transport that re-validates per hop.
allow_redirects=False,
headers={
# Realistic desktop UA — many recipe sites 403 anything
# that smells like a bot. We're identifying as a normal
# browser; per-site robots.txt we still respect via
# recipe_scrapers' built-in wild_mode safety nets.
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
},
)
if resp.status_code != 200:
log.warning("[discover] fetch %s -> %s", url, resp.status_code)
return None
scraper = scrape_html(
html=resp.text, org_url=url, supported_only=False
)
except Exception as e2:
log.warning("[discover] scrape_me(%s) failed: %s / fallback: %s", url, e, e2)
return None
shaped = _to_mealie_shape(scraper, url)
image = shaped.get("image") or None
if not shaped.get("name"):
log.warning("[discover] no name extracted from %s", url)
return None
if not shaped.get("recipeIngredient"):
log.warning("[discover] no ingredients extracted from %s", url)
return None
return shaped, image
def run_discover(
*,
db: DB,
job_id: int,
forge: Forge,
urls: list[str],
) -> None:
"""Walk a list of URLs: scrape → insert → enrich. Runs in a daemon
thread; respects external cancel via state poll."""
log.info("[discover:%s] start (%d urls)", job_id, len(urls))
def _cancelled() -> bool:
s = db.get_discover_job_state(job_id)
return s in ("cancelled", "failed", "done")
try:
for url in urls:
if _cancelled():
log.info("[discover:%s] aborted (state changed)", job_id)
return
db.update_discover_job_progress(job_id, pages_delta=1)
scraped = _scrape_one(url)
if scraped is None:
db.update_discover_job_progress(
job_id, error_delta=1, last_error=f"scrape failed: {url[:200]}"
)
continue
shaped, image = scraped
try:
slug = _slug_from_url(url)
discover_id = db.insert_discovered_recipe(
slug=slug,
source_url=url,
name=shaped.get("name") or None,
description=(shaped.get("description") or "")[:60000] or None,
image_url=image,
scraped_json=json.dumps(shaped, ensure_ascii=False),
)
except Exception as e:
log.warning("[discover:%s] insert(%s) failed: %s", job_id, url, e)
db.update_discover_job_progress(
job_id, error_delta=1, last_error=f"insert: {str(e)[:200]}"
)
continue
if not discover_id:
# UNIQUE conflict — already in the corpus from a prior scrape
db.update_discover_job_progress(job_id, skipped_delta=1)
continue
try:
meta = forge.enrich_recipe(shaped)
except (ForgeError, RuntimeError) as e:
msg = str(e)[:500]
log.warning("[discover:%s] enrich(%s): %s", job_id, url, msg)
db.update_discover_job_progress(
job_id, error_delta=1, last_error=f"enrich: {msg[:200]}"
)
# Leave the row in 'raw' so we can retry enrichment later.
# The recipe IS in the corpus; just hasn't been classified.
continue
try:
db.update_discovered_meta(
discover_id,
meta_json=json.dumps(meta, ensure_ascii=False),
version=DB.ENRICH_VERSION,
)
db.update_discover_job_progress(job_id, added_delta=1)
except Exception as e:
log.warning("[discover:%s] persist meta(%s): %s", job_id, url, e)
db.update_discover_job_progress(
job_id, error_delta=1, last_error=f"persist: {str(e)[:200]}"
)
db.finalize_discover_job(job_id, state="done")
log.info("[discover:%s] done", job_id)
except Exception:
log.exception("[discover:%s] crashed", job_id)
try:
db.finalize_discover_job(job_id, state="failed")
except Exception:
pass
def spawn_thread(
*,
db: DB,
job_id: int,
forge: Forge,
urls: list[str],
) -> threading.Thread:
t = threading.Thread(
target=run_discover,
kwargs={"db": db, "job_id": job_id, "forge": forge, "urls": urls},
name=f"discover-recipes-{job_id}",
daemon=True,
)
t.start()
return t