Files
vrobbler/vrobbler/apps/foods/sources/rscraper.py
Colin Powell 770a51b9c0
Some checks failed
build & deploy / test (push) Failing after 1m10s
build & deploy / deploy (push) Has been skipped
[foods] Add recipe website parsing
2026-03-06 10:46:34 -05:00

183 lines
6.4 KiB
Python

# services/recipe_scraper.py
from recipe_scrapers import scrape_html
from recipe_scrapers._exceptions import (
NoSchemaFoundInWildMode,
SchemaOrgException,
)
import requests
from typing import Dict, Optional, List
from django.core.files.base import ContentFile
class RecipeScraperService:
def __init__(self):
self.session = requests.Session()
self.session.headers.update(
{"User-Agent": "Mozilla/5.0 (compatible; Vrobbler/0.3)"}
)
def scrape(self, html: str, org_url: str) -> Dict:
"""Scrape recipe data from HTML.
Args:
html: The HTML content of the page
org_url: The original URL the HTML was fetched from
"""
try:
scraper = scrape_html(html, org_url=org_url, supported_only=False)
except Exception as e:
raise ValueError(f"Failed to scrape {org_url}: {str(e)}")
data_dict = {
"title": scraper.title(),
"ingredients": self._safe_call(scraper, "ingredients", []),
"instructions": self._safe_call(scraper, "instructions_list", []),
"yields": self._safe_call(scraper, "yields"),
"nutrition": self._safe_call(scraper, "nutrition"),
"course": self._safe_call(scraper, "course"),
"dietary": self._safe_call(scraper, "dietary", []),
"prep_time": self._safe_call(scraper, "prep_time"),
"total_time": self._safe_call(scraper, "total_time"),
"image": self._safe_call(scraper, "image"),
"cook_time": self._safe_call(scraper, "cook_time"),
"cuisine": self._safe_call(scraper, "cuisine"),
"site": self._safe_call(scraper, "host"),
"url": org_url,
"category": self._safe_call(scraper, "category"),
"keywords": self._safe_call(scraper, "keywords"),
}
return data_dict
def scrape_url(self, url: str):
response = self.session.get(url)
if response.status_code != 200:
raise Exception("Recipe website returned non 200 response", e)
return self.scrape(response.text, org_url=url)
@classmethod
def is_recipe(self, html: str, org_url: str) -> bool:
"""Check if HTML contains a recipe.
Args:
html: The HTML content to check
org_url: The original URL the HTML was fetched from
"""
try:
scraper = scrape_html(html, org_url=org_url, supported_only=False)
return scraper.schema is not None
except NoSchemaFoundInWildMode:
return False
except Exception:
return False
@classmethod
def is_recipe_url(cls, url: str) -> bool:
"""Check if a URL points to a recipe by fetching and checking the HTML."""
try:
response = requests.get(
url,
timeout=10,
headers={
"User-Agent": "Mozilla/5.0 (compatible; Vrobbler/0.3)"
},
)
if response.status_code != 200:
logger.debug("Recipe website returned non 200 response")
return False
return cls.is_recipe(response.text, url)
except Exception as e:
print(e)
return False
def _safe_call(self, scraper, method, default=None):
"""
Safely call a scraper method, returning default on error.
Handles both missing methods (AttributeError) and methods that return None.
"""
try:
# Check if method exists before calling
if not hasattr(scraper, method):
return default
result = getattr(scraper, method)()
return result if result else default
except (
AttributeError,
TypeError,
KeyError,
ValueError,
SchemaOrgException,
):
# AttributeError: method doesn't exist on this scraper class
# TypeError: method exists but can't be called
# KeyError: method exists but data structure is unexpected
return default
def download_image(self, image_url: str) -> Optional[ContentFile]:
"""Download recipe image and return as Django ContentFile."""
if not image_url:
return None
try:
response = self.session.get(image_url, timeout=10)
response.raise_for_status()
filename = image_url.split("/")[-1].split("?")[0]
if not filename.endswith((".jpg", ".jpeg", ".png", ".webp")):
filename = "recipe_image.jpg"
return ContentFile(response.content, name=filename)
except Exception:
return None
def parse_servings(self, yield_text: str) -> Optional[int]:
"""Extract number of servings from yield text."""
import re
if not yield_text:
return None
match = re.search(r"(\d+)", yield_text)
return int(match.group(1)) if match else None
def extract_tags(self, recipe_data: Dict) -> List[str]:
"""Extract all tags from recipe metadata."""
tags = set()
# Add cuisine
if recipe_data.get("cuisine"):
cuisine = recipe_data["cuisine"]
if isinstance(cuisine, str):
tags.add(cuisine.strip())
elif isinstance(cuisine, list):
tags.update([c.strip() for c in cuisine if c])
# Add dietary
if recipe_data.get("dietary"):
dietary = recipe_data["dietary"]
if isinstance(dietary, str):
tags.add(dietary.strip())
elif isinstance(dietary, list):
tags.update([d.strip() for d in dietary if d])
# Add course
if recipe_data.get("course"):
course = recipe_data["course"]
if isinstance(course, str):
tags.add(course.strip())
elif isinstance(course, list):
tags.update([c.strip() for c in course if c])
# Add keywords
keywords = recipe_data.get("keywords")
if keywords:
if isinstance(keywords, str):
tags.update(
[k.strip() for k in keywords.split(",") if k.strip()]
)
elif isinstance(keywords, list):
tags.update([k.strip() for k in keywords if k.strip()])
return list(tags)