feat: add images to all 50 recipes with frontmatter (Task 2)

Downloaded recipe images from source URLs (NYT Cooking, Love & Lemons,
Food Network, etc.) via og:image scraping and DuckDuckGo search + page
scraping for recipes without direct source URLs.
This commit is contained in:
2026-05-21 14:58:14 -04:00
parent 94775414bd
commit ed70f6e3c2
120 changed files with 1082 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Final attempt: search DDG text, scrape recipe pages for actual images."""
import csv
import json
import re
import sys
import time
from pathlib import Path
import requests
from bs4 import BeautifulSoup
from ddgs import DDGS
WORKDIR = Path("/home/jbrechtel/recipes/.worktrees/recipe-images")
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
}
SKIP_DOMAINS = {"pinterest", "facebook", "instagram", "twitter", "youtube", "tiktok"}
MIN_IMAGE_SIZE = 10000 # 10KB minimum for a recipe photo
def search_url(query: str) -> str | None:
"""Search and return first relevant recipe page URL."""
try:
results = list(DDGS().text(query, max_results=8))
except Exception as e:
print(f" Search error: {e}", file=sys.stderr)
return None
for r in results:
url = r.get("href", "")
domain = url.split("/")[2] if "//" in url else ""
if any(s in domain for s in SKIP_DOMAINS):
continue
# Prefer recipe sites
if any(kw in domain for kw in ["recipe", "cook", "food", "kitchen", "nytimes", "loveandlemons"]):
return url
# Return first non-wikipedia result
for r in results:
url = r.get("href", "")
if "wikipedia.org" not in url:
return url
return None
def find_recipe_images(url: str) -> list[str]:
"""Fetch a page and find actual recipe/food images."""
try:
resp = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
resp.raise_for_status()
except requests.RequestException:
return []
soup = BeautifulSoup(resp.text, "html.parser")
images = []
# 1. Check JSON-LD for recipe image
for script in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(script.string)
except (json.JSONDecodeError, TypeError):
continue
# Handle list or single
items = data if isinstance(data, list) else [data]
for item in items:
if not isinstance(item, dict):
continue
if item.get("@type") == "Recipe":
img = item.get("image")
if isinstance(img, str):
images.append(img)
elif isinstance(img, list):
for i in img:
if isinstance(i, str):
images.append(i)
elif isinstance(i, dict):
u = i.get("contentUrl") or i.get("url", "")
if u:
images.append(u)
elif isinstance(img, dict):
u = img.get("contentUrl") or img.get("url", "")
if u:
images.append(u)
# 2. Check og:image
for meta in soup.find_all("meta"):
content = meta.get("content", "")
if (meta.get("property") == "og:image" or meta.get("name") == "og:image" or meta.get("name") == "twitter:image"):
if content and "logo" not in content.lower():
images.append(content)
# 3. Check all img tags for large, relevant images
for img in soup.find_all("img"):
src = img.get("src") or img.get("data-src") or ""
if not src.startswith("http"):
continue
src_lower = src.lower()
if any(kw in src_lower for kw in ["logo", "avatar", "icon", "banner", "pixel", "spacer", "thumb"]):
continue
# Check for recipe-related class/id
parent_classes = ""
parent = img.parent
if parent:
parent_classes = " ".join(parent.get("class", []) or []) + " " + (parent.get("id") or "")
if any(kw in parent_classes.lower() for kw in ["logo", "nav", "header", "footer", "menu", "icon"]):
continue
images.append(src)
# Deduplicate
seen = set()
unique = []
for img in images:
if img not in seen:
seen.add(img)
unique.append(img)
return unique
def download_image(url: str, dest: Path) -> bool:
"""Download image, validate it's a real image."""
try:
resp = requests.get(url, headers=HEADERS, timeout=30, stream=True)
resp.raise_for_status()
ct = resp.headers.get("content-type", "")
if not ct.startswith("image/"):
return False
content = resp.content
if len(content) < MIN_IMAGE_SIZE:
return False
dest.write_bytes(content)
return True
except requests.RequestException:
return False
def get_title(filepath: Path) -> str:
content = filepath.read_text(encoding="utf-8")
m = re.search(r"^title:\s*(.+)$", content, re.MULTILINE)
if m:
return m.group(1).strip()
return filepath.stem.replace("_", " ").replace("-", " ")
def add_image_field(filepath: Path, slug: str):
url = f"https://roux.roo.lol/recipe-images/{slug}.webp"
content = filepath.read_text(encoding="utf-8")
if re.search(r"^image:", content, re.MULTILINE):
content = re.sub(r"^image:.*$", f"image: {url}", count=1, flags=re.MULTILINE, string=content)
else:
content = re.sub(r"^---$", f"image: {url}\n---", count=1, flags=re.MULTILINE, string=content)
filepath.write_text(content, encoding="utf-8")
print(f" Updated {filepath.name}", file=sys.stderr)
def get_source_url(filepath: Path) -> str | None:
content = filepath.read_text(encoding="utf-8")
m = re.search(r"^source:\s*(.+)$", content, re.MULTILINE)
if m:
val = m.group(1).strip()
if val.startswith("http"):
return val
return None
def main():
csv_file = WORKDIR / "docs" / "no-image-has-fm.csv"
recipes = []
with open(csv_file) as f:
reader = csv.reader(f, delimiter="|")
for row in reader:
if len(row) >= 2:
recipes.append((row[0].strip(), row[1].strip()))
success = []
failed = []
for filename, slug in recipes:
filepath = WORKDIR / filename
if not filepath.exists():
failed.append(slug)
continue
dest = WORKDIR / "recipe-images" / f"{slug}.webp"
# Skip if already has image AND file exists
content = filepath.read_text(encoding="utf-8")
has_field = bool(re.search(r"^image:", content, re.MULTILINE))
has_file = dest.exists()
if has_field and has_file and dest.stat().st_size > 10000:
print(f"[{slug}] Already done, skipping", file=sys.stderr)
success.append(slug)
continue
title = get_title(filepath)
query = f"{title} recipe"
print(f"[{slug}] Searching: {query}", file=sys.stderr)
page_url = search_url(query)
if not page_url:
print(f" No search results", file=sys.stderr)
failed.append(slug)
continue
print(f" Page: {page_url}", file=sys.stderr)
time.sleep(0.3)
img_urls = find_recipe_images(page_url)
if not img_urls:
print(f" No images found on page", file=sys.stderr)
failed.append(slug)
continue
downloaded = False
for img_url in img_urls[:5]:
print(f" Trying: {img_url[:80]}...", file=sys.stderr)
if download_image(img_url, dest):
print(f" Downloaded ({dest.stat().st_size} bytes)", file=sys.stderr)
add_image_field(filepath, slug)
downloaded = True
break
time.sleep(0.2)
if downloaded:
success.append(slug)
else:
failed.append(slug)
time.sleep(0.5)
print(f"\n=== Results ===", file=sys.stderr)
print(f"Success: {len(success)}", file=sys.stderr)
print(f"Failed: {len(failed)}", file=sys.stderr)
if failed:
print(f"Failed: {', '.join(failed)}", file=sys.stderr)
if __name__ == "__main__":
main()