recipe-scrapers-js
Version:
A recipe scrapers library
364 lines (259 loc) • 11.3 kB
Markdown
> **Note**: This document focuses specifically on the ingredients extraction system. For overall project architecture, see [Architecture](./architecture.md).
The ingredients extraction system is designed to extract and structure recipe ingredients from various cooking websites. The system uses a multi-layered approach combining generic extraction plugins with site-specific scrapers to produce a consistent data structure.
```typescript
type ParsedIngredient = {
quantity: number | null // Primary quantity (e.g., 2)
quantity2: number | null // Secondary quantity for ranges (e.g., 1-2 cups)
unitOfMeasureID: string | null // Normalized unit key (e.g., "cup")
unitOfMeasure: string | null // Unit as written (e.g., "cups")
description: string // Ingredient name (e.g., "flour")
isGroupHeader: boolean // True if this is a section header
}
type IngredientItem = {
value: string // The ingredient text, e.g., "1 1/2 cups flour"
parsed?: ParsedIngredient // Structured data (optional, via parseIngredients option)
}
type IngredientGroup = {
name: string | null // Group name, e.g., "For the dough" or null for default
items: IngredientItem[] // Array of ingredients in this group
}
type Ingredients = IngredientGroup[] // Array of groups
```
**Why no IDs?**
- Initially had `id` fields on both items and groups
- Removed for simplicity - IDs added complexity without providing value
- Groups and items are identified by their position in arrays
**Why groups?**
- Many recipes organize ingredients into sections (e.g., "For the crust", "For the filling")
- Default group name is `null` for ungrouped ingredients
- Preserves recipe structure and improves readability
## Extraction Flow
### 1. Plugin-Based Extraction
The `RecipeExtractor` runs multiple extraction plugins in priority order:
```txt
1. Schema.org Plugin (priority: 90)
├─> Extracts from <script type="application/ld+json">
└─> Returns: Ingredients with clean, normalized text
2. OpenGraph Plugin (priority: 50)
└─> Fallback for basic metadata
3. PostProcessor Plugins (in priority order)
├─> HtmlStripperPlugin (100): Removes HTML tags from text values
└─> IngredientParserPlugin (50): Parses ingredients into structured data*
*Only active when `parseIngredients` option is enabled
```
**Key Insight**: Schema.org JSON-LD provides **clean, well-formatted text**:
- Proper spacing: `"1 1/2 cups flour"`
- Normalized fractions: `"1/2"` instead of `"½"`
- No HTML artifacts or concatenation issues
### 2. Site-Specific Scrapers
Scrapers extend `AbstractScraper` and can override any field extractor:
```typescript
class NYTimes extends AbstractScraper {
extractors = {
ingredients: this.ingredients.bind(this),
}
protected ingredients(prevValue: Ingredients | undefined): Ingredients {
// Custom logic here
}
}
```
**The `prevValue` Parameter**:
- Contains the result from plugin extraction (usually Schema.org)
- Provides clean text that scrapers can restructure
- Optional - scrapers can extract from scratch if needed
## The Flatten→Regroup Pattern
### Why This Pattern Exists
Many recipe websites have **structure in HTML** but **clean text in JSON-LD**:
**HTML Structure** (NYTimes example):
```html
<h3>For the dough</h3>
<li>1½cups flour</li> <!-- ❌ No space, unicode fraction -->
<li>½teaspoon salt</li> <!-- ❌ Concatenated -->
<h3>For the filling</h3>
<li>2cups sugar</li> <!-- ❌ No space -->
```
**JSON-LD Text** (from Schema.org):
```json
[
"1 1/2 cups flour", // ✅ Clean, spaced, normalized
"1/2 teaspoon salt", // ✅ Perfect formatting
"2 cups sugar" // ✅ No issues
]
```
**The Problem**:
- JSON-LD has **perfect text** but **loses grouping** (all ingredients in flat array)
- HTML has **accurate grouping** but **poor text quality** (no spaces, unicode chars, etc.)
**The Solution**: Use BOTH!
1. Extract clean text from JSON-LD (via Schema.org plugin)
2. Flatten it to strings for matching
3. Parse HTML structure for grouping
4. Match HTML elements to JSON-LD text
5. Rebuild grouped structure with clean text
### Implementation
```typescript
protected ingredients(prevValue: Ingredients | undefined): Ingredients {
const headingSelector = 'h3.ingredient-group'
const ingredientSelector = 'li.ingredient'
if (prevValue && prevValue.length > 0) {
// Step 1: Flatten prevValue (from Schema.org) to get clean text
const values = flattenIngredients(prevValue)
// values = ["1 1/2 cups flour", "1/2 teaspoon salt", "2 cups sugar"]
// Step 2: Parse HTML and match text to rebuild groups
return groupIngredients(
this.$, // Cheerio instance
values, // Clean text from JSON-LD
headingSelector, // Where to find group names
ingredientSelector, // Where to find ingredient items
)
}
throw new NoIngredientsFoundException()
}
```
Converts grouped structure to flat array of strings:
```typescript
// Input:
[
{ name: "For the dough", items: [{ value: "1 cup flour" }] },
{ name: "", items: [{ value: "Salt to taste" }] }
]
// Output:
["1 cup flour", "Salt to taste"]
```
**Why flatten?** To get an ordered list of clean text values for matching against HTML elements.
Rebuilds grouped structure by:
1. Parsing HTML to find headings and items
2. Matching HTML text (normalized) to `values` array (normalized)
3. Creating groups based on HTML structure
4. Using matched text from `values` (preserving clean formatting)
**Normalization**: Both HTML text and values are normalized before matching (trim, lowercase, collapse whitespace) to ensure reliable matches despite formatting differences.
#### `stringsToIngredients(values: string[]): Ingredients`
Converts flat string array to default group structure:
```typescript
// Input:
["1 cup flour", "Salt to taste"]
// Output:
[
{
name: null, // Default group
items: [
{ value: "1 cup flour" },
{ value: "Salt to taste" }
]
}
]
```
Used by Schema.org plugin when converting `recipeIngredient` array.
- ✅ JSON-LD provides clean text but lacks grouping
- ✅ HTML has visible grouping structure (headings, sections)
- ✅ Text quality in HTML is poor (spacing, unicode, concatenation issues)
- **Example**: NYTimes, BBC Good Food, Simply Recipes
### Parse HTML Directly When
- ✅ JSON-LD is missing or unreliable
- ✅ HTML text quality is good
- ✅ Complex custom structure that doesn't fit standard pattern
- **Example**: Sites without proper Schema.org markup
### Use Default (No Override) When
- ✅ Schema.org JSON-LD provides both good text AND grouping
- ✅ No special processing needed
- **Example**: Sites with perfect Schema.org implementation
## Common Pitfalls
### ❌ Don't Parse HTML Text Directly If You Have JSON-LD
```typescript
// BAD: HTML text has formatting issues
protected ingredients(): Ingredients {
const items = this.$('li.ingredient').map((_, el) =>
this.$(el).text() // "1½cups" - no space!
).get()
// ...
}
```
```typescript
// GOOD: Use JSON-LD text with HTML structure
protected ingredients(prevValue: Ingredients | undefined): Ingredients {
const values = flattenIngredients(prevValue) // Clean text from JSON-LD
return groupIngredients(this.$, values, headingSelector, itemSelector)
}
```
```typescript
// BAD: Modifying clean text
const values = flattenIngredients(prevValue)
.map(v => v.toUpperCase()) // Don't do this!
```
The text from JSON-LD is already clean and normalized. Preserve it.
```typescript
// BAD: Unnecessary work
const values = flattenIngredients(prevValue)
return stringsToIngredients(values) // Just return prevValue!
```
If Verify test passes with both HTML and JSON-LD extraction
The library supports optional structured parsing of ingredient strings using the [parse-ingredient](https://github.com/jakeboone02/parse-ingredient) library. When enabled via the `parseIngredients` option, each ingredient item includes a `parsed` field with extracted data.
```typescript
// Enable with defaults
const scraper = new MyScraper(html, url, { parseIngredients: true })
// Enable with options
const scraper = new MyScraper(html, url, {
parseIngredients: {
normalizeUOM: true, // "tbsp" → "tablespoon"
ignoreUOMs: ['small'], // Treat as description, not unit
}
})
```
The `IngredientParserPlugin` runs as a PostProcessor (priority 50), after HTML stripping:
```txt
1. HtmlStripperPlugin (100)
└─> "2 cups <b>flour</b>" → "2 cups flour"
2. IngredientParserPlugin (50)
└─> "2 cups flour" → { value: "2 cups flour", parsed: {...} }
```
```typescript
{
value: "1-2 tablespoons olive oil",
parsed: {
quantity: 1, // Primary quantity
quantity2: 2, // Secondary (range) quantity
unitOfMeasure: "tablespoons", // As written
unitOfMeasureID: "tablespoon", // Normalized key
description: "olive oil", // Ingredient name
isGroupHeader: false // True for "For the sauce:" etc.
}
}
```
- **Scaling recipes**: Multiply quantities by a factor
- **Shopping lists**: Aggregate same ingredients across recipes
- **Nutritional lookup**: Search by normalized ingredient name
- **Unit conversion**: Convert between measurement systems
1. **Text Normalization Pipeline**: Configurable normalization steps (fraction conversion, unit standardization, etc.)
2. **Fuzzy Matching**: Handle cases where HTML text diverges significantly from JSON-LD (currently uses exact normalized matching)
3. **Fallback Strategies**: Graceful degradation when JSON-LD is partial or HTML structure is ambiguous
4. **Schema.org Validation**: Detect and handle malformed JSON-LD more robustly
### Non-Goals
- **Unit Conversion**: Converting between measurement systems (use parsed data with external tools)
- **Substitutions**: Handling ingredient alternatives or substitutions
- **Nutritional Analysis**: Calculating nutrition facts from ingredients
## Summary
The ingredients system balances **text quality** (from JSON-LD) with **structural accuracy** (from HTML):
1. **Schema.org Plugin** extracts clean text → `Ingredients` with default grouping
2. **Scrapers** flatten text → parse HTML structure → rebuild groups with clean text
3. **IngredientParserPlugin** (optional) → adds structured `parsed` data
4. **Result**: Accurate grouping with high-quality, normalized ingredient text and optional structured data
This architecture leverages the strengths of both JSON-LD (clean data) and HTML (visual structure) to produce the best possible output.