recipe-scrapers-js
Version:
A recipe scrapers library
579 lines (434 loc) • 17.4 kB
Markdown
# Recipe Scrapers JS - Architecture
## Overview
Recipe Scrapers JS is a TypeScript library for extracting structured recipe data from cooking websites. It uses a plugin-based architecture that combines generic extraction methods with site-specific customizations to produce consistent `RecipeObject` outputs.
### Key Features
- **Multi-source extraction**: JSON-LD (Schema.org), Microdata, OpenGraph
- **Plugin architecture**: Extensible extraction and post-processing pipeline
- **Site-specific scrapers**: Custom logic for popular recipe sites
- **Runtime validation**: Zod-based schema validation for data quality
- **Type-safe**: Full TypeScript support with strict typing
- **Test coverage**: Comprehensive test suite with real HTML fixtures
## Core Concepts
### RecipeObject
The output format representing a complete recipe. See `src/types/recipe.interface.ts` for the full interface definition.
### Extraction & Validation Pipeline
```txt
HTML Input
↓
RecipeExtractor / AbstractScraper
↓
┌─────────────────────────┐
│ Extractor Plugins │
│ (Priority Order) │
├─────────────────────────┤
│ 1. Schema.org (90) │ ← JSON-LD extraction
│ 2. Microdata (80) │ ← HTML microdata
│ 3. OpenGraph (50) │ ← OG meta tags
└─────────────────────────┘
↓
Partial RecipeObject
↓
Site-Specific Scraper (optional)
↓
┌─────────────────────────┐
│ PostProcessor Plugins │
├─────────────────────────┤
│ • HTML Stripper (100) │ ← Remove HTML tags
│ • Ingredient Parser* │ ← Parse ingredients (optional)
└─────────────────────────┘
↓
┌─────────────────────────┐
│ Validation (Optional) │
├─────────────────────────┤
│ • Zod Schema │ ← Runtime validation
│ • Type checking │
│ • Auto-fixes │
└─────────────────────────┘
↓
Final RecipeObject
```
## Plugin System
### Plugin Types
#### 1. ExtractorPlugin
Extracts data from HTML to populate `RecipeObject` fields.
```typescript
abstract class AbstractExtractorPlugin {
abstract readonly priority: number // Higher = runs first
// Override to extract specific fields
name?($: CheerioAPI, prevValue?: string): string | undefined
ingredients?($: CheerioAPI, prevValue?: Ingredients): Ingredients | undefined
// ... other field extractors
}
```
**Priority System**:
- Higher priority plugins run first
- Later plugins can override or enhance earlier results
- Use `prevValue` to access previous extraction results
**Built-in Extractors**:
- **Schema.org** (priority 90): Primary extractor for JSON-LD data
- **OpenGraph** (priority 50): Fallback for basic metadata
#### 2. PostProcessorPlugin
Processes extracted data after all extraction is complete.
```typescript
abstract class AbstractPostprocessorPlugin {
abstract readonly priority: number
abstract process(recipe: RecipeObject): RecipeObject
}
```
**Built-in Processors**:
- **HtmlStripperPlugin** (priority 100): Removes HTML tags from text fields
- **IngredientParserPlugin** (priority 50): Parses ingredient strings into structured data (optional, enabled via `parseIngredients` option)
### Plugin Registration
Plugins are registered in `PluginManager`:
```typescript
const pluginManager = new PluginManager()
// Add extractors
pluginManager.registerExtractorPlugin(new SchemaOrgExtractorPlugin())
pluginManager.registerExtractorPlugin(new OpenGraphExtractorPlugin())
// Add post-processors
pluginManager.registerPostprocessorPlugin(new HtmlStripperPlugin())
```
## Site-Specific Scrapers
### AbstractScraper
Base class for all site-specific scrapers:
```typescript
abstract class AbstractScraper {
protected $: CheerioAPI // Cheerio instance for HTML parsing
static host(): string // Domain this scraper handles
extractors: {
[K in keyof RecipeFields]?: (prevValue?: RecipeFields[K]) => RecipeFields[K]
}
// Extraction methods
toRecipeObject(): RecipeObject // Extract without validation
parse(): RecipeObjectValidated // Extract with validation (throws on error)
safeParse(): SafeParseReturnType // Extract with validation (returns result)
// Override for custom validation
protected getSchema(): ZodSchema
}
```
### Validation Methods
**`toRecipeObject()`** - Extract without validation
```typescript
const recipe = scraper.toRecipeObject()
// Returns RecipeObject - no runtime checks
```
**`parse()`** - Extract with validation (throws ZodError on failure)
```typescript
try {
const recipe = scraper.parse()
// Returns RecipeObjectValidated - guaranteed valid
} catch (error) {
if (error instanceof ZodError) {
console.error(error.format())
}
}
```
**`safeParse()`** - Extract with validation (returns result object)
```typescript
const result = scraper.safeParse()
if (result.success) {
console.log(result.data) // RecipeObjectValidated
} else {
console.error(result.error.issues) // Validation errors
}
```
### How Scrapers Work
1. **Domain matching**: `host()` method identifies which scraper to use
2. **Plugin extraction**: Generic plugins extract base data
3. **Custom extraction**: Scraper's `extractors` override/enhance fields
4. **Post-processing**: PostProcessor plugins clean up final output
### Example Scraper with Custom Validation
```typescript
class NYTimes extends AbstractScraper {
static host() {
return 'cooking.nytimes.com'
}
// Override schema for site-specific validation
protected getSchema() {
return RecipeObjectSchema.extend({
title: z.string()
.refine((v) => v.length >= 10, 'Titles must be descriptive')
})
}
}
```
### When to Create a Scraper
Create a site-specific scraper when:
- ✅ Generic plugins produce incorrect/incomplete data
- ✅ Site has unique HTML structure requiring custom parsing
- ✅ Data needs restructuring (e.g., ingredient grouping)
- ✅ Site lacks proper Schema.org markup
Don't create a scraper when:
- ❌ Generic plugins already extract everything correctly
- ❌ Only minor text cleanup needed (use post-processors instead)
## RecipeExtractor
Main entry point for recipe extraction:
```typescript
class RecipeExtractor {
constructor(
html: string,
url: string,
pluginManager?: PluginManager,
)
extract(): RecipeObject
}
```
### Extraction Process
1. **Parse HTML**: Load HTML with Cheerio
2. **Find scraper**: Match URL domain to registered scrapers
3. **Run extractors**: Execute plugins and scraper extractors
4. **Post-process**: Apply post-processor plugins
5. **Return result**: Final `RecipeObject`
### Usage
```typescript
import { RecipeExtractor } from 'recipe-scrapers-js'
const html = await fetch('https://cooking.nytimes.com/recipes/...')
const extractor = new RecipeExtractor(
await html.text(),
'https://cooking.nytimes.com/recipes/...'
)
const recipe = extractor.extract()
console.log(recipe.name)
console.log(recipe.ingredients)
```
## Validation System
The library uses [Zod](https://zod.dev/) for runtime validation.
### Schema Organization
```txt
src/schemas/
├── recipe.schema.ts # Main RecipeObject schema
└── common.schema.ts # Reusable helpers (zString, zHttpUrl, etc.)
```
### Validation Features
- **Auto-fixing**: Calculates missing totalTime from cook + prep times
- **Cross-field validation**: Ensures time consistency and rating relationships
- **Custom error messages**: Clear validation feedback
- **Transform pipeline**: Trims whitespace, normalizes data
- **Extensible**: Scrapers can override schemas for site-specific rules
### Validation Guarantees
Validated recipes (`RecipeObjectValidated`) are guaranteed to have:
- ✅ Valid URLs for host, canonicalUrl, image
- ✅ Non-empty title, yields, description, author
- ✅ At least one ingredient group with items
- ✅ At least one instruction group with steps
- ✅ Positive time values (when present)
- ✅ Valid ratings (0-5) and non-negative rating counts
- ✅ Consistent totalTime ≥ cookTime + prepTime
## Project Structure
```txt
recipe-scrapers-js/
├── src/
│ ├── index.ts # Public API exports
│ ├── recipe-extractor.ts # Main extraction orchestrator
│ ├── plugin-manager.ts # Plugin registration & execution
│ ├── abstract-scraper.ts # Base scraper class
│ ├── abstract-plugin.ts # Base plugin classes
│ │
│ ├── schemas/ # Zod validation schemas
│ │ └── recipe.schema.ts # RecipeObject schema
│ │
│ ├── plugins/ # Generic extraction plugins
│ │ ├── schema-org.extractor/
│ │ ├── opengraph.extractor.ts
│ │ └── html-stripper.processor.ts
│ │
│ ├── scrapers/ # Site-specific scrapers
│ │ ├── _index.ts # Scraper registry
│ │ ├── nytimes.ts
│ │ ├── allrecipes.ts
│ │ └── ...
│ │
│ ├── utils/ # Utility functions
│ │ ├── ingredients.ts # Ingredient utilities
│ │ ├── instructions.ts # Instruction utilities
│ │ ├── parsing.ts # Text parsing helpers
│ │ └── ...
│ │
│ ├── types/ # TypeScript types
│ │ ├── recipe.interface.ts # RecipeObject definition
│ │ └── scraper.interface.ts
│ │
│ └── exceptions/ # Custom exceptions
│
├── test-data/ # Test fixtures by domain
│ ├── allrecipes.com/
│ │ ├── allrecipes.testhtml # HTML fixture
│ │ └── allrecipes.json # Expected output
│ └── ...
│
└── docs/
├── architecture.md # This file
└── ingredients-architecture.md # Ingredients deep-dive
```
## Testing Strategy
### Test Structure
Each site has test fixtures in `test-data/[domain]/`:
- `*.testhtml` - Real HTML from the site (anonymized if needed)
- `*.json` - Expected `RecipeObject` output
### Test Types
#### 1. Unit Tests
Located in `__tests__/` directories alongside source files:
- Plugin tests: Verify extraction logic
- Utility tests: Test helper functions
- Type predicates: Validate type guards
```typescript
// Example: Plugin test
describe('SchemaOrgExtractorPlugin', () => {
it('should extract ingredients from recipeIngredient', () => {
const html = '<script type="application/ld+json">...</script>'
const $ = cheerio.load(html)
const plugin = new SchemaOrgExtractorPlugin()
const result = plugin.ingredients($)
expect(result).toEqual([...])
})
})
```
#### 2. Integration Tests
Tests scrapers with real HTML fixtures:
```typescript
describe('NYTimes scraper', () => {
it('should extract recipe from nytimes.testhtml', async () => {
const html = await Bun.file('test-data/cooking.nytimes.com/nytimes.testhtml').text()
const expected = await Bun.file('test-data/cooking.nytimes.com/nytimes.json').json()
const extractor = new RecipeExtractor(html, 'https://cooking.nytimes.com/...')
const result = extractor.extract()
expect(result).toEqual(expected)
})
})
```
### Writing Tests
When adding a new scraper:
1. **Fetch real HTML**: Get actual page HTML from the target site
2. **Create fixture**: Save as `test-data/[domain]/[testname].testhtml`
3. **Run extraction**: Use the library to extract data
4. **Verify manually**: Check that extraction is correct
5. **Save expected**: Store result as `[testname].json`
6. **Write test**: Create integration test comparing fixture to expected
### Test Best Practices
- ✅ Use real HTML from actual sites
- ✅ Test edge cases (missing fields, unusual structure)
- ✅ Use `toEqual` for full object comparison
- ✅ Keep tests focused and independent
- ❌ Don't test internal implementation details
- ❌ Don't use mock HTML that doesn't match real sites
### Running Tests
```bash
# Run all tests
bun test
# Run specific test file
bun test src/plugins/__tests__/schema-org.test.ts
# Run with coverage
bun test --coverage
# Run scrapers tests only
bun test src/scrapers/__tests__/
```
## Type System
### Strict Type Safety
The project enforces strict TypeScript rules:
```typescript
// ❌ NEVER use non-null assertions
const value = obj.field! // Don't do this
// ✅ Use optional chaining and nullish coalescing
const value = obj.field ?? 'default'
// ❌ NEVER use 'any'
function process(data: any) { ... } // Don't do this
// ✅ Use proper types or 'unknown'
function process(data: RecipeObject) { ... }
function process(data: unknown) {
if (isRecipeObject(data)) {
// Type guard narrows to RecipeObject
}
}
```
### Type Guards vs Zod Validation
**Type Guards** - For compile-time type narrowing:
```typescript
export function isIngredientItem(value: unknown): value is IngredientItem {
return isPlainObject(value) && 'value' in value && isString(value.value)
}
```
**Zod Schemas** - For runtime validation with error details:
```typescript
const result = RecipeObjectSchema.safeParse(data)
if (result.success) {
// data is validated RecipeObjectValidated
} else {
// result.error contains detailed validation errors
}
```
## Code Style
### Modern ECMAScript
- Use ESM (`import`/`export`) not CommonJS
- Use `const`/`let` instead of `var`
- Prefer template literals for strings
- Use destructuring for objects/arrays
- Use async/await for promises
### Bun-First
- Prefer Bun APIs over Node.js when available
- Use Bun's built-in test framework
- Leverage Bun's fast module resolution
### Documentation
- Add JSDoc comments for public APIs
- Include examples in documentation
- Document complex algorithms inline
- Keep comments up-to-date with code
## Adding New Features
### Adding a New Scraper
1. **Research**: Analyze target site's HTML structure
2. **Check JSON-LD**: Verify if Schema.org data exists and quality
3. **Create scraper**: Extend `AbstractScraper` in `src/scrapers/`
4. **Override extractors**: Add custom extraction methods
5. **Custom validation** (optional): Override `getSchema()` for site-specific rules
6. **Register**: Add to `src/scrapers/_index.ts`
7. **Add tests**: Create fixtures in `test-data/[domain]/`
8. **Test validation**: Ensure `parse()` succeeds on test data
9. **Verify**: Run tests and ensure extraction is accurate
### Adding a New Plugin
1. **Identify need**: What extraction method is missing?
2. **Choose type**: ExtractorPlugin or PostProcessorPlugin?
3. **Implement**: Extend appropriate abstract class
4. **Register**: Add to `PluginManager` initialization
5. **Test**: Add unit tests for plugin logic
6. **Document**: Update relevant architecture docs
### Adding Schema Validation Rules
1. **Identify need**: What validation is missing?
2. **Update schema**: Modify `src/schemas/recipe.schema.ts`
3. **Add refinements**: Use `.refine()` for cross-field validation
4. **Add transforms**: Use `.transform()` for auto-fixes
5. **Test**: Add unit tests for new validation rules
## Performance Considerations
### Cheerio Usage
- Cheerio is synchronous and fast
- Cache selectors when reusing: `const $heading = this.$('h1')`
- Use efficient selectors (IDs and classes over complex queries)
- Limit DOM traversal when possible
### Memory
- HTML fixtures can be large - stream if needed
- Don't store entire DOM in memory unnecessarily
- Clear references to Cheerio instances after extraction
### Optimization Opportunities
- **Selector optimization**: Profile slow selectors
- **Lazy loading**: Only load scrapers for matched domains
## Future Enhancements
### Planned Features
1. **More scrapers**: Expand site coverage
2. **Better fuzzy matching**: Improve ingredient text matching
3. **Ingredient parsing**: Break down quantity/unit/ingredient
4. **Video extraction**: Support recipe videos
5. **CLI tool**: Scaffold new scrapers
### Architecture Improvements
1. **Scraper generator**: CLI tool to scaffold new scrapers
2. **Schema versioning**: Track validation rule changes
3. **Streaming API**: Process large HTML documents efficiently
4. **Performance profiling**: Identify validation bottlenecks
## Contributing
See the main README.md for contribution guidelines. Key points:
- Follow TypeScript strict mode rules
- Add tests for all new features
- Update documentation
- Use existing patterns and conventions
- Run `bun test` before submitting PRs
## Related Documentation
- **[Ingredients Architecture](./ingredients-architecture.md)**: Deep dive into ingredient extraction system
- **API Reference**: (Future: Generated from TSDoc comments)
- **Recipe Schema**: (Future: Detailed RecipeObject field documentation)