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:
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scrape og:image from recipe source URLs, download images, update .cook files."""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
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",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
}
|
||||
|
||||
|
||||
def get_source_url(filepath: Path) -> str | None:
|
||||
"""Extract the source URL from a .cook file's YAML frontmatter."""
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
match = re.search(r"^source:\s*(.+)$", content, re.MULTILINE)
|
||||
if not match:
|
||||
return None
|
||||
val = match.group(1).strip()
|
||||
# Only return actual URLs
|
||||
if val.startswith("http://") or val.startswith("https://"):
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def fetch_og_image(url: str, timeout: int = 20) -> str | None:
|
||||
"""Fetch a page and extract og:image URL."""
|
||||
try:
|
||||
resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
print(f" FETCH FAILED: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
|
||||
# Try og:image meta tag
|
||||
for meta in soup.find_all("meta"):
|
||||
if meta.get("property") == "og:image" and meta.get("content"):
|
||||
return meta["content"]
|
||||
if meta.get("name") == "og:image" and meta.get("content"):
|
||||
return meta["content"]
|
||||
|
||||
# Try twitter:image
|
||||
for meta in soup.find_all("meta"):
|
||||
if meta.get("name") == "twitter:image" and meta.get("content"):
|
||||
return meta["content"]
|
||||
|
||||
# Try finding the first large image (common in recipe sites)
|
||||
for img in soup.find_all("img"):
|
||||
src = img.get("src") or ""
|
||||
if "logo" in src.lower() or "avatar" in src.lower() or "icon" in src.lower():
|
||||
continue
|
||||
if src.startswith("http") and any(kw in src.lower() for kw in ["upload", "wp-content", "images"]):
|
||||
return src
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def download_image(url: str, dest: Path) -> bool:
|
||||
"""Download an image from URL to destination path."""
|
||||
try:
|
||||
resp = requests.get(url, headers=HEADERS, timeout=30, stream=True)
|
||||
resp.raise_for_status()
|
||||
dest.write_bytes(resp.content)
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
print(f" DOWNLOAD FAILED: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def add_image_field(filepath: Path, slug: str):
|
||||
"""Add or update the image field in a .cook file's YAML frontmatter."""
|
||||
url = f"https://roux.roo.lol/recipe-images/{slug}.webp"
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
|
||||
# If image field already exists, replace it
|
||||
if re.search(r"^image:", content, re.MULTILINE):
|
||||
content = re.sub(r"^image:.*$", f"image: {url}", content, count=1, flags=re.MULTILINE)
|
||||
else:
|
||||
# Insert before closing ---
|
||||
content = re.sub(r"^---$", f"image: {url}\n---", content, count=1, flags=re.MULTILINE)
|
||||
|
||||
filepath.write_text(content, encoding="utf-8")
|
||||
print(f" Updated image field: {filepath.name}", file=sys.stderr)
|
||||
|
||||
|
||||
def process_recipe(filepath: Path, slug: str) -> bool:
|
||||
"""Process a single recipe: find image, download, update frontmatter."""
|
||||
dest = WORKDIR / "recipe-images" / f"{slug}.webp"
|
||||
|
||||
# Skip if already downloaded
|
||||
if dest.exists():
|
||||
print(f"[{slug}] Image already exists, skipping download", file=sys.stderr)
|
||||
add_image_field(filepath, slug)
|
||||
return True
|
||||
|
||||
# Try to get source URL and scrape image
|
||||
src_url = get_source_url(filepath)
|
||||
if src_url:
|
||||
print(f"[{slug}] Scraping {src_url}", file=sys.stderr)
|
||||
img_url = fetch_og_image(src_url)
|
||||
if img_url:
|
||||
print(f" Found: {img_url}", file=sys.stderr)
|
||||
if download_image(img_url, dest):
|
||||
print(f" Downloaded to {dest.name}", file=sys.stderr)
|
||||
add_image_field(filepath, slug)
|
||||
return True
|
||||
else:
|
||||
print(f" Download failed", file=sys.stderr)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
csv_file = WORKDIR / "docs" / "no-image-has-fm.csv"
|
||||
if not csv_file.exists():
|
||||
print(f"CSV not found: {csv_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
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()))
|
||||
|
||||
print(f"Processing {len(recipes)} recipes...", file=sys.stderr)
|
||||
|
||||
success = []
|
||||
failed = []
|
||||
|
||||
for filename, slug in recipes:
|
||||
filepath = WORKDIR / filename
|
||||
if not filepath.exists():
|
||||
print(f"[{slug}] File not found: {filepath}", file=sys.stderr)
|
||||
failed.append(slug)
|
||||
continue
|
||||
|
||||
ok = process_recipe(filepath, slug)
|
||||
if ok:
|
||||
success.append(slug)
|
||||
else:
|
||||
failed.append(slug)
|
||||
|
||||
# Be polite to servers
|
||||
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 slugs: {', '.join(failed)}", file=sys.stderr)
|
||||
|
||||
# Write failed slugs to file for retry
|
||||
if failed:
|
||||
with open(WORKDIR / "docs" / "failed-images.csv", "w") as f:
|
||||
for slug in failed:
|
||||
f.write(f"{slug}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user