Recipe Aggregator
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
Section titled “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
Section titled “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
Section titled “How It Works: The Recipe Import Pipeline”Enter Recipe URL → Extract Recipe Data → Save to Database → Search & View- Data Extraction: Use
/v1/extract/jsonto extract structured recipe data - Content Cleaning: Use
/v1/extract/markdownfor clean recipe text - Storage: Save to a local JSON database
- Search: Find recipes by name, ingredient, or cuisine
Project Setup
Section titled “Project Setup”Let’s build a simple but powerful recipe aggregator.
Prerequisites
Section titled “Prerequisites”- Node.js 18+ installed
- A Tabstack API key (get one here)
- Basic command-line knowledge
Step 1: Initialize Your Project
Section titled “Step 1: Initialize Your Project”# Create project directorymkdir recipe-aggregatorcd recipe-aggregator
# Initialize Node.js projectnpm init -y
# Install dependenciesnpm install node-fetch dotenvStep 2: Create Project Structure
Section titled “Step 2: Create Project Structure”mkdir -p src datatouch .env src/extractor.js src/database.js src/search.js src/cli.jsYour 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.jsonStep 3: Configure Environment
Section titled “Step 3: Configure Environment”Create your .env file:
TABSTACK_API_KEY=your_api_key_hereTABSTACK_API_URL=https://api.tabstack.aiUpdate 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
Section titled “Building Your Recipe Aggregator”Step 1: Recipe Extractor - The Recipe Parser
Section titled “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:
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
Section titled “Step 2: Database - Simple JSON Storage”This module handles storing and retrieving recipes from a JSON file.
Create 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
Section titled “Step 3: Search - Find Your Recipes”This module provides search and filtering functionality.
Create 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
Section titled “Step 4: CLI - Command-Line Interface”This is the main interface for interacting with your recipe aggregator.
Create 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 <url>"); 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 argumentsconst [, , command, ...args] = process.argv;
const cli = new RecipeCLI();
switch (command) { case "add": if (!args[0]) { console.error("Usage: npm run add <recipe-url>"); process.exit(1); } cli.addRecipe(args[0]); break;
case "list": cli.listRecipes(); break;
case "search": if (!args[0]) { console.error("Usage: npm run search <query> [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 <url> Add a recipe from a URL npm run list List all recipes npm run search <query> [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
Section titled “Running Your Recipe Aggregator”Adding Your First Recipe
Section titled “Adding Your First Recipe”Let’s add a recipe from any cooking website:
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, oras 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/2024Listing All Recipes
Section titled “Listing All Recipes”View your recipe collection:
npm run listExpected 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: 5Searching Recipes
Section titled “Searching Recipes”Search by title or description:
npm run search "tomato"Search by ingredient:
npm run search "chicken" ingredientFind quick recipes (under 30 min):
npm run search "" quickExample 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
Section titled “Understanding the Tabstack API Endpoints”This recipe aggregator uses two Tabstack endpoints:
/v1/extract/json Endpoint
Section titled “/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
Section titled “/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 clutterExtending Your Recipe Aggregator
Section titled “Extending Your Recipe Aggregator”Here are some ideas to make your aggregator even better:
1. Recipe Ratings
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “Troubleshooting””Schema discovery failed”
Section titled “”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”
Section titled “”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
/markdownendpoint to see the clean content
Incomplete recipe data
Section titled “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
Section titled “Next Steps”You now have a working recipe aggregator! Here’s what to explore next:
- Import your favorite recipes from different cooking sites
- Build a web interface with Express and React
- Add recipe photos by extracting images with
/json - Create collections (weeknight dinners, desserts, etc.)
- Share recipes with friends by exporting to markdown
For more examples, check out the Price Monitor tutorial.