recipe-manager/server.js

551 lines
20 KiB
JavaScript

const express = require('express');
const axios = require('axios');
const cheerio = require('cheerio');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
// Setup directories for persistence
const DATA_DIR = path.join(__dirname, 'data');
const DATA_FILE = path.join(DATA_DIR, 'recipes.json');
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
if (!fs.existsSync(DATA_FILE)) {
fs.writeFileSync(DATA_FILE, JSON.stringify([], null, 2));
}
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Helper: Parse ISO 8601 Durations (e.g. PT1H30M to "1 hr 30 mins")
function parseISODuration(duration) {
if (!duration || typeof duration !== 'string') return '';
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
if (!match) return duration;
const hours = match[1] ? parseInt(match[1], 10) : 0;
const minutes = match[2] ? parseInt(match[2], 10) : 0;
let result = '';
if (hours > 0) result += `${hours} hr${hours > 1 ? 's' : ''} `;
if (minutes > 0) result += `${minutes} min${minutes > 1 ? 's' : ''}`;
return result.trim() || duration;
}
// Helper: Extract recipe instructions from schema format
function extractInstructions(instructions) {
if (!instructions) return [];
if (typeof instructions === 'string') {
return instructions.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
}
if (Array.isArray(instructions)) {
const result = [];
for (const inst of instructions) {
if (typeof inst === 'string') {
result.push(inst.trim());
} else if (inst && typeof inst === 'object') {
if (inst['@type'] === 'HowToStep') {
result.push(inst.text || inst.name || '');
} else if (inst['@type'] === 'HowToSection' && Array.isArray(inst.itemListElement)) {
if (inst.name) {
result.push(`### ${inst.name}`); // Prefix section names to identify them in the frontend
}
for (const step of inst.itemListElement) {
if (typeof step === 'string') {
result.push(step.trim());
} else if (step && typeof step === 'object') {
result.push(step.text || step.name || '');
}
}
} else if (inst.text) {
result.push(inst.text);
} else if (inst.name) {
result.push(inst.name);
}
}
}
return result.filter(Boolean);
}
return [];
}
// Helper: Normalize image resources from JSON-LD schema
function normalizeImages(imageObj) {
if (!imageObj) return [];
if (typeof imageObj === 'string') return [imageObj];
if (Array.isArray(imageObj)) {
return imageObj.map(img => (typeof img === 'string' ? img : img.url)).filter(Boolean);
}
if (typeof imageObj === 'object' && imageObj.url) {
return [imageObj.url];
}
return [];
}
// Helper: Retrieve saved recipes
function readRecipes() {
try {
const data = fs.readFileSync(DATA_FILE, 'utf8');
return JSON.parse(data);
} catch (e) {
console.error('Error reading recipes database:', e);
return [];
}
}
// Helper: Save recipes to storage
function writeRecipes(recipes) {
try {
fs.writeFileSync(DATA_FILE, JSON.stringify(recipes, null, 2), 'utf8');
return true;
} catch (e) {
console.error('Error writing recipes database:', e);
return false;
}
}
// API: List saved recipes
app.get('/api/recipes', (req, res) => {
const recipes = readRecipes();
res.json(recipes);
});
// API: Save new recipe
app.post('/api/recipes', (req, res) => {
const recipes = readRecipes();
const newRecipe = {
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5),
title: req.body.title || 'Untitled Recipe',
description: req.body.description || '',
ingredients: Array.isArray(req.body.ingredients) ? req.body.ingredients : [],
instructions: Array.isArray(req.body.instructions) ? req.body.instructions : [],
servings: req.body.servings || '',
prepTime: req.body.prepTime || '',
cookTime: req.body.cookTime || '',
totalTime: req.body.totalTime || '',
image: req.body.image || '',
images: Array.isArray(req.body.images) ? req.body.images : [],
sourceUrl: req.body.sourceUrl || '',
createdAt: new Date().toISOString()
};
recipes.push(newRecipe);
writeRecipes(recipes);
res.status(201).json(newRecipe);
});
// API: Update recipe
app.put('/api/recipes/:id', (req, res) => {
const recipes = readRecipes();
const index = recipes.findIndex(r => r.id === req.params.id);
if (index === -1) {
return res.status(404).json({ error: 'Recipe not found' });
}
const updatedRecipe = {
...recipes[index],
title: req.body.title || recipes[index].title,
description: req.body.description !== undefined ? req.body.description : recipes[index].description,
ingredients: Array.isArray(req.body.ingredients) ? req.body.ingredients : recipes[index].ingredients,
instructions: Array.isArray(req.body.instructions) ? req.body.instructions : recipes[index].instructions,
servings: req.body.servings !== undefined ? req.body.servings : recipes[index].servings,
prepTime: req.body.prepTime !== undefined ? req.body.prepTime : recipes[index].prepTime,
cookTime: req.body.cookTime !== undefined ? req.body.cookTime : recipes[index].cookTime,
totalTime: req.body.totalTime !== undefined ? req.body.totalTime : recipes[index].totalTime,
image: req.body.image !== undefined ? req.body.image : recipes[index].image,
images: Array.isArray(req.body.images) ? req.body.images : recipes[index].images,
sourceUrl: req.body.sourceUrl !== undefined ? req.body.sourceUrl : recipes[index].sourceUrl,
updatedAt: new Date().toISOString()
};
recipes[index] = updatedRecipe;
writeRecipes(recipes);
res.json(updatedRecipe);
});
// API: Delete recipe
app.delete('/api/recipes/:id', (req, res) => {
let recipes = readRecipes();
const filtered = recipes.filter(r => r.id !== req.params.id);
if (recipes.length === filtered.length) {
return res.status(404).json({ error: 'Recipe not found' });
}
writeRecipes(filtered);
res.json({ success: true });
});
// API: Scrape Recipe from Link
app.post('/api/scrape', async (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ error: 'URL is required' });
}
try {
const response = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1'
},
timeout: 10000
});
const html = response.data;
const $ = cheerio.load(html);
// Initial search: Extract JSON-LD structures
let schemaRecipe = null;
$('script[type="application/ld+json"]').each((i, el) => {
try {
const text = $(el).html();
if (!text) return;
const data = JSON.parse(text);
const searchForRecipe = (obj) => {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
for (const item of obj) {
searchForRecipe(item);
if (schemaRecipe) return;
}
} else {
const type = obj['@type'];
if (type === 'Recipe' || (Array.isArray(type) && type.includes('Recipe'))) {
schemaRecipe = obj;
return;
}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
searchForRecipe(obj[key]);
if (schemaRecipe) return;
}
}
}
};
searchForRecipe(data);
} catch (e) {
// Safe to ignore malformed JSON script elements
}
});
// Populate structured data object
let recipeData = {
title: '',
description: '',
ingredients: [],
instructions: [],
servings: '',
prepTime: '',
cookTime: '',
totalTime: '',
images: [],
sourceUrl: url
};
// Scrape candidate image arrays from tags to enable user choice
const extractedImages = [];
$('img').each((i, el) => {
const src = $(el).attr('src') || $(el).attr('data-src') || $(el).attr('data-lazy-src');
if (src && src.startsWith('http')) {
// Skip tracking pixels, tiny graphics or profile avatars
const width = $(el).attr('width');
const height = $(el).attr('height');
const isSmall = (width && parseInt(width, 10) < 150) || (height && parseInt(height, 10) < 150);
const containsTrashKeywords = /avatar|logo|icon|spacer|ad-|banner|pixel|tracking/i.test(src);
if (!isSmall && !containsTrashKeywords && !extractedImages.includes(src)) {
extractedImages.push(src);
}
}
});
if (schemaRecipe) {
// Standard schema-based extraction
recipeData.title = schemaRecipe.name || $('title').text().trim() || '';
recipeData.description = schemaRecipe.description || '';
// Servings
if (schemaRecipe.recipeYield) {
recipeData.servings = Array.isArray(schemaRecipe.recipeYield)
? schemaRecipe.recipeYield[0]
: schemaRecipe.recipeYield.toString();
}
// Times
recipeData.prepTime = parseISODuration(schemaRecipe.prepTime || '');
recipeData.cookTime = parseISODuration(schemaRecipe.cookTime || '');
recipeData.totalTime = parseISODuration(schemaRecipe.totalTime || '');
// Ingredients (Support singular and plural, also support plain strings)
const rawIngredients = schemaRecipe.recipeIngredient || schemaRecipe.recipeIngredients || [];
if (typeof rawIngredients === 'string') {
recipeData.ingredients = rawIngredients.split(/\r?\n/).map(i => i.trim()).filter(Boolean);
} else if (Array.isArray(rawIngredients)) {
recipeData.ingredients = rawIngredients;
}
// Instructions (Support singular and plural)
recipeData.instructions = extractInstructions(schemaRecipe.recipeInstructions || schemaRecipe.recipeInstruction);
// Schema Image fallback
const schemaImages = normalizeImages(schemaRecipe.image);
schemaImages.forEach(img => {
if (!extractedImages.includes(img)) {
extractedImages.unshift(img); // Prioritize JSON-LD schema images at the start
}
});
} else {
// Fallback Scraping strategies if JSON-LD is missing
recipeData.title = $('meta[property="og:title"]').attr('content') || $('title').text().trim() || '';
recipeData.description = $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content') || '';
const ogImg = $('meta[property="og:image"]').attr('content');
if (ogImg && !extractedImages.includes(ogImg)) {
extractedImages.unshift(ogImg);
}
// Parse ingredients structurally
const ingredientSelectors = [
'.wprm-recipe-ingredient',
'.tasty-recipes-ingredients li',
'.tasty-recipe-ingredients li',
'.recipe-ingredients li',
'.ingredients-list li',
'.ingredients li',
'[class*="ingredients"] li',
'[class*="ingredients"] p',
'[class*="ingredient"] li',
'[class*="ingredient"] p',
'ul[class*="ingredient"] li',
'ol[class*="ingredient"] li',
'.cooked-ingredient',
'.recipe-ingredients-wrap li'
];
for (const selector of ingredientSelectors) {
const items = $(selector);
if (items.length > 1) {
items.each((i, el) => {
const txt = $(el).text().trim().replace(/\s+/g, ' ');
if (txt && !recipeData.ingredients.includes(txt)) {
recipeData.ingredients.push(txt);
}
});
if (recipeData.ingredients.length > 1) break;
}
}
// Parse instructions structurally
const instructionSelectors = [
'.wprm-recipe-instruction',
'.tasty-recipes-instructions li',
'.tasty-recipe-instructions li',
'.recipe-directions li',
'.recipe-instructions li',
'.instructions-list li',
'.instructions li',
'.recipe-steps li',
'[class*="instructions"] li',
'[class*="instructions"] p',
'[class*="directions"] li',
'[class*="directions"] p',
'[class*="step"] li',
'[class*="step"] p',
'.cooked-step',
'.recipe-method li',
'.recipe-method p',
'.recipe-preparation li'
];
for (const selector of instructionSelectors) {
const items = $(selector);
if (items.length > 1) {
items.each((i, el) => {
const txt = $(el).text().trim().replace(/\s+/g, ' ');
if (txt && !recipeData.instructions.includes(txt)) {
recipeData.instructions.push(txt);
}
});
if (recipeData.instructions.length > 1) break;
}
}
}
// Heuristics Fallback: Heading Sibling crawl if still empty
if (recipeData.ingredients.length === 0) {
$('h1, h2, h3, h4, h5').each((i, heading) => {
const text = $(heading).text().toLowerCase();
if (text.includes('ingredients') && !text.includes('instruction') && !text.includes('direction')) {
let next = $(heading).next();
if (next.length === 0) {
next = $(heading).parent().next();
}
let limit = 0;
while (next.length > 0 && limit < 15) {
if (/h[1-6]/i.test(next[0].tagName)) break;
const items = next.find('li, p');
if (items.length > 0) {
items.each((j, el) => {
const itemText = $(el).text().trim().replace(/\s+/g, ' ');
if (itemText && itemText.length > 2 && itemText.length < 200 && !recipeData.ingredients.includes(itemText)) {
recipeData.ingredients.push(itemText);
}
});
} else {
const itemText = next.text().trim().replace(/\s+/g, ' ');
if (itemText && itemText.length > 2 && itemText.length < 200) {
recipeData.ingredients.push(itemText);
}
}
next = next.next();
limit++;
}
if (recipeData.ingredients.length > 2) return false; // Break heading loop
}
});
}
if (recipeData.instructions.length === 0) {
$('h1, h2, h3, h4, h5').each((i, heading) => {
const text = $(heading).text().toLowerCase();
if (/instruction|direction|method|preparation|steps/i.test(text) && !text.includes('ingredient')) {
let next = $(heading).next();
if (next.length === 0) {
next = $(heading).parent().next();
}
let limit = 0;
while (next.length > 0 && limit < 15) {
if (/h[1-6]/i.test(next[0].tagName)) break;
const items = next.find('li, p');
if (items.length > 0) {
items.each((j, el) => {
const itemText = $(el).text().trim().replace(/\s+/g, ' ');
if (itemText && itemText.length > 5 && itemText.length < 1000 && !recipeData.instructions.includes(itemText)) {
recipeData.instructions.push(itemText);
}
});
} else {
const itemText = next.text().trim().replace(/\s+/g, ' ');
if (itemText && itemText.length > 5 && itemText.length < 1000) {
recipeData.instructions.push(itemText);
}
}
next = next.next();
limit++;
}
if (recipeData.instructions.length > 1) return false; // Break heading loop
}
});
}
// Heuristics Fallback: Blockquote / Multi-paragraph text classifier (for vintage/informal blogs)
if (recipeData.ingredients.length === 0 && recipeData.instructions.length === 0) {
const isStepText = (text) => {
if (/^(?:step\s*)?\d+[\.\)\-]\s/i.test(text)) return true;
if (/^\d+\s+(?:mix|stir|combine|sift|bake|heat|preheat|fry|drain|serve|boil|chop|pour|place|add|cook|whisk|beat|divide|mould|brush|roll|repeat|prepare|cut)\b/i.test(text)) return true;
if (/^(?:mix|stir|combine|sift|bake|heat|preheat|fry|drain|serve|boil|chop|pour|place|add|cook|whisk|beat|divide|mould|brush|roll|repeat|prepare|cut)\b/i.test(text) && text.length > 25) return true;
return false;
};
const isTitleOrHeader = (text) => {
const titleClean = recipeData.title.toLowerCase();
const textClean = text.toLowerCase();
if (titleClean.includes(textClean) || textClean.includes(titleClean)) return true;
if (/^(ingredients|instructions|directions|method|results|notes|conclusion)\b/i.test(textClean)) return true;
return false;
};
// Prioritize containers: blockquote first (specific card), then entry-content, then article
let selectedContainer = null;
if ($('blockquote').length > 0) {
selectedContainer = $('blockquote');
} else if ($('.entry-content').length > 0) {
selectedContainer = $('.entry-content');
} else if ($('article').length > 0) {
selectedContainer = $('article');
}
if (selectedContainer) {
selectedContainer.each((i, container) => {
const paragraphs = $(container).find('p');
if (paragraphs.length > 2) {
paragraphs.each((j, el) => {
const html = $(el).html() || '';
if (html.includes('<br>')) {
// Split by BR tags if it's a single block
const lines = html.split(/<br\s*\/?>/i)
.map(l => cheerio.load(l).text().trim().replace(/\s+/g, ' '))
.filter(Boolean);
for (const line of lines) {
if (isTitleOrHeader(line) || line.length < 3) continue;
if (isStepText(line)) {
recipeData.instructions.push(line);
} else {
recipeData.ingredients.push(line);
}
}
} else {
const txt = $(el).text().trim().replace(/\s+/g, ' ');
if (!txt || isTitleOrHeader(txt) || txt.length < 3) return;
if (isStepText(txt)) {
recipeData.instructions.push(txt);
} else {
recipeData.ingredients.push(txt);
}
}
});
if (recipeData.ingredients.length > 1) return false; // Break container loop
}
});
}
}
// Clean up lists
recipeData.ingredients = recipeData.ingredients.map(i => i.trim()).filter(Boolean);
recipeData.instructions = recipeData.instructions.map(i => i.trim()).filter(Boolean);
recipeData.images = extractedImages.slice(0, 15); // Send top 15 images to UI for selection
res.json(recipeData);
} catch (error) {
console.error('Error occurred while scraping URL:', error.message);
const status = error.response ? error.response.status : 500;
const message = error.response && error.response.status === 403
? 'This website is protected by Cloudflare bot protection and blocks automated access.'
: error.message;
res.status(status).json({
error: message,
isBlocked: status === 403 || status === 503
});
}
});
// Launch server
app.listen(PORT, () => {
console.log(`Recipe Manager server is active at http://localhost:${PORT}`);
});