From 5797dade02f1b5f446a65f484a0fca32538987df Mon Sep 17 00:00:00 2001 From: Paul R Kartchner Date: Tue, 28 Oct 2025 20:47:12 +0000 Subject: [PATCH] feat: add ingredient scaling and enable wild mode scraping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## New Features ### 1. Ingredient Scaling Based on Servings - Added interactive servings control with +/- buttons on recipe detail page - Ingredients scale proportionally when servings are adjusted - Smart parsing handles fractions (1/2, ΒΌ), mixed numbers (1 1/2), decimals, and ranges - Reset button to return to original servings - Non-scalable ingredients (e.g., "to taste") are detected and displayed unchanged **Files:** - NEW: packages/web/src/utils/ingredientParser.ts - Ingredient parsing and scaling logic - UPDATED: packages/web/src/pages/RecipeDetail.tsx - Added servings controls - UPDATED: packages/web/src/App.css - Styled servings control buttons ### 2. Wild Mode Scraping (Parity with Mealie) - Upgraded scraper to use `scrape_html()` with `supported_only=False` - Now works with ANY website that has recipe schema, not just 541+ supported sites - Matches Mealie's scraping capabilities - Successfully tested with littlespoonfarm.com and other previously unsupported sites **Changes:** - Switch from `scrape_me()` to `scrape_html()` with wild mode enabled - Added HTML fetching with proper user-agent headers - Now supports thousands of recipe websites beyond the officially supported list ## Testing βœ… Ingredient scaling tested with fractions, decimals, ranges βœ… Wild mode tested with littlespoonfarm.com (previously unsupported) βœ… Verified parity with Mealie's scraping performance πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/api/scripts/scrape_recipe.py | 22 +- packages/web/src/App.css | 52 ++++ packages/web/src/pages/RecipeDetail.tsx | 71 +++++- packages/web/src/utils/ingredientParser.ts | 271 +++++++++++++++++++++ 4 files changed, 404 insertions(+), 12 deletions(-) create mode 100644 packages/web/src/utils/ingredientParser.ts diff --git a/packages/api/scripts/scrape_recipe.py b/packages/api/scripts/scrape_recipe.py index f952a67..9a37bea 100644 --- a/packages/api/scripts/scrape_recipe.py +++ b/packages/api/scripts/scrape_recipe.py @@ -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 = { diff --git a/packages/web/src/App.css b/packages/web/src/App.css index 1d52278..2df8a45 100644 --- a/packages/web/src/App.css +++ b/packages/web/src/App.css @@ -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 { diff --git a/packages/web/src/pages/RecipeDetail.tsx b/packages/web/src/pages/RecipeDetail.tsx index 6583832..b2fb102 100644 --- a/packages/web/src/pages/RecipeDetail.tsx +++ b/packages/web/src/pages/RecipeDetail.tsx @@ -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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [currentServings, setCurrentServings] = useState(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 && Prep: {recipe.prepTime} min} {recipe.cookTime && Cook: {recipe.cookTime} min} {recipe.totalTime && Total: {recipe.totalTime} min} - {recipe.servings && Servings: {recipe.servings}} + {recipe.servings && currentServings !== null && ( +
+ + Servings: {currentServings} + + {currentServings !== recipe.servings && ( + + )} +
+ )} {recipe.sourceUrl && ( @@ -89,14 +124,30 @@ function RecipeDetail() {

Ingredients

    - {recipe.ingredients.map((ingredient, index) => ( -
  • - {ingredient.amount && `${ingredient.amount} `} - {ingredient.unit && `${ingredient.unit} `} - {ingredient.name} - {ingredient.notes && ` (${ingredient.notes})`} -
  • - ))} + {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 ( +
  • + {displayStr} + {ingredient.notes && ` (${ingredient.notes})`} +
  • + ); + })}
)} diff --git a/packages/web/src/utils/ingredientParser.ts b/packages/web/src/utils/ingredientParser.ts new file mode 100644 index 0000000..8b41f0f --- /dev/null +++ b/packages/web/src/utils/ingredientParser.ts @@ -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 = { + 'ΒΌ': 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); +}