--- title: Recipe Aggregator | Tabstack description: Build your personal recipe collection by importing recipes from any cooking website. This example shows you how to extract, store, and search recipes from across the web in one centralized location. --- **Complexity**: Beginner **Estimated time**: 30-45 minutes **Key Tabstack APIs**: `/v1/extract/json`, `/v1/extract/markdown` ## The Story Behind This Project You’ve got recipes bookmarked across dozens of websites—some on food blogs, some on NYT Cooking, some on AllRecipes. Each site has different formatting, ads, and paywalls. You want all your favorite recipes in one place, in a clean format, searchable and organized. This recipe aggregator does exactly that. ## What This Aggregator Does Unlike browser bookmarks or Pinterest boards, our recipe aggregator will: - **Extract recipes from any cooking website** using AI - Convert messy recipe pages into clean, structured data - Store recipes in a searchable local database - Support filtering by ingredients, cuisine, cooking time - Export recipes to clean markdown format ## How It Works: The Recipe Import Pipeline ``` Enter Recipe URL → Extract Recipe Data → Save to Database → Search & View ``` 1. **Data Extraction**: Use `/v1/extract/json` to extract structured recipe data 2. **Content Cleaning**: Use `/v1/extract/markdown` for clean recipe text 3. **Storage**: Save to a local JSON database 4. **Search**: Find recipes by name, ingredient, or cuisine ## Project Setup Let’s build a simple but powerful recipe aggregator. ### Prerequisites - Node.js 18+ installed - A Tabstack API key ([get one here](https://console.tabstack.ai/)) - Basic command-line knowledge ### Step 1: Initialize Your Project Terminal window ``` # Create project directory mkdir recipe-aggregator cd recipe-aggregator # Initialize Node.js project npm init -y # Install dependencies npm install node-fetch dotenv ``` ### Step 2: Create Project Structure Terminal window ``` mkdir -p src data touch .env src/extractor.js src/database.js src/search.js src/cli.js ``` Your project structure: ``` recipe-aggregator/ ├── src/ │ ├── extractor.js # Extract recipes from URLs │ ├── database.js # Simple JSON storage │ ├── search.js # Search and filter recipes │ └── cli.js # Command-line interface ├── data/ │ └── recipes.json # Recipe database ├── .env # API key └── package.json ``` ### Step 3: Configure Environment Create your `.env` file: Terminal window ``` TABSTACK_API_KEY=your_api_key_here TABSTACK_API_URL=https://api.tabstack.ai ``` **Update your `package.json`**: ``` { ... "type": "module", "scripts": { "add": "node src/cli.js add", "list": "node src/cli.js list", "search": "node src/cli.js search" }, ... } ``` **Important**: The `"type": "module"` line is required to use ES6 imports in Node.js. ## Building Your Recipe Aggregator ### Step 1: Recipe Extractor - The Recipe Parser This module extracts structured recipe data from any cooking website using the Tabstack API. Create `src/extractor.js`: src/extractor.js ``` import fetch from "node-fetch"; import dotenv from "dotenv"; dotenv.config(); const API_KEY = process.env.TABSTACK_API_KEY; const BASE_URL = process.env.TABSTACK_API_URL || "https://api.tabstack.ai"; export class RecipeExtractor { constructor() { this.headers = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }; } /** * Extract recipe data using a schema */ async extractRecipe(url, schema = null) { console.log("📖 Extracting recipe data..."); // Use provided schema or the default recipe schema const recipeSchema = schema || this.getDefaultRecipeSchema(); const response = await fetch(`${BASE_URL}/v1/extract/json`, { method: "POST", headers: this.headers, body: JSON.stringify({ url: url, json_schema: recipeSchema, }), }); if (!response.ok) { throw new Error(`Recipe extraction failed: ${response.statusText}`); } const recipeData = await response.json(); console.log("✅ Recipe extracted successfully"); // Parse time and servings to numbers if possible const parseTime = (timeStr) => { if (!timeStr) return null; const match = timeStr.match(/(\d+)/); return match ? parseInt(match[1]) : null; }; const parseServings = (servingsStr) => { if (!servingsStr) return null; const match = servingsStr.match(/(\d+)/); return match ? parseInt(match[1]) : null; }; // Add metadata and parse fields return { ...recipeData, total_time: parseTime(recipeData.total_time), servings: parseServings(recipeData.servings), source_url: url, imported_at: new Date().toISOString(), id: this.generateRecipeId(recipeData.title || "Untitled Recipe"), }; } /** * Get clean markdown version of the recipe page */ async getRecipeMarkdown(url) { console.log("📄 Getting clean recipe text..."); const response = await fetch(`${BASE_URL}/v1/extract/markdown`, { method: "POST", headers: this.headers, body: JSON.stringify({ url: url, metadata: true, }), }); if (!response.ok) { throw new Error(`Markdown conversion failed: ${response.statusText}`); } const data = await response.json(); console.log("✅ Markdown generated"); return data.content; } /** * Default recipe schema for common recipe sites */ getDefaultRecipeSchema() { return { type: "object", properties: { title: { type: "string", description: "Recipe title or name", }, description: { type: "string", description: "Recipe description or summary", }, total_time: { type: "string", description: "Total time to make the recipe", }, servings: { type: "string", description: "Number of servings or yield", }, ingredients: { type: "array", items: { type: "string" }, maxItems: 50, description: "List of ingredients with quantities", }, instructions: { type: "array", items: { type: "string" }, maxItems: 30, description: "Step-by-step cooking instructions", }, }, required: [ "title", "description", "total_time", "servings", "ingredients", "instructions", ], additionalProperties: false, }; } /** * Generate a unique ID for a recipe */ generateRecipeId(title) { const slug = title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); return `${slug}-${Date.now()}`; } } ``` ### Step 2: Database - Simple JSON Storage This module handles storing and retrieving recipes from a JSON file. Create `src/database.js`: src/database.js ``` import { promises as fs } from "fs"; import path from "path"; export class RecipeDatabase { constructor(dbPath = "./data/recipes.json") { this.dbPath = dbPath; } /** * Load all recipes from the database */ async loadRecipes() { try { const data = await fs.readFile(this.dbPath, "utf8"); return JSON.parse(data); } catch (error) { // Return empty database if file doesn't exist return { recipes: [] }; } } /** * Save recipes to the database */ async saveRecipes(data) { await fs.mkdir(path.dirname(this.dbPath), { recursive: true }); await fs.writeFile(this.dbPath, JSON.stringify(data, null, 2), "utf8"); } /** * Add a new recipe to the database */ async addRecipe(recipe) { const db = await this.loadRecipes(); // Check for duplicates based on URL const exists = db.recipes.some((r) => r.source_url === recipe.source_url); if (exists) { console.log("⚠️ Recipe from this URL already exists in database"); return false; } db.recipes.push(recipe); await this.saveRecipes(db); console.log(`✅ Recipe "${recipe.title}" added to database`); return true; } /** * Get a recipe by ID */ async getRecipe(id) { const db = await this.loadRecipes(); return db.recipes.find((r) => r.id === id); } /** * Get all recipes */ async getAllRecipes() { const db = await this.loadRecipes(); return db.recipes; } /** * Delete a recipe by ID */ async deleteRecipe(id) { const db = await this.loadRecipes(); const initialLength = db.recipes.length; db.recipes = db.recipes.filter((r) => r.id !== id); if (db.recipes.length < initialLength) { await this.saveRecipes(db); console.log("✅ Recipe deleted"); return true; } console.log("⚠️ Recipe not found"); return false; } /** * Get database statistics */ async getStats() { const db = await this.loadRecipes(); const recipes = db.recipes; const totalWithTime = recipes.filter((r) => r.total_time).length; const avgTime = totalWithTime > 0 ? recipes.reduce((sum, r) => sum + (r.total_time || 0), 0) / totalWithTime : 0; return { total: recipes.length, avgTotalTime: Math.round(avgTime), withTimeInfo: totalWithTime, }; } } ``` ### Step 3: Search - Find Your Recipes This module provides search and filtering functionality. Create `src/search.js`: src/search.js ``` import { RecipeDatabase } from "./database.js"; export class RecipeSearch { constructor() { this.db = new RecipeDatabase(); } /** * Search recipes by title or description */ async searchByText(query) { const recipes = await this.db.getAllRecipes(); const lowerQuery = query.toLowerCase(); return recipes.filter((recipe) => { const title = (recipe.title || "").toLowerCase(); const description = (recipe.description || "").toLowerCase(); return title.includes(lowerQuery) || description.includes(lowerQuery); }); } /** * Search recipes by ingredient */ async searchByIngredient(ingredient) { const recipes = await this.db.getAllRecipes(); const lowerIngredient = ingredient.toLowerCase(); return recipes.filter((recipe) => { if (!recipe.ingredients) return false; return recipe.ingredients.some((ing) => ing.toLowerCase().includes(lowerIngredient) ); }); } /** * Filter recipes by cooking time */ async filterByTime(maxMinutes) { const recipes = await this.db.getAllRecipes(); return recipes.filter((recipe) => { return recipe.total_time && recipe.total_time <= maxMinutes; }); } /** * Get quick recipes (under 30 min) */ async getQuickRecipes() { const recipes = await this.db.getAllRecipes(); return recipes.filter((recipe) => { return recipe.total_time && recipe.total_time <= 30; }); } /** * Format recipe for display */ formatRecipe(recipe, detailed = false) { let output = `\n${"=".repeat(60)}\n`; output += `📖 ${recipe.title}\n`; output += `${"=".repeat(60)}\n\n`; if (recipe.description) { output += `${recipe.description}\n\n`; } // Metadata const metadata = []; if (recipe.servings) metadata.push(`👥 ${recipe.servings} servings`); if (recipe.total_time) metadata.push(`⏱️ ${recipe.total_time} min`); if (metadata.length > 0) { output += metadata.join(" | ") + "\n\n"; } if (detailed) { // Ingredients if (recipe.ingredients && recipe.ingredients.length > 0) { output += `📝 INGREDIENTS:\n`; recipe.ingredients.forEach((ing, i) => { output += ` ${i + 1}. ${ing}\n`; }); output += `\n`; } // Instructions if (recipe.instructions && recipe.instructions.length > 0) { output += `👨‍🍳 INSTRUCTIONS:\n`; recipe.instructions.forEach((step, i) => { output += ` ${i + 1}. ${step}\n`; }); output += `\n`; } } // Source output += `🔗 Source: ${recipe.source_url}\n`; output += `📅 Added: ${new Date(recipe.imported_at).toLocaleDateString()}\n`; return output; } } ``` ### Step 4: CLI - Command-Line Interface This is the main interface for interacting with your recipe aggregator. Create `src/cli.js`: src/cli.js ``` import { RecipeExtractor } from "./extractor.js"; import { RecipeDatabase } from "./database.js"; import { RecipeSearch } from "./search.js"; class RecipeCLI { constructor() { this.extractor = new RecipeExtractor(); this.db = new RecipeDatabase(); this.search = new RecipeSearch(); } /** * Add a recipe from a URL */ async addRecipe(url) { console.log(`\n🍳 Recipe Aggregator - Add Recipe\n`); console.log(`URL: ${url}\n`); try { // Extract recipe data const recipe = await this.extractor.extractRecipe(url); // Save to database const added = await this.db.addRecipe(recipe); if (added) { console.log(`\n${this.search.formatRecipe(recipe, true)}`); } } catch (error) { console.error(`\n❌ Error: ${error.message}`); process.exit(1); } } /** * List all recipes */ async listRecipes() { console.log(`\n📚 Recipe Collection\n`); try { const recipes = await this.db.getAllRecipes(); if (recipes.length === 0) { console.log("No recipes yet. Add one with: npm run add "); return; } console.log(`Total recipes: ${recipes.length}\n`); console.log(`${"=".repeat(80)}\n`); recipes.forEach((recipe, i) => { const servings = recipe.servings ? ` | ${recipe.servings} servings` : ""; console.log(`${i + 1}. ${recipe.title}`); console.log(` ⏱️ ${recipe.total_time || "?"} min${servings}`); console.log(` 🔗 ${recipe.source_url}`); console.log(""); }); // Show stats const stats = await this.db.getStats(); console.log(`${"=".repeat(80)}\n`); console.log(`📊 Statistics:`); console.log(` Total recipes: ${stats.total}`); console.log(` Average time: ${stats.avgTotalTime} min`); console.log(` Recipes with time info: ${stats.withTimeInfo}`); } catch (error) { console.error(`\n❌ Error: ${error.message}`); process.exit(1); } } /** * Search recipes */ async searchRecipes(query, type = "text") { console.log(`\n🔍 Searching recipes...\n`); try { let results = []; switch (type) { case "ingredient": results = await this.search.searchByIngredient(query); break; case "quick": results = await this.search.getQuickRecipes(); break; default: results = await this.search.searchByText(query); } if (results.length === 0) { console.log("No recipes found matching your criteria."); return; } console.log(`Found ${results.length} recipe(s):\n`); results.forEach((recipe, i) => { console.log(`${i + 1}. ${recipe.title}`); const servings = recipe.servings ? ` | ${recipe.servings} servings` : ""; console.log(` ⏱️ ${recipe.total_time || "?"} min${servings}`); console.log(` 🔗 ${recipe.source_url}\n`); }); } catch (error) { console.error(`\n❌ Error: ${error.message}`); process.exit(1); } } } // Parse command-line arguments const [, , command, ...args] = process.argv; const cli = new RecipeCLI(); switch (command) { case "add": if (!args[0]) { console.error("Usage: npm run add "); process.exit(1); } cli.addRecipe(args[0]); break; case "list": cli.listRecipes(); break; case "search": if (!args[0]) { console.error("Usage: npm run search [type]"); console.error("Types: text (default), ingredient, quick"); process.exit(1); } cli.searchRecipes(args[0], args[1]); break; default: console.log(` Recipe Aggregator CLI Commands: npm run add Add a recipe from a URL npm run list List all recipes npm run search [type] Search recipes Search types: text Search by title/description (default) ingredient Search by ingredient quick Show quick recipes (under 30 min) Examples: npm run add https://www.allrecipes.com/recipe/12345/ npm run search "pasta" npm run search "chicken" ingredient npm run search "" quick `); } ``` ## Running Your Recipe Aggregator ### Adding Your First Recipe Let’s add a recipe from any cooking website: Terminal window ``` npm run add "https://www.seriouseats.com/basic-tomato-sauce-recipe" ``` **Expected Output:** ``` 🍳 Recipe Aggregator - Add Recipe URL: https://www.seriouseats.com/basic-tomato-sauce-recipe 📖 Extracting recipe data... ✅ Recipe extracted successfully ✅ Recipe "Basic Tomato Sauce" added to database ============================================================ 📖 Basic Tomato Sauce ============================================================ A simple, classic tomato sauce that's perfect for pasta, pizza, or as a base for other Italian dishes. 👥 4 servings | ⏱️ 45 min 📝 INGREDIENTS: 1. 2 tablespoons extra-virgin olive oil 2. 4 cloves garlic, minced 3. 1 (28-ounce) can whole peeled tomatoes 4. 1 teaspoon dried oregano 5. Kosher salt and black pepper 6. 2 tablespoons fresh basil, torn 👨‍🍳 INSTRUCTIONS: 1. Heat oil in a large saucepan over medium heat 2. Add garlic and cook until fragrant, about 1 minute 3. Add tomatoes, crushing them by hand as you add them 4. Add oregano and season with salt and pepper 5. Simmer for 30 minutes, stirring occasionally 6. Stir in fresh basil before serving 🔗 Source: https://www.seriouseats.com/basic-tomato-sauce-recipe 📅 Added: 1/15/2024 ``` ### Listing All Recipes View your recipe collection: Terminal window ``` npm run list ``` **Expected Output:** ``` 📚 Recipe Collection Total recipes: 5 ================================================================================ 1. Basic Tomato Sauce ⏱️ 45 min | 4 servings 🔗 https://www.seriouseats.com/basic-tomato-sauce-recipe 2. Thai Green Curry ⏱️ 35 min | 4 servings 🔗 https://www.bonappetit.com/recipe/thai-green-curry 3. Chocolate Chip Cookies ⏱️ 25 min | 24 servings 🔗 https://www.allrecipes.com/recipe/10813/best-chocolate-chip-cookies/ 4. Greek Salad ⏱️ 15 min | 6 servings 🔗 https://www.foodnetwork.com/recipes/greek-salad 5. Beef Tacos ⏱️ 30 min | 4 servings 🔗 https://www.mexicanplease.com/beef-tacos/ ================================================================================ 📊 Statistics: Total recipes: 5 Average time: 30 min Recipes with time info: 5 ``` ### Searching Recipes Search by title or description: Terminal window ``` npm run search "tomato" ``` Search by ingredient: Terminal window ``` npm run search "chicken" ingredient ``` Find quick recipes (under 30 min): Terminal window ``` npm run search "" quick ``` **Example Search Output:** ``` 🔍 Searching recipes... Found 2 recipe(s): 1. Basic Tomato Sauce ⏱️ 45 min | 4 servings 🔗 https://www.seriouseats.com/basic-tomato-sauce-recipe 2. Tomato Basil Soup ⏱️ 30 min | 6 servings 🔗 https://www.allrecipes.com/recipe/39544/garden-fresh-tomato-soup/ ``` ## Understanding the Tabstack API Endpoints This recipe aggregator uses two Tabstack endpoints: ### `/v1/extract/json` Endpoint Extracts structured recipe data: ``` POST https://api.tabstack.ai/v1/extract/json { "url": "https://example.com/recipe", "json_schema": { ... } } // Returns: { "title": "Chocolate Chip Cookies", "ingredients": ["2 cups flour", "1 cup butter", ...], "instructions": ["Preheat oven to 350°F", ...] } ``` ### `/v1/extract/markdown` Endpoint Gets clean recipe text: ``` POST https://api.tabstack.ai/v1/extract/markdown { "url": "https://example.com/recipe" } // Returns clean markdown without ads or clutter ``` ## Extending Your Recipe Aggregator Here are some ideas to make your aggregator even better: ### 1. Recipe Ratings Add a rating system: ``` async rateRecipe(id, rating) { const recipe = await this.db.getRecipe(id); if (!recipe) return false; recipe.rating = rating; recipe.rated_at = new Date().toISOString(); await this.db.saveRecipes(await this.db.loadRecipes()); return true; } ``` ### 2. Shopping List Generator Generate a shopping list from selected recipes: ``` async generateShoppingList(recipeIds) { const recipes = await Promise.all( recipeIds.map(id => this.db.getRecipe(id)) ); const ingredients = new Set(); recipes.forEach(recipe => { recipe.ingredients?.forEach(ing => ingredients.add(ing)); }); return Array.from(ingredients).sort(); } ``` ### 3. Meal Planning Plan your week: ``` class MealPlanner { async createWeekPlan(recipeIds) { const days = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ]; const plan = {}; days.forEach((day, i) => { plan[day] = recipeIds[i] || null; }); return plan; } } ``` ### 4. Recipe Scaling Adjust serving sizes: ``` function scaleRecipe(recipe, newServings) { const scale = newServings / recipe.servings; return { ...recipe, servings: newServings, ingredients: recipe.ingredients.map((ing) => { // Parse and scale ingredient quantities return scaleIngredient(ing, scale); }), }; } ``` ### 5. Export Recipes Export to different formats: ``` async exportRecipe(id, format = 'markdown') { const recipe = await this.db.getRecipe(id); switch (format) { case 'markdown': return this.toMarkdown(recipe); case 'json': return JSON.stringify(recipe, null, 2); case 'html': return this.toHTML(recipe); } } ``` ### 6. Recipe Tags Add custom tags: ``` async addTag(recipeId, tag) { const recipe = await this.db.getRecipe(recipeId); if (!recipe.custom_tags) { recipe.custom_tags = []; } if (!recipe.custom_tags.includes(tag)) { recipe.custom_tags.push(tag); await this.db.saveRecipes(await this.db.loadRecipes()); } } ``` ### 7. Duplicate Detection Prevent adding the same recipe twice: ``` async findSimilarRecipes(title) { const recipes = await this.db.getAllRecipes(); return recipes.filter(recipe => { const similarity = this.calculateSimilarity( title.toLowerCase(), recipe.title.toLowerCase() ); return similarity > 0.8; }); } ``` ## Troubleshooting ### ”Schema discovery failed” Some recipe sites may be harder to analyze: - Try using the default schema instead - Simplify the extraction instructions - Check if the site requires login ### ”No ingredients extracted” The page structure may be unusual: - Check the source URL in a browser - Look for recipe structured data (JSON-LD) - Try the `/markdown` endpoint to see the clean content ### Incomplete recipe data Not all sites include all fields: - The schema will extract what’s available - Missing fields will be undefined/null - Consider adding manual fields after import ## Next Steps You now have a working recipe aggregator! Here’s what to explore next: 1. **Import your favorite recipes** from different cooking sites 2. **Build a web interface** with Express and React 3. **Add recipe photos** by extracting images with `/json` 4. **Create collections** (weeknight dinners, desserts, etc.) 5. **Share recipes** with friends by exporting to markdown For more examples, check out the [Price Monitor](/examples/price-monitor/index.md) tutorial.