Initial commit: GourmetVault recipe manager

This commit is contained in:
Chris 2026-05-26 19:57:35 -04:00
commit 97733b8570
9 changed files with 4544 additions and 0 deletions

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Dependency directories
node_modules/
jspm_packages/
web_modules/
# Local database and volume directories
data/
*.db
*.sqlite
# Environment secrets
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor and OS files
.vscode/
.idea/
.DS_Store
Thumbs.db
# Scratch and test scripts
test_scraper.js

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# Base image: Minimal LTS Node Alpine
FROM node:20-alpine
# Setup workspace environment
WORKDIR /usr/src/app
# Copy dependency manifests
COPY package*.json ./
# Install clean production dependencies
RUN npm ci --only=production
# Copy application source files
COPY . .
# Setup persistent volume mount point
RUN mkdir -p /usr/src/app/data
# Default port exposure
EXPOSE 3000
# Start command
CMD [ "npm", "start" ]

14
docker-compose.yml Normal file
View file

@ -0,0 +1,14 @@
version: '3.8'
services:
recipe-manager:
build: .
container_name: recipe-manager-app
ports:
- "3000:3000"
volumes:
- ./data:/usr/src/app/data
restart: unless-stopped
environment:
- PORT=3000
- NODE_ENV=production

1344
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "recipe-manager",
"version": "1.0.0",
"description": "Dockerized Recipe Management and Web Scraping Application",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
},
"dependencies": {
"axios": "^1.7.2",
"cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5",
"express": "^4.19.2"
},
"private": true
}

813
public/app.js Normal file
View file

@ -0,0 +1,813 @@
// GourmetVault client application state
let allRecipes = [];
let activeRecipe = null;
let currentMultiplier = 1;
let selectedCoverImage = '';
let stopwatchInterval = null;
let stopwatchSeconds = 0;
let cookingActiveStep = 0;
// Page elements caching
const elements = {
themeToggle: document.getElementById('theme-toggle'),
urlScrapeForm: document.getElementById('url-scrape-form'),
scrapeUrlInput: document.getElementById('scrape-url-input'),
btnScrapeSubmit: document.getElementById('btn-scrape-submit'),
btnManualAdd: document.getElementById('btn-manual-add'),
recipesGrid: document.getElementById('recipes-grid'),
emptyState: document.getElementById('empty-state'),
librarySearch: document.getElementById('library-search'),
// Scrape / Edit Modal
saveModal: document.getElementById('save-modal'),
recipeEditForm: document.getElementById('recipe-edit-form'),
editRecipeId: document.getElementById('edit-recipe-id'),
editTitle: document.getElementById('edit-title'),
editSource: document.getElementById('edit-source'),
editDescription: document.getElementById('edit-description'),
editServings: document.getElementById('edit-servings'),
editPrepTime: document.getElementById('edit-prep-time'),
editCookTime: document.getElementById('edit-cook-time'),
imageSelectionGrid: document.getElementById('image-selection-grid'),
editImageUrl: document.getElementById('edit-image-url'),
editIngredients: document.getElementById('edit-ingredients'),
editInstructions: document.getElementById('edit-instructions'),
modalActionTitle: document.getElementById('modal-action-title'),
btnSaveConfirm: document.getElementById('btn-save-confirm'),
// Detail Modal
detailModal: document.getElementById('detail-modal'),
recipeDetailBanner: document.getElementById('recipe-detail-banner'),
btnDetailEdit: document.getElementById('btn-detail-edit'),
btnDetailDelete: document.getElementById('btn-detail-delete'),
recipeDetailTitle: document.getElementById('recipe-detail-title'),
tagServings: document.getElementById('tag-servings'),
tagPrep: document.getElementById('tag-prep'),
tagCook: document.getElementById('tag-cook'),
recipeDetailDescription: document.getElementById('recipe-detail-description'),
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
recipeDetailInstructions: document.getElementById('recipe-detail-instructions'),
scalerMinus: document.getElementById('scaler-minus'),
scalerPlus: document.getElementById('scaler-plus'),
currentServingsVal: document.getElementById('current-servings-val'),
btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'),
// Cooking Mode Modal
cookingModeModal: document.getElementById('cooking-mode-modal'),
cookRecipeTitle: document.getElementById('cook-recipe-title'),
cookTimerVal: document.getElementById('cook-timer-val'),
cookTimerPlay: document.getElementById('cook-timer-play'),
cookTimerReset: document.getElementById('cook-timer-reset'),
cookProgressBar: document.getElementById('cook-progress-bar'),
cookStepIndex: document.getElementById('cook-step-index'),
cookStepInstruction: document.getElementById('cook-step-instruction'),
cookBtnPrev: document.getElementById('cook-btn-prev'),
cookBtnNext: document.getElementById('cook-btn-next'),
cookStepDots: document.getElementById('cook-step-dots'),
// Loader
loadingOverlay: document.getElementById('loading-overlay'),
loaderStepFetch: document.getElementById('loader-step-fetch'),
loaderStepParse: document.getElementById('loader-step-parse'),
loaderStepImages: document.getElementById('loader-step-images')
};
// -------------------------------------------------------------
// LIFE CYCLE AND REGISTRATION
// -------------------------------------------------------------
window.addEventListener('DOMContentLoaded', () => {
initTheme();
fetchRecipes();
registerEvents();
});
function registerEvents() {
// Theme Toggle
elements.themeToggle.addEventListener('click', toggleTheme);
// URL Form Submission
elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit);
// Manual Add Form Opening
elements.btnManualAdd.addEventListener('click', openManualAdd);
// Library Search
elements.librarySearch.addEventListener('input', filterRecipes);
// Recipe Form Save
elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
// Action Buttons Detail Modal
elements.btnDetailEdit.addEventListener('click', editActiveRecipe);
elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe);
// Servings Scaler Buttons
elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5));
elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5));
// Cooking Mode Trigger
elements.btnTriggerCookmode.addEventListener('click', enterCookingMode);
// Cooking Controls
elements.cookBtnPrev.addEventListener('click', prevCookingStep);
elements.cookBtnNext.addEventListener('click', nextCookingStep);
elements.cookTimerPlay.addEventListener('click', toggleTimer);
elements.cookTimerReset.addEventListener('click', resetTimer);
// Global Modals click to dismiss on overlay
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay && !overlay.classList.contains('loading-overlay')) {
closeModal(overlay.id);
}
});
});
// Global key bindings for cooking mode
document.addEventListener('keydown', (e) => {
if (elements.cookingModeModal.classList.contains('active')) {
if (e.key === 'ArrowRight' || e.key === ' ') {
e.preventDefault();
nextCookingStep();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
prevCookingStep();
} else if (e.key === 'Escape') {
exitCookingMode();
}
}
});
}
// -------------------------------------------------------------
// UTILITIES: SYSTEM THEMES
// -------------------------------------------------------------
function initTheme() {
const savedTheme = localStorage.getItem('gourmet-theme') || 'dark';
if (savedTheme === 'dark') {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
}
function toggleTheme() {
document.body.classList.toggle('dark-theme');
const isDark = document.body.classList.contains('dark-theme');
localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light');
}
// -------------------------------------------------------------
// LOADING STATUS MACHINE
// -------------------------------------------------------------
function setLoaderStepIcon(stepElement, iconName, spin = false) {
const wrapper = stepElement.querySelector('.step-icon-wrapper');
if (wrapper) {
wrapper.innerHTML = `<i data-lucide="${iconName}" class="${spin ? 'spinning-icon' : ''}"></i>`;
}
}
function showScrapeLoading() {
elements.loadingOverlay.classList.add('active');
// Reset Steps to default
elements.loaderStepFetch.className = 'loader-step active';
elements.loaderStepParse.className = 'loader-step';
elements.loaderStepImages.className = 'loader-step';
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
lucide.createIcons();
// Simulate process transitions
setTimeout(() => {
if (!elements.loadingOverlay.classList.contains('active')) return;
elements.loaderStepFetch.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
elements.loaderStepParse.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
lucide.createIcons();
}, 1800);
setTimeout(() => {
if (!elements.loadingOverlay.classList.contains('active')) return;
elements.loaderStepParse.className = 'loader-step completed';
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
elements.loaderStepImages.className = 'loader-step active';
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
lucide.createIcons();
}, 3500);
}
function hideScrapeLoading() {
elements.loadingOverlay.classList.remove('active');
}
// -------------------------------------------------------------
// CORE MODAL ROUTER
// -------------------------------------------------------------
function openModal(id) {
document.getElementById(id).classList.add('active');
}
function closeModal(id) {
document.getElementById(id).classList.remove('active');
if (id === 'cooking-mode-modal') {
exitCookingMode();
}
}
// -------------------------------------------------------------
// CRUD & API HANDLERS
// -------------------------------------------------------------
async function fetchRecipes() {
try {
const response = await fetch('/api/recipes');
allRecipes = await response.json();
renderLibrary();
} catch (error) {
console.error('Error fetching recipes library:', error);
}
}
// Scrape link form submission
async function handleScrapeSubmit(e) {
e.preventDefault();
const url = elements.scrapeUrlInput.value.trim();
if (!url) return;
showScrapeLoading();
try {
const response = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 403 || errorData.isBlocked) {
throw new Error('BLOCKED');
}
throw new Error(errorData.error || 'Extraction error.');
}
const data = await response.json();
hideScrapeLoading();
openDraftPreview(data);
elements.scrapeUrlInput.value = '';
} catch (error) {
hideScrapeLoading();
if (error.message === 'BLOCKED') {
alert(`Access Blocked by Site Protection\n\nThis website uses Cloudflare or bot-defense screens to prevent automated scraping.\n\nDon't worry! You can still add this recipe by clicking the "Manual Recipe" button at the top to quickly paste your ingredients and directions.`);
} else {
alert(`Scraping failed: ${error.message}`);
}
}
}
// Save or Edit submit
async function handleRecipeSave(e) {
e.preventDefault();
const id = elements.editRecipeId.value;
const recipePayload = {
title: elements.editTitle.value.trim(),
sourceUrl: elements.editSource.value.trim(),
description: elements.editDescription.value.trim(),
servings: elements.editServings.value.trim(),
prepTime: elements.editPrepTime.value.trim(),
cookTime: elements.editCookTime.value.trim(),
image: elements.editImageUrl.value.trim(),
images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src),
ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean),
instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean)
};
try {
let response;
if (id) {
// Edit
response = await fetch(`/api/recipes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipePayload)
});
} else {
// Create
response = await fetch('/api/recipes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipePayload)
});
}
if (!response.ok) throw new Error('Saving failed.');
const saved = await response.json();
closeModal('save-modal');
fetchRecipes();
// If active view was open, update it
if (activeRecipe && activeRecipe.id === saved.id) {
showRecipeDetails(saved);
}
} catch (error) {
console.error('Error saving recipe:', error);
alert('Error saving recipe.');
}
}
// Delete Active Recipe
async function deleteActiveRecipe() {
if (!activeRecipe) return;
if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return;
try {
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Deletion failed.');
closeModal('detail-modal');
activeRecipe = null;
fetchRecipes();
} catch (error) {
console.error('Error deleting recipe:', error);
alert('Deletion failed.');
}
}
// -------------------------------------------------------------
// DRAFT / MANUAL CREATOR MODAL POPULATOR
// -------------------------------------------------------------
function openManualAdd() {
elements.modalActionTitle.textContent = 'Create New Recipe';
elements.editRecipeId.value = '';
elements.recipeEditForm.reset();
elements.imageSelectionGrid.innerHTML = '';
selectedCoverImage = '';
openModal('save-modal');
}
function openDraftPreview(data) {
elements.modalActionTitle.textContent = 'Save Extracted Recipe';
elements.editRecipeId.value = '';
elements.editTitle.value = data.title || '';
elements.editSource.value = data.sourceUrl || '';
elements.editDescription.value = data.description || '';
elements.editServings.value = data.servings || '';
elements.editPrepTime.value = data.prepTime || '';
elements.editCookTime.value = data.cookTime || '';
// Ingredients text area
elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : '';
// Instructions text area
elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : '';
// Render Image Selector
renderImageSelector(data.images || []);
openModal('save-modal');
}
function editActiveRecipe() {
if (!activeRecipe) return;
elements.modalActionTitle.textContent = 'Edit Recipe Details';
elements.editRecipeId.value = activeRecipe.id;
elements.editTitle.value = activeRecipe.title || '';
elements.editSource.value = activeRecipe.sourceUrl || '';
elements.editDescription.value = activeRecipe.description || '';
elements.editServings.value = activeRecipe.servings || '';
elements.editPrepTime.value = activeRecipe.prepTime || '';
elements.editCookTime.value = activeRecipe.cookTime || '';
elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : '';
elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : '';
renderImageSelector(activeRecipe.images || []);
// Set active selection
if (activeRecipe.image) {
elements.editImageUrl.value = activeRecipe.image;
selectedCoverImage = activeRecipe.image;
selectImageThumbnail(activeRecipe.image);
}
openModal('save-modal');
}
// -------------------------------------------------------------
// IMAGE EXTRACTION CAROUSEL & SELECTOR
// -------------------------------------------------------------
function renderImageSelector(images) {
elements.imageSelectionGrid.innerHTML = '';
selectedCoverImage = '';
if (!images || images.length === 0) {
elements.editImageUrl.value = '';
return;
}
// Pre-select first image
selectedCoverImage = images[0];
elements.editImageUrl.value = images[0];
images.forEach((imgSrc, index) => {
const option = document.createElement('div');
option.className = `image-option ${index === 0 ? 'selected' : ''}`;
option.innerHTML = `<img src="${imgSrc}" alt="extracted thumbnail">`;
option.addEventListener('click', () => {
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => el.classList.remove('selected'));
option.classList.add('selected');
selectedCoverImage = imgSrc;
elements.editImageUrl.value = imgSrc;
});
elements.imageSelectionGrid.appendChild(option);
});
}
function selectImageThumbnail(url) {
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => {
const img = el.querySelector('img');
if (img && img.src === url) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
});
}
// -------------------------------------------------------------
// DASHBOARD RENDERING
// -------------------------------------------------------------
function renderLibrary() {
elements.recipesGrid.innerHTML = '';
const searchVal = elements.librarySearch.value.toLowerCase().trim();
const filtered = allRecipes.filter(recipe => {
return recipe.title.toLowerCase().includes(searchVal) ||
(recipe.description && recipe.description.toLowerCase().includes(searchVal)) ||
(recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(searchVal)));
});
if (filtered.length === 0) {
elements.emptyState.style.display = 'flex';
return;
}
elements.emptyState.style.display = 'none';
filtered.forEach(recipe => {
const domain = recipe.sourceUrl ? new URL(recipe.sourceUrl).hostname.replace('www.', '') : 'Manual Recipe';
const card = document.createElement('div');
card.className = 'recipe-card';
card.addEventListener('click', () => showRecipeDetails(recipe));
let imgBlock = '';
if (recipe.image) {
imgBlock = `<img src="${recipe.image}" class="recipe-card-img" alt="${recipe.title}" loading="lazy">`;
} else {
imgBlock = `
<div class="recipe-card-img-placeholder">
<i data-lucide="cooking-pot"></i>
<span>No dish photo</span>
</div>
`;
}
card.innerHTML = `
<div class="recipe-card-img-wrapper">
${imgBlock}
</div>
<div class="recipe-card-content">
<span class="recipe-card-origin">${domain}</span>
<h3 class="recipe-card-title">${recipe.title}</h3>
<p class="recipe-card-desc">${recipe.description || 'No description available for this recipe.'}</p>
<div class="recipe-card-meta">
${recipe.servings ? `<span><i data-lucide="users"></i> ${recipe.servings} Servings</span>` : ''}
${recipe.totalTime ? `<span><i data-lucide="clock"></i> ${recipe.totalTime}</span>` : ''}
</div>
</div>
`;
elements.recipesGrid.appendChild(card);
});
lucide.createIcons();
}
function filterRecipes() {
renderLibrary();
}
// -------------------------------------------------------------
// DYNAMIC SERVINGS QUANTITY SCALER
// -------------------------------------------------------------
function scaleIngredientText(text, multiplier) {
if (multiplier === 1) return text;
// Mixed number regex: e.g. "1 1/2" or "2 1/4"
const mixedRegex = /^(\d+)\s+(\d+\/\d+)\s*(.*)/;
const mixedMatch = text.match(mixedRegex);
if (mixedMatch) {
const whole = parseInt(mixedMatch[1], 10);
const fracParts = mixedMatch[2].split('/');
const fracVal = parseInt(fracParts[0], 10) / parseInt(fracParts[1], 10);
const total = (whole + fracVal) * multiplier;
return `${formatScaledNumber(total)} ${mixedMatch[3]}`;
}
// Pure Fraction regex: e.g. "1/2" or "3/4"
const fracRegex = /^(\d+\/\d+)\s*(.*)/;
const fracMatch = text.match(fracRegex);
if (fracMatch) {
const parts = fracMatch[1].split('/');
const val = (parseInt(parts[0], 10) / parseInt(parts[1], 10)) * multiplier;
return `${formatScaledNumber(val)} ${fracMatch[2]}`;
}
// Decimals & Integers regex: e.g. "1.5" or "4"
const numRegex = /^(\d+(?:\.\d+)?)\s*(.*)/;
const numMatch = text.match(numRegex);
if (numMatch) {
const val = parseFloat(numMatch[1]) * multiplier;
return `${formatScaledNumber(val)} ${numMatch[2]}`;
}
return text;
}
function formatScaledNumber(num) {
if (num % 1 === 0) return num.toString();
const rounded = Math.round(num * 100) / 100;
const decimal = rounded % 1;
const integer = Math.floor(rounded);
// Common fraction mappings for nice reading
if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4';
if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2';
if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4';
if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3';
if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3';
return rounded.toString();
}
function adjustMultiplier(change) {
const newMulti = currentMultiplier + change;
if (newMulti < 0.5 || newMulti > 10) return;
currentMultiplier = newMulti;
elements.currentServingsVal.textContent = `${currentMultiplier}x`;
renderScaledIngredients();
}
function renderScaledIngredients() {
elements.recipeDetailIngredients.innerHTML = '';
if (!activeRecipe || !activeRecipe.ingredients) return;
activeRecipe.ingredients.forEach((ing, index) => {
const scaledText = scaleIngredientText(ing, currentMultiplier);
const li = document.createElement('li');
li.className = 'ingredient-checkbox-item';
li.innerHTML = `
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
<span class="ingredient-text">${scaledText}</span>
`;
li.addEventListener('click', () => {
li.classList.toggle('checked');
});
elements.recipeDetailIngredients.appendChild(li);
});
lucide.createIcons();
}
// -------------------------------------------------------------
// RECIPE DETAIL VIEWER COMPONENT
// -------------------------------------------------------------
function showRecipeDetails(recipe) {
activeRecipe = recipe;
currentMultiplier = 1;
elements.currentServingsVal.textContent = '1x';
// Set hero cover banner image
if (recipe.image) {
elements.recipeDetailBanner.style.backgroundImage = `url('${recipe.image}')`;
} else {
elements.recipeDetailBanner.style.backgroundImage = 'none';
elements.recipeDetailBanner.style.backgroundColor = 'var(--bg-app)';
}
elements.recipeDetailTitle.textContent = recipe.title;
elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.';
// Setup meta tags
if (recipe.servings) {
elements.tagServings.style.display = 'flex';
elements.tagServings.querySelector('.val').textContent = `${recipe.servings} Servings`;
} else {
elements.tagServings.style.display = 'none';
}
if (recipe.prepTime) {
elements.tagPrep.style.display = 'flex';
elements.tagPrep.querySelector('.val').textContent = recipe.prepTime;
} else {
elements.tagPrep.style.display = 'none';
}
if (recipe.cookTime) {
elements.tagCook.style.display = 'flex';
elements.tagCook.querySelector('.val').textContent = recipe.cookTime;
} else {
elements.tagCook.style.display = 'none';
}
// Populate Ingredients with scaler
renderScaledIngredients();
// Populate Directions Steps
elements.recipeDetailInstructions.innerHTML = '';
if (recipe.instructions) {
recipe.instructions.forEach((step, index) => {
const li = document.createElement('li');
// Look for section headers (e.g. ### Sauce)
if (step.startsWith('###')) {
li.className = 'instruction-step-item section-header-step';
li.innerHTML = `
<div class="step-text-block">${step.replace('###', '').trim()}</div>
`;
} else {
li.className = 'instruction-step-item';
li.innerHTML = `
<div class="step-number-bullet">${index + 1}</div>
<div class="step-text-block">${step}</div>
`;
li.addEventListener('click', () => {
li.classList.toggle('checked');
});
}
elements.recipeDetailInstructions.appendChild(li);
});
}
openModal('detail-modal');
}
// -------------------------------------------------------------
// INTERACTIVE FULLSCREEN COOKING ASSISTANT
// -------------------------------------------------------------
function enterCookingMode() {
if (!activeRecipe || !activeRecipe.instructions || activeRecipe.instructions.length === 0) return;
elements.cookRecipeTitle.textContent = activeRecipe.title;
cookingActiveStep = 0;
// Filter out header steps from the cooking carousel to keep steps clean
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
if (cleanInstructions.length === 0) return;
elements.cookingModeModal.classList.add('active');
document.body.style.overflow = 'hidden'; // Lock background scrolling
renderCookingStep(cleanInstructions);
// Reset Timer
resetTimer();
startTimer();
}
function exitCookingMode() {
elements.cookingModeModal.classList.remove('active');
document.body.style.overflow = 'auto'; // Unlock background scrolling
stopTimer();
}
function renderCookingStep(instructions) {
const stepText = instructions[cookingActiveStep];
elements.cookStepIndex.textContent = `Step ${cookingActiveStep + 1} of ${instructions.length}`;
elements.cookStepInstruction.textContent = stepText;
// Update progress bar
const progressPercent = ((cookingActiveStep + 1) / instructions.length) * 100;
elements.cookProgressBar.style.width = `${progressPercent}%`;
// Render step navigation dot indicators
elements.cookStepDots.innerHTML = '';
instructions.forEach((_, idx) => {
const dot = document.createElement('div');
dot.className = `dot ${idx === cookingActiveStep ? 'active' : ''}`;
dot.addEventListener('click', () => {
cookingActiveStep = idx;
renderCookingStep(instructions);
});
elements.cookStepDots.appendChild(dot);
});
// Enable/disable buttons
elements.cookBtnPrev.disabled = cookingActiveStep === 0;
const nextBtnIconWrapper = elements.cookBtnNext.querySelector('.btn-icon-wrapper');
if (cookingActiveStep === instructions.length - 1) {
elements.cookBtnNext.querySelector('span').textContent = 'Finish Cooking';
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = '<i data-lucide="check"></i>';
} else {
elements.cookBtnNext.querySelector('span').textContent = 'Next Step';
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = '<i data-lucide="chevron-right"></i>';
}
lucide.createIcons();
}
function prevCookingStep() {
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
if (cookingActiveStep > 0) {
cookingActiveStep--;
renderCookingStep(cleanInstructions);
}
}
function nextCookingStep() {
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
if (cookingActiveStep < cleanInstructions.length - 1) {
cookingActiveStep++;
renderCookingStep(cleanInstructions);
} else {
// Finished cooking
exitCookingMode();
alert('Congratulations! Enjoy your delicious meal.');
}
}
// Timer/Stopwatch Functions
function startTimer() {
if (stopwatchInterval) return;
elements.cookTimerPlay.innerHTML = '<i data-lucide="pause"></i>';
lucide.createIcons();
stopwatchInterval = setInterval(() => {
stopwatchSeconds++;
const mins = Math.floor(stopwatchSeconds / 60).toString().padStart(2, '0');
const secs = (stopwatchSeconds % 60).toString().padStart(2, '0');
elements.cookTimerVal.textContent = `${mins}:${secs}`;
}, 1000);
}
function stopTimer() {
if (stopwatchInterval) {
clearInterval(stopwatchInterval);
stopwatchInterval = null;
}
elements.cookTimerPlay.innerHTML = '<i data-lucide="play"></i>';
lucide.createIcons();
}
function toggleTimer() {
if (stopwatchInterval) {
stopTimer();
} else {
startTimer();
}
}
function resetTimer() {
stopTimer();
stopwatchSeconds = 0;
elements.cookTimerVal.textContent = '00:00';
}

302
public/index.html Normal file
View file

@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GourmetVault – Curated Recipe Manager</title>
<!-- Google Fonts Outfit & Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Custom Styling -->
<link rel="stylesheet" href="style.css">
</head>
<body class="dark-theme">
<!-- Top Navigation -->
<header class="app-header">
<div class="header-logo">
<div class="logo-icon"><i data-lucide="cooking-pot"></i></div>
<span class="logo-text">Gourmet<span>Vault</span></span>
</div>
<div class="header-actions">
<button class="btn btn-outline" id="btn-manual-add" title="Create Recipe Manually">
<i data-lucide="plus"></i> <span>Manual Recipe</span>
</button>
<button class="theme-toggle" id="theme-toggle" title="Toggle Theme">
<i data-lucide="sun" class="sun-icon"></i>
<i data-lucide="moon" class="moon-icon"></i>
</button>
</div>
</header>
<!-- Main Workspace -->
<main class="app-container">
<!-- Hero / URL Input Bar -->
<section class="scraping-hero">
<div class="hero-content">
<h1>Save Recipes from Anywhere Instantly</h1>
<p>Paste any cooking link below. Our engine will extract ingredients, directions, times, and images directly into your personal digital cookbook.</p>
</div>
<form class="url-input-form" id="url-scrape-form">
<div class="input-wrapper">
<i data-lucide="link" class="input-icon"></i>
<input type="url" id="scrape-url-input" placeholder="Paste cooking website link here (e.g., allrecipes.com, foodnetwork.com...)" required autocomplete="off">
</div>
<button type="submit" class="btn btn-primary" id="btn-scrape-submit">
<i data-lucide="wand-2"></i> <span>Extract Recipe</span>
</button>
</form>
</section>
<!-- Recipe Library Dashboard -->
<section class="library-section">
<div class="library-header">
<h2 class="section-title">My Cookbook</h2>
<!-- Search and Filters -->
<div class="filter-controls">
<div class="search-wrapper">
<i data-lucide="search" class="search-icon"></i>
<input type="text" id="library-search" placeholder="Search saved recipes...">
</div>
</div>
</div>
<!-- Recipes Grid -->
<div class="recipes-grid" id="recipes-grid">
<!-- Recipe cards will be loaded here dynamically -->
</div>
<!-- Empty State -->
<div class="empty-state" id="empty-state">
<div class="empty-icon"><i data-lucide="folder-open"></i></div>
<h3>Your vault is empty</h3>
<p>Insert a cooking web link above or create one manually to build your digital recipe collections.</p>
</div>
</section>
</main>
<!-- SCRAPE PREVIEW & EDIT DIALOG -->
<div class="modal-overlay" id="save-modal">
<div class="modal-box max-width-lg">
<div class="modal-header">
<h3 id="modal-action-title">Scraped Recipe Draft</h3>
<button class="close-btn" onclick="closeModal('save-modal')"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="recipe-edit-form">
<input type="hidden" id="edit-recipe-id">
<div class="form-grid-2">
<div class="form-group">
<label for="edit-title">Recipe Title *</label>
<input type="text" id="edit-title" required placeholder="e.g. Garlic Butter Roast Salmon">
</div>
<div class="form-group">
<label for="edit-source">Source URL</label>
<input type="url" id="edit-source" placeholder="Origin link">
</div>
</div>
<div class="form-group">
<label for="edit-description">Description / Short Review</label>
<textarea id="edit-description" rows="2" placeholder="Brief note about the dish..."></textarea>
</div>
<div class="form-grid-3">
<div class="form-group">
<label for="edit-servings">Servings</label>
<input type="text" id="edit-servings" placeholder="e.g., 4">
</div>
<div class="form-group">
<label for="edit-prep-time">Prep Time</label>
<input type="text" id="edit-prep-time" placeholder="e.g., 15 mins">
</div>
<div class="form-group">
<label for="edit-cook-time">Cook Time</label>
<input type="text" id="edit-cook-time" placeholder="e.g., 30 mins">
</div>
</div>
<!-- Image Chooser -->
<div class="form-group">
<label>Cover Image</label>
<p class="form-help">Select one of the images extracted from the site, or paste a custom URL below.</p>
<div class="image-gallery-selector" id="image-selection-grid">
<!-- Dynamically loaded thumbnail options -->
</div>
<div class="input-wrapper custom-img-url-wrapper">
<i data-lucide="image" class="input-icon"></i>
<input type="url" id="edit-image-url" placeholder="Or enter a direct custom image URL...">
</div>
</div>
<div class="form-grid-2">
<!-- Ingredients Editing -->
<div class="form-group">
<label>Ingredients (one per line)</label>
<textarea id="edit-ingredients" rows="8" placeholder="1 lb Pasta&#10;2 tbsp Olive oil&#10;4 cloves Garlic..." required></textarea>
</div>
<!-- Instructions Editing -->
<div class="form-group">
<label>Instructions (one step per line)</label>
<textarea id="edit-instructions" rows="8" placeholder="Boil water and cook pasta.&#10;Sauté garlic in olive oil.&#10;Combine pasta and sauce..." required></textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline" onclick="closeModal('save-modal')">Discard</button>
<button type="submit" form="recipe-edit-form" class="btn btn-primary" id="btn-save-confirm">
<i data-lucide="save"></i> <span>Save to Vault</span>
</button>
</div>
</div>
</div>
<!-- INTERACTIVE RECIPE DETAIL VIEWER -->
<div class="modal-overlay" id="detail-modal">
<div class="modal-box max-width-xl no-padding">
<div class="detail-hero-banner" id="recipe-detail-banner">
<button class="detail-back-btn" onclick="closeModal('detail-modal')"><i data-lucide="arrow-left"></i> <span>Back</span></button>
<div class="detail-actions">
<button class="btn btn-icon-round" id="btn-detail-edit" title="Edit Recipe"><i data-lucide="edit-3"></i></button>
<button class="btn btn-icon-round btn-danger-hover" id="btn-detail-delete" title="Delete Recipe"><i data-lucide="trash-2"></i></button>
</div>
</div>
<div class="detail-main-content">
<div class="detail-header-info">
<h2 id="recipe-detail-title">Garlic Butter Salmon</h2>
<div class="recipe-meta-tags">
<span id="tag-servings"><i data-lucide="users"></i> <span class="val">4 servings</span></span>
<span id="tag-prep"><i data-lucide="clock"></i> Prep: <span class="val">15 mins</span></span>
<span id="tag-cook"><i data-lucide="flame"></i> Cook: <span class="val">25 mins</span></span>
</div>
<p class="recipe-detail-desc" id="recipe-detail-description">A quick and delicious salmon dinner.</p>
</div>
<div class="detail-tabs-container">
<!-- Serving Adjuster -->
<div class="servings-scaler-panel">
<span class="scale-label">Scale Ingredients:</span>
<div class="scaler-controls">
<button class="scaler-btn" id="scaler-minus"><i data-lucide="minus"></i></button>
<span class="scaler-val" id="current-servings-val">1x</span>
<button class="scaler-btn" id="scaler-plus"><i data-lucide="plus"></i></button>
</div>
<button class="btn btn-accent cooking-mode-trigger" id="btn-trigger-cookmode">
<i data-lucide="chef-hat"></i> <span>Start Cooking Mode</span>
</button>
</div>
<div class="detail-split-layout">
<!-- Left: Ingredients Checklist -->
<div class="detail-ingredients-column">
<h3>Ingredients</h3>
<p class="section-subtext">Check off items as you gather them</p>
<ul class="ingredients-check-list" id="recipe-detail-ingredients">
<!-- Checkbox elements populated via JS -->
</ul>
</div>
<!-- Right: Instructions Checklist -->
<div class="detail-instructions-column">
<h3>Cooking Directions</h3>
<p class="section-subtext">Track your progress step-by-step</p>
<ol class="instructions-progress-list" id="recipe-detail-instructions">
<!-- Steps populated via JS -->
</ol>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- FULLSCREEN INTERACTIVE COOKING MODE -->
<div class="modal-overlay cooking-mode-overlay" id="cooking-mode-modal">
<div class="cooking-container">
<div class="cooking-header">
<div class="cooking-brand">
<i data-lucide="chef-hat"></i> <span>Cooking Assistant</span>
</div>
<div class="cooking-title-indicator" id="cook-recipe-title">Garlic Salmon</div>
<!-- Live Stopwatch / Timer -->
<div class="cook-timer-panel">
<div class="timer-display" id="cook-timer-val">00:00</div>
<button class="timer-btn" id="cook-timer-play" title="Play/Pause stopwatch"><i data-lucide="play"></i></button>
<button class="timer-btn btn-outline" id="cook-timer-reset" title="Reset stopwatch"><i data-lucide="rotate-ccw"></i></button>
</div>
<button class="close-btn color-white" onclick="exitCookingMode()"><i data-lucide="x"></i></button>
</div>
<div class="cooking-stepper-box">
<div class="step-progress-track">
<div class="step-progress-bar" id="cook-progress-bar"></div>
</div>
<div class="cooking-slide">
<span class="cooking-step-badge" id="cook-step-index">STEP 1 of 5</span>
<div class="cooking-step-text" id="cook-step-instruction">Boil water and cook pasta.</div>
</div>
</div>
<div class="cooking-controls">
<button class="btn btn-outline btn-lg color-white" id="cook-btn-prev">
<i data-lucide="chevron-left"></i> <span>Prev Step</span>
</button>
<div class="step-dots" id="cook-step-dots">
<!-- Step indicator bullet points -->
</div>
<button class="btn btn-accent btn-lg" id="cook-btn-next">
<span>Next Step</span> <span class="btn-icon-wrapper"><i data-lucide="chevron-right"></i></span>
</button>
</div>
</div>
</div>
<!-- LOADING / SCRAPING STATUS SCREEN -->
<div class="modal-overlay loading-overlay" id="loading-overlay">
<div class="loader-card">
<div class="spinner-outer">
<div class="spinner-inner"></div>
<i data-lucide="cooking-pot" class="spinner-logo"></i>
</div>
<h3>Extracting Recipe Data</h3>
<p class="loader-subtitle">Retrieving site contents and parsing culinary structures...</p>
<ul class="loader-steps">
<li class="loader-step active" id="loader-step-fetch">
<span class="step-icon-wrapper"><i data-lucide="check-circle-2"></i></span> <span>Connecting to source URL...</span>
</li>
<li class="loader-step" id="loader-step-parse">
<span class="step-icon-wrapper"><i data-lucide="circle-dashed" class="spinning-icon"></i></span> <span>Parsing schema structures...</span>
</li>
<li class="loader-step" id="loader-step-images">
<span class="step-icon-wrapper"><i data-lucide="circle" class="dot-icon"></i></span> <span>Extracting high-res dish photos...</span>
</li>
</ul>
</div>
</div>
<script>
// Initialize Lucide Icons initially
window.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
});
</script>
<!-- Frontend Javascript -->
<script src="app.js"></script>
</body>
</html>

1447
public/style.css Normal file

File diff suppressed because it is too large Load diff

551
server.js Normal file
View file

@ -0,0 +1,551 @@
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}`);
});