feat: Add ingredient scaling and enable wild mode scraping #2

Merged
pkartch merged 1 commits from feature/ingredient-scaling into main 2025-10-30 05:37:34 +00:00
4 changed files with 404 additions and 12 deletions
Showing only changes of commit 5797dade02 - Show all commits

View File

@@ -2,11 +2,13 @@
"""
Recipe scraper script using the recipe-scrapers library.
This script is called by the Node.js API to scrape recipes from URLs.
Uses wild mode (supported_only=False) to work with any website, not just officially supported ones.
"""
import sys
import json
from recipe_scrapers import scrape_me
import urllib.request
from recipe_scrapers import scrape_html
def safe_extract(scraper, method_name, default=None):
"""Safely extract data from scraper, returning default if method fails."""
@@ -32,10 +34,26 @@ def parse_servings(servings_str):
except Exception:
return None
def fetch_html(url):
"""Fetch HTML content from URL with proper headers."""
req = urllib.request.Request(
url,
headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
)
with urllib.request.urlopen(req, timeout=30) as response:
return response.read().decode('utf-8')
def scrape_recipe(url):
"""Scrape a recipe from the given URL and return JSON data."""
try:
scraper = scrape_me(url)
# Fetch HTML content
html = fetch_html(url)
# Use scrape_html with supported_only=False to enable wild mode
# This allows scraping from ANY website, not just the 541+ officially supported ones
scraper = scrape_html(html, org_url=url, supported_only=False)
# Extract recipe data with safe extraction
recipe_data = {

View File

@@ -136,6 +136,58 @@ nav a:hover {
gap: 2rem;
margin-bottom: 2rem;
color: #666;
align-items: center;
flex-wrap: wrap;
}
.servings-control {
display: flex;
align-items: center;
gap: 0.5rem;
}
.servings-control button {
width: 32px;
height: 32px;
padding: 0;
font-size: 1.2rem;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background-color: #2d5016;
color: white;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.servings-control button:hover:not(:disabled) {
background-color: #3d6821;
}
.servings-control button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.servings-control .reset-button {
width: auto;
height: auto;
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
font-weight: normal;
background-color: #666;
}
.servings-control .reset-button:hover {
background-color: #777;
}
.servings-control span {
margin: 0 0.25rem;
white-space: nowrap;
}
.ingredients, .instructions {

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Recipe } from '@basil/shared';
import { recipesApi } from '../services/api';
import { scaleIngredientString } from '../utils/ingredientParser';
function RecipeDetail() {
const { id } = useParams<{ id: string }>();
@@ -9,6 +10,7 @@ function RecipeDetail() {
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentServings, setCurrentServings] = useState<number | null>(null);
useEffect(() => {
if (id) {
@@ -20,7 +22,9 @@ function RecipeDetail() {
try {
setLoading(true);
const response = await recipesApi.getById(recipeId);
setRecipe(response.data || null);
const loadedRecipe = response.data || null;
setRecipe(loadedRecipe);
setCurrentServings(loadedRecipe?.servings || null);
setError(null);
} catch (err) {
setError('Failed to load recipe');
@@ -30,6 +34,22 @@ function RecipeDetail() {
}
};
const incrementServings = () => {
if (currentServings !== null) {
setCurrentServings(currentServings + 1);
}
};
const decrementServings = () => {
if (currentServings !== null && currentServings > 1) {
setCurrentServings(currentServings - 1);
}
};
const resetServings = () => {
setCurrentServings(recipe?.servings || null);
};
const handleDelete = async () => {
if (!id || !confirm('Are you sure you want to delete this recipe?')) {
return;
@@ -73,7 +93,22 @@ function RecipeDetail() {
{recipe.prepTime && <span>Prep: {recipe.prepTime} min</span>}
{recipe.cookTime && <span>Cook: {recipe.cookTime} min</span>}
{recipe.totalTime && <span>Total: {recipe.totalTime} min</span>}
{recipe.servings && <span>Servings: {recipe.servings}</span>}
{recipe.servings && currentServings !== null && (
<div className="servings-control">
<button onClick={decrementServings} disabled={currentServings <= 1}>
</button>
<span>Servings: {currentServings}</span>
<button onClick={incrementServings}>
+
</button>
{currentServings !== recipe.servings && (
<button onClick={resetServings} className="reset-button">
Reset
</button>
)}
</div>
)}
</div>
{recipe.sourceUrl && (
@@ -89,14 +124,30 @@ function RecipeDetail() {
<div className="ingredients">
<h3>Ingredients</h3>
<ul>
{recipe.ingredients.map((ingredient, index) => (
{recipe.ingredients.map((ingredient, index) => {
// Construct the full ingredient string
let ingredientStr = '';
if (ingredient.amount && ingredient.unit) {
ingredientStr = `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`;
} else if (ingredient.amount) {
ingredientStr = `${ingredient.amount} ${ingredient.name}`;
} else {
ingredientStr = ingredient.name;
}
// Apply scaling if servings changed
const displayStr =
recipe.servings && currentServings && recipe.servings !== currentServings
? scaleIngredientString(ingredientStr, recipe.servings, currentServings)
: ingredientStr;
return (
<li key={index}>
{ingredient.amount && `${ingredient.amount} `}
{ingredient.unit && `${ingredient.unit} `}
{ingredient.name}
{displayStr}
{ingredient.notes && ` (${ingredient.notes})`}
</li>
))}
);
})}
</ul>
</div>
)}

View File

@@ -0,0 +1,271 @@
/**
* Ingredient Parser Utility
* Parses ingredient strings to extract amount, unit, and ingredient name
* Handles fractions, decimals, ranges, and unicode characters
*/
interface ParsedIngredient {
amount: number | null;
amountRange: { min: number; max: number } | null;
unit: string;
name: string;
original: string;
scalable: boolean;
}
// Unicode fraction mappings
const UNICODE_FRACTIONS: Record<string, number> = {
'¼': 0.25,
'½': 0.5,
'¾': 0.75,
'⅓': 0.333,
'⅔': 0.667,
'⅕': 0.2,
'⅖': 0.4,
'⅗': 0.6,
'⅘': 0.8,
'⅙': 0.167,
'⅚': 0.833,
'⅛': 0.125,
'⅜': 0.375,
'⅝': 0.625,
'⅞': 0.875,
};
// Common units (for parsing, not exhaustive)
const UNITS = [
// Volume
'cup', 'cups', 'c',
'tablespoon', 'tablespoons', 'tbsp', 'tbs', 'tb',
'teaspoon', 'teaspoons', 'tsp', 'ts',
'fluid ounce', 'fluid ounces', 'fl oz', 'fl. oz.',
'milliliter', 'milliliters', 'ml',
'liter', 'liters', 'l',
'pint', 'pints', 'pt',
'quart', 'quarts', 'qt',
'gallon', 'gallons', 'gal',
// Weight
'pound', 'pounds', 'lb', 'lbs',
'ounce', 'ounces', 'oz',
'gram', 'grams', 'g',
'kilogram', 'kilograms', 'kg',
// Count/Other
'piece', 'pieces',
'slice', 'slices',
'clove', 'cloves',
'can', 'cans',
'package', 'packages', 'pkg',
'bunch', 'bunches',
'pinch', 'pinches',
'dash', 'dashes',
'handful', 'handfuls',
];
/**
* Convert fraction string to decimal
*/
function fractionToDecimal(fraction: string): number {
const parts = fraction.split('/');
if (parts.length !== 2) return 0;
const numerator = parseFloat(parts[0]);
const denominator = parseFloat(parts[1]);
if (isNaN(numerator) || isNaN(denominator) || denominator === 0) {
return 0;
}
return numerator / denominator;
}
/**
* Parse amount string (handles fractions, decimals, mixed numbers, ranges)
*/
function parseAmount(amountStr: string): { value: number | null; range: { min: number; max: number } | null } {
amountStr = amountStr.trim();
// Replace unicode fractions
for (const [unicode, decimal] of Object.entries(UNICODE_FRACTIONS)) {
amountStr = amountStr.replace(unicode, ` ${decimal}`);
}
// Handle ranges: "2-3", "1 to 2", "1-2"
const rangeMatch = amountStr.match(/^(\d+(?:\.\d+)?)\s*(?:-|to)\s*(\d+(?:\.\d+)?)$/i);
if (rangeMatch) {
const min = parseFloat(rangeMatch[1]);
const max = parseFloat(rangeMatch[2]);
return { value: null, range: { min, max } };
}
// Handle mixed numbers: "1 1/2", "2 3/4"
const mixedMatch = amountStr.match(/^(\d+)\s+(\d+)\/(\d+)$/);
if (mixedMatch) {
const whole = parseFloat(mixedMatch[1]);
const fraction = fractionToDecimal(`${mixedMatch[2]}/${mixedMatch[3]}`);
return { value: whole + fraction, range: null };
}
// Handle simple fractions: "1/2", "3/4"
if (amountStr.includes('/')) {
return { value: fractionToDecimal(amountStr), range: null };
}
// Handle decimal numbers: "1.5", "2.25"
const decimal = parseFloat(amountStr);
if (!isNaN(decimal)) {
return { value: decimal, range: null };
}
return { value: null, range: null };
}
/**
* Parse ingredient string into components
*/
export function parseIngredient(ingredientStr: string): ParsedIngredient {
const original = ingredientStr;
// Check for non-scalable patterns
const nonScalablePatterns = [
/to taste/i,
/as needed/i,
/for (?:serving|garnish|dusting)/i,
/optional/i,
];
const isNonScalable = nonScalablePatterns.some(pattern => pattern.test(ingredientStr));
if (isNonScalable) {
return {
amount: null,
amountRange: null,
unit: '',
name: ingredientStr,
original,
scalable: false,
};
}
// Extract amount from beginning of string
// Matches patterns like: "2", "1/2", "1 1/2", "2-3", "1.5", "¼"
const amountPattern = /^([\d\u00BC-\u00BE\u2150-\u215E\s\/.-]+)/;
const amountMatch = ingredientStr.match(amountPattern);
if (!amountMatch) {
// No amount found - return as-is, not scalable
return {
amount: null,
amountRange: null,
unit: '',
name: ingredientStr,
original,
scalable: false,
};
}
const amountStr = amountMatch[1].trim();
const { value: amount, range: amountRange } = parseAmount(amountStr);
// Remove amount from string
let remaining = ingredientStr.substring(amountMatch[0].length).trim();
// Extract unit (check against known units)
let unit = '';
for (const possibleUnit of UNITS) {
// Case-insensitive match at the beginning of remaining string
const unitPattern = new RegExp(`^${possibleUnit}\\b`, 'i');
if (unitPattern.test(remaining)) {
unit = remaining.match(unitPattern)![0];
remaining = remaining.substring(unit.length).trim();
break;
}
}
// Remaining text is the ingredient name
const name = remaining;
return {
amount,
amountRange,
unit,
name,
original,
scalable: amount !== null || amountRange !== null,
};
}
/**
* Format a number as a fraction or mixed number for display
*/
export function formatAmount(amount: number): string {
// If whole number, return as-is
if (Number.isInteger(amount)) {
return amount.toString();
}
// Check if close to common fractions
const commonFractions = [
{ value: 0.125, display: '⅛' },
{ value: 0.25, display: '¼' },
{ value: 0.333, display: '⅓' },
{ value: 0.375, display: '⅜' },
{ value: 0.5, display: '½' },
{ value: 0.625, display: '⅝' },
{ value: 0.667, display: '⅔' },
{ value: 0.75, display: '¾' },
{ value: 0.875, display: '⅞' },
];
const wholePart = Math.floor(amount);
const fractionalPart = amount - wholePart;
// Find closest fraction
for (const frac of commonFractions) {
if (Math.abs(fractionalPart - frac.value) < 0.01) {
if (wholePart > 0) {
return `${wholePart} ${frac.display}`;
}
return frac.display;
}
}
// Otherwise, round to 1 decimal place
return amount.toFixed(1);
}
/**
* Scale an ingredient by a ratio
*/
export function scaleIngredient(parsed: ParsedIngredient, ratio: number): string {
if (!parsed.scalable || ratio === 1) {
return parsed.original;
}
let scaledAmountStr = '';
if (parsed.amount !== null) {
const scaledAmount = parsed.amount * ratio;
scaledAmountStr = formatAmount(scaledAmount);
} else if (parsed.amountRange !== null) {
const scaledMin = parsed.amountRange.min * ratio;
const scaledMax = parsed.amountRange.max * ratio;
scaledAmountStr = `${formatAmount(scaledMin)}-${formatAmount(scaledMax)}`;
}
// Reconstruct the ingredient string
const parts = [scaledAmountStr, parsed.unit, parsed.name].filter(p => p).join(' ');
return parts;
}
/**
* Main function to scale an ingredient string
*/
export function scaleIngredientString(ingredientStr: string, originalServings: number, newServings: number): string {
if (originalServings === 0 || newServings === originalServings) {
return ingredientStr;
}
const ratio = newServings / originalServings;
const parsed = parseIngredient(ingredientStr);
return scaleIngredient(parsed, ratio);
}