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
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
slug="$1"
url="$2"
echo "[$slug] Fetching $url" >&2
html=$(curl -sL --max-time 15 "$url" 2>/dev/null) || { echo "[$slug] FETCH FAILED" >&2; echo ""; exit 0; }
# Try multiple og:image patterns
img_url=$(echo "$html" | grep -oP 'property="og:image"\s+content="[^"]*"' | sed 's/.*content="//;s/"//' | head -1)
if [ -z "$img_url" ]; then
img_url=$(echo "$html" | grep -oP 'name="og:image"\s+content="[^"]*"' | sed 's/.*content="//;s/"//' | head -1)
fi
if [ -z "$img_url" ]; then
img_url=$(echo "$html" | grep -oP 'content="[^"]*"[^>]*property="og:image"' | sed 's/.*content="//;s/".*//' | head -1)
fi
if [ -n "$img_url" ]; then
echo "[$slug] Found: $img_url" >&2
echo "$img_url"
else
echo "[$slug] No og:image found" >&2
fi
+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()
+95
View File
@@ -0,0 +1,95 @@
#!/tmp/recipe-venv/bin/python3
"""Find images for remaining 6 recipes."""
import re, sys, 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"}
recipes = [
("Arugula Pesto Chicken with Pecans & Roasted Butternut.cook", "arugula-pesto-chicken-with-pecans-roasted-butternut", "Arugula Pesto Chicken Pecans Roasted Butternut recipe garnishandgather"),
("Bang Bang Tofu.cook", "bang-bang-tofu", "Bang Bang Tofu recipe"),
("Chana Chickpea Salad.cook", "chana-chickpea-salad", "Chana Chickpea Salad recipe"),
("Gaja's Bulgogi Lettuce Wraps.cook", "gaja-s-bulgogi-lettuce-wraps", "Gaja Bulgogi Lettuce Wraps recipe garnishandgather"),
("Pecan-Brown Butter Trout.cook", "pecan-brown-butter-trout", "Pecan Brown Butter Trout recipe"),
("West African Peanut Chicken.cook", "west-african-peanut-chicken", "West African Peanut Chicken recipe"),
]
for file, slug, query in recipes:
fp = WORKDIR / file
dest = WORKDIR / "recipe-images" / f"{slug}.webp"
if dest.exists() and dest.stat().st_size > 10000:
print(f"[{slug}] Already done")
continue
print(f"[{slug}] Searching: {query}")
try:
results = list(DDGS().text(query, max_results=5))
except Exception as e:
print(f" Search error: {e}")
continue
found = False
for r in results:
url = r.get("href", "")
if not url or any(s in url for s in ["pinterest","facebook","instagram"]):
continue
print(f" Fetching: {url}")
try:
resp = requests.get(url, headers=HEADERS, timeout=15)
resp.raise_for_status()
except Exception as e:
print(f" Failed: {e}")
continue
soup = BeautifulSoup(resp.text, "html.parser")
img_urls = []
# og:image
for meta in soup.find_all("meta"):
c = meta.get("content", "")
p = (meta.get("property") or "").lower()
n = (meta.get("name") or "").lower()
if ("og:image" in p or "og:image" in n or "twitter:image" in n) and c and "logo" not in c.lower():
img_urls.append(c)
# img tags
for img in soup.find_all("img"):
src = img.get("src") or img.get("data-src") or ""
if src.startswith("http") and "logo" not in src.lower() and "icon" not in src.lower():
img_urls.append(src)
for img_url in img_urls[:5]:
try:
r2 = requests.get(img_url, headers=HEADERS, timeout=15)
r2.raise_for_status()
ct = r2.headers.get("content-type", "")
if ct.startswith("image/") and len(r2.content) > 10000:
dest.write_bytes(r2.content)
print(f" Downloaded ({len(r2.content)} bytes): {img_url[:60]}")
found = True
break
except:
pass
if found:
break
time.sleep(0.3)
if found:
# Add image field
url_val = f"https://roux.roo.lol/recipe-images/{slug}.webp"
content = fp.read_text(encoding="utf-8")
if re.search(r"^image:", content, re.MULTILINE):
content = re.sub(r"^image:.*$", f"image: {url_val}", count=1, flags=re.MULTILINE, string=content)
else:
content = re.sub(r"^---$", f"image: {url_val}\n---", count=1, flags=re.MULTILINE, string=content)
fp.write_text(content, encoding="utf-8")
print(f" Updated image field")
else:
print(f" FAILED")
time.sleep(0.5)
print("\nDone!")
+171
View File
@@ -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()
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""Search the web for recipe images, download them, and update .cook files.
Uses DuckDuckGo search to find recipe pages, then scrapes og:image.
"""
import csv
import os
import re
import sys
import time
from pathlib import Path
import requests
from bs4 import BeautifulSoup
from duckduckgo_search 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",
}
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
def fetch_og_image(url: str) -> str | None:
"""Fetch a page and extract og:image URL."""
try:
resp = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
resp.raise_for_status()
except requests.RequestException:
return None
soup = BeautifulSoup(resp.text, "html.parser")
# Try og:image meta tag (property or name)
for meta in soup.find_all("meta"):
prop = (meta.get("property") or "").lower()
name = (meta.get("name") or "").lower()
content = meta.get("content", "")
if "og:image" in prop and content:
return content
if "og:image" in name and content:
return content
if "twitter:image" in name and content:
return content
# Try schema.org JSON-LD
for script in soup.find_all("script", type="application/ld+json"):
import json
try:
data = json.loads(script.string)
if isinstance(data, dict):
img = data.get("image")
if isinstance(img, str) and img.startswith("http"):
return img
if isinstance(img, dict):
url = img.get("contentUrl") or img.get("url", "")
if url.startswith("http"):
return url
if isinstance(img, list) and img:
first = img[0]
if isinstance(first, str):
return first
if isinstance(first, dict):
url = first.get("contentUrl") or first.get("url", "")
if url.startswith("http"):
return url
except (json.JSONDecodeError, AttributeError):
pass
return None
def search_and_find_image(query: str) -> str | None:
"""Search DuckDuckGo for a recipe and find og:image."""
try:
results = list(DDGS().text(query, max_results=5))
except Exception as e:
print(f" Search error: {e}", file=sys.stderr)
return None
for r in results:
url = r.get("href", "")
if not url or "pinterest" in url or "facebook" in url or "instagram" in url:
continue
print(f" Trying: {url}", file=sys.stderr)
img = fetch_og_image(url)
if img:
print(f" Found image: {img}", file=sys.stderr)
return img
time.sleep(0.3)
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 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:
"""Extract source URL from .cook frontmatter."""
content = filepath.read_text(encoding="utf-8")
match = re.search(r"^source:\s*(.+)$", content, re.MULTILINE)
if match:
val = match.group(1).strip()
if val.startswith("http://") or val.startswith("https://"):
return val
return None
def process_recipe(filename: str, slug: str) -> bool:
"""Try to find and download an image for a recipe."""
filepath = WORKDIR / filename
dest = WORKDIR / "recipe-images" / f"{slug}.webp"
if dest.exists():
print(f"[{slug}] Already exists", file=sys.stderr)
add_image_field(filepath, slug)
return True
# Strategy 1: Try source URL if available
src_url = get_source_url(filepath)
if src_url:
print(f"[{slug}] Trying source URL: {src_url}", file=sys.stderr)
img = fetch_og_image(src_url)
if img:
if download_image(img, dest):
print(f" Downloaded from source", file=sys.stderr)
add_image_field(filepath, slug)
return True
# Strategy 2: Search by recipe name
# Get the title from frontmatter
content = filepath.read_text(encoding="utf-8")
title_match = re.search(r"^title:\s*(.+)$", content, re.MULTILINE)
if title_match:
query = title_match.group(1).strip() + " recipe"
else:
query = filename.replace(".cook", "").replace("_", " ").replace("-", " ") + " recipe"
print(f"[{slug}] Searching: {query}", file=sys.stderr)
img = search_and_find_image(query)
if img and download_image(img, dest):
print(f" Downloaded from search", file=sys.stderr)
add_image_field(filepath, slug)
return True
# Strategy 3: Try with "og:image" or "recipe image" variations
query2 = query + " image"
print(f"[{slug}] Searching (alt): {query2}", file=sys.stderr)
img = search_and_find_image(query2)
if img and download_image(img, dest):
print(f" Downloaded from alt search", file=sys.stderr)
add_image_field(filepath, slug)
return True
return False
def main():
# First process the no-image-has-fm list
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()))
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"
if dest.exists() and re.search(r"^image:", filepath.read_text(), re.MULTILINE):
print(f"[{slug}] Already processed, skipping", file=sys.stderr)
success.append(slug)
continue
ok = process_recipe(filename, slug)
if ok:
success.append(slug)
else:
failed.append(slug)
# Be polite
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:
with open(WORKDIR / "docs" / "failed-images.csv", "w") as f:
for s in failed:
f.write(f"{s}\n")
print(f"Failed: {', '.join(failed)}", file=sys.stderr)
if __name__ == "__main__":
main()
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Search DuckDuckGo Images for recipe photos, download, update .cook files."""
import csv
import re
import sys
import time
from pathlib import Path
import requests
from duckduckgo_search import DDGS
WORKDIR = Path("/home/jbrechtel/recipes/.worktrees/recipe-images")
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
}
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()
# Validate it's actually an image
content_type = resp.headers.get("content-type", "")
if not content_type.startswith("image/"):
print(f" Not an image (content-type: {content_type})", file=sys.stderr)
return False
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."""
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 search_images(query: str, max_results: int = 5):
"""Search DuckDuckGo Images for a recipe and return image URLs."""
try:
results = list(DDGS().images(query, max_results=max_results))
return [r["image"] for r in results if r.get("image")]
except Exception as e:
print(f" Image search error: {e}", file=sys.stderr)
return []
def get_title(filepath: Path) -> str:
"""Extract title from .cook frontmatter."""
content = filepath.read_text(encoding="utf-8")
match = re.search(r"^title:\s*(.+)$", content, re.MULTILINE)
if match:
return match.group(1).strip()
# Fall back to filename
name = filepath.stem
return name.replace("_", " ").replace("-", " ")
def main():
csv_file = WORKDIR / "docs" / "failed-images.csv"
# Also check which are actually missing
existing_csv = WORKDIR / "docs" / "no-image-has-fm.csv"
recipes = []
with open(existing_csv) as f:
reader = csv.reader(f, delimiter="|")
for row in reader:
if len(row) >= 2:
recipes.append((row[0].strip(), row[1].strip()))
# Filter to those without an image file or without image field
to_process = []
for filename, slug in recipes:
filepath = WORKDIR / filename
dest = WORKDIR / "recipe-images" / f"{slug}.webp"
has_image_file = dest.exists()
has_image_field = False
if filepath.exists():
content = filepath.read_text(encoding="utf-8")
has_image_field = bool(re.search(r"^image:", content, re.MULTILINE))
if not has_image_file or not has_image_field:
to_process.append((filename, slug))
print(f"Processing {len(to_process)} remaining recipes...", file=sys.stderr)
success = []
failed = []
for filename, slug in to_process:
filepath = WORKDIR / filename
dest = WORKDIR / "recipe-images" / f"{slug}.webp"
if not filepath.exists():
failed.append(slug)
continue
title = get_title(filepath)
queries = [
f"{title} recipe",
title,
]
found = False
for query in queries:
print(f"[{slug}] Searching images: {query}", file=sys.stderr)
urls = search_images(query)
for img_url in urls:
# Skip tiny icons, logos
if any(kw in img_url.lower() for kw in ["logo", "icon", "avatar", "merriam", "wikipedia"]):
continue
print(f" Trying: {img_url[:80]}...", file=sys.stderr)
if download_image(img_url, dest):
print(f" Downloaded!", file=sys.stderr)
add_image_field(filepath, slug)
found = True
break
time.sleep(0.2)
if found:
break
time.sleep(0.5)
if found:
success.append(slug)
else:
failed.append(slug)
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()