Rewrite the README for external users, neutralize example identities in code comments, generalize the OIDC group note, and simplify the gitleaks workflow header.
378 lines
15 KiB
Python
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, ...). The user 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
|