feat: Add ingredient scaling and enable wild mode scraping #2
@@ -2,11 +2,13 @@
|
|||||||
"""
|
"""
|
||||||
Recipe scraper script using the recipe-scrapers library.
|
Recipe scraper script using the recipe-scrapers library.
|
||||||
This script is called by the Node.js API to scrape recipes from URLs.
|
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 sys
|
||||||
import json
|
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):
|
def safe_extract(scraper, method_name, default=None):
|
||||||
"""Safely extract data from scraper, returning default if method fails."""
|
"""Safely extract data from scraper, returning default if method fails."""
|
||||||
@@ -32,10 +34,26 @@ def parse_servings(servings_str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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):
|
def scrape_recipe(url):
|
||||||
"""Scrape a recipe from the given URL and return JSON data."""
|
"""Scrape a recipe from the given URL and return JSON data."""
|
||||||
try:
|
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
|
# Extract recipe data with safe extraction
|
||||||
recipe_data = {
|
recipe_data = {
|
||||||
|
|||||||
@@ -136,6 +136,58 @@ nav a:hover {
|
|||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
color: #666;
|
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 {
|
.ingredients, .instructions {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Recipe } from '@basil/shared';
|
import { Recipe } from '@basil/shared';
|
||||||
import { recipesApi } from '../services/api';
|
import { recipesApi } from '../services/api';
|
||||||
|
import { scaleIngredientString } from '../utils/ingredientParser';
|
||||||
|
|
||||||
function RecipeDetail() {
|
function RecipeDetail() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -9,6 +10,7 @@ function RecipeDetail() {
|
|||||||
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
const [recipe, setRecipe] = useState<Recipe | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentServings, setCurrentServings] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -20,7 +22,9 @@ function RecipeDetail() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await recipesApi.getById(recipeId);
|
const response = await recipesApi.getById(recipeId);
|
||||||
setRecipe(response.data || null);
|
const loadedRecipe = response.data || null;
|
||||||
|
setRecipe(loadedRecipe);
|
||||||
|
setCurrentServings(loadedRecipe?.servings || null);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load recipe');
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (!id || !confirm('Are you sure you want to delete this recipe?')) {
|
if (!id || !confirm('Are you sure you want to delete this recipe?')) {
|
||||||
return;
|
return;
|
||||||
@@ -73,7 +93,22 @@ function RecipeDetail() {
|
|||||||
{recipe.prepTime && <span>Prep: {recipe.prepTime} min</span>}
|
{recipe.prepTime && <span>Prep: {recipe.prepTime} min</span>}
|
||||||
{recipe.cookTime && <span>Cook: {recipe.cookTime} min</span>}
|
{recipe.cookTime && <span>Cook: {recipe.cookTime} min</span>}
|
||||||
{recipe.totalTime && <span>Total: {recipe.totalTime} 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>
|
</div>
|
||||||
|
|
||||||
{recipe.sourceUrl && (
|
{recipe.sourceUrl && (
|
||||||
@@ -89,14 +124,30 @@ function RecipeDetail() {
|
|||||||
<div className="ingredients">
|
<div className="ingredients">
|
||||||
<h3>Ingredients</h3>
|
<h3>Ingredients</h3>
|
||||||
<ul>
|
<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}>
|
<li key={index}>
|
||||||
{ingredient.amount && `${ingredient.amount} `}
|
{displayStr}
|
||||||
{ingredient.unit && `${ingredient.unit} `}
|
|
||||||
{ingredient.name}
|
|
||||||
{ingredient.notes && ` (${ingredient.notes})`}
|
{ingredient.notes && ` (${ingredient.notes})`}
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
271
packages/web/src/utils/ingredientParser.ts
Normal file
271
packages/web/src/utils/ingredientParser.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user