feat: add ingredient scaling and enable wild mode scraping
Some checks failed
CI Pipeline / Lint Code (pull_request) Has been cancelled
CI Pipeline / Test API Package (pull_request) Has been cancelled
CI Pipeline / Test Web Package (pull_request) Has been cancelled
CI Pipeline / Test Shared Package (pull_request) Has been cancelled
CI Pipeline / Build All Packages (pull_request) Has been cancelled
CI Pipeline / Generate Coverage Report (pull_request) Has been cancelled
Docker Build & Deploy / Build Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Push Docker Images (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Staging (pull_request) Has been cancelled
Docker Build & Deploy / Deploy to Production (pull_request) Has been cancelled
E2E Tests / End-to-End Tests (pull_request) Has been cancelled
E2E Tests / E2E Tests (Mobile) (pull_request) Has been cancelled
Security Scanning / NPM Audit (pull_request) Has been cancelled
Security Scanning / Dependency License Check (pull_request) Has been cancelled
Security Scanning / Code Quality Scan (pull_request) Has been cancelled
Security Scanning / Docker Image Security (pull_request) Has been cancelled
Security Scanning / Security Summary (pull_request) Has been cancelled

## 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 <noreply@anthropic.com>
This commit is contained in:
2025-10-28 20:47:12 +00:00
parent 0db8180d8a
commit 5797dade02
4 changed files with 404 additions and 12 deletions

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);
}