UNPKG

recipe-scrapers-js

Version:
579 lines (434 loc) 17.4 kB
# 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)