const express = require('express');
const axios = require('axios');
const cheerio = require('cheerio');
const cors = require('cors');
const fs = require('fs');
const http = require('http');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const Database = require('better-sqlite3');
const rateLimit = require('express-rate-limit');
const { log, httpLogger } = require('./logger');
// Enable stealth mode to bypass bot detection (Cloudflare, Akamai, etc.)
puppeteer.use(StealthPlugin());
const app = express();
const PORT = process.env.PORT || 3000;
const APP_URL = (process.env.APP_URL || '').trim().replace(/\/+$/, '');
// Enable proxy header trust (e.g. X-Forwarded-Proto, X-Forwarded-Host) when behind reverse proxies / SSL termination
app.set('trust proxy', 1);
// Helper to determine canonical application base URL
function getBaseUrl(req) {
if (APP_URL) {
return APP_URL;
}
if (req) {
const protocol = req.protocol || 'http';
const host = req.get('host') || `localhost:${PORT}`;
return `${protocol}://${host}`;
}
return `http://localhost:${PORT}`;
}
// Setup directories for persistence
const DATA_DIR = path.join(__dirname, 'data');
const DB_FILE = path.join(DATA_DIR, 'forkfolio.db');
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
// Open SQLite Database connection and set WAL journal mode for concurrency speed
const db = new Database(DB_FILE);
db.pragma('journal_mode = WAL');
// Initialize database schema tables
db.prepare(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
salt TEXT NOT NULL,
passwordHash TEXT NOT NULL,
isAdmin INTEGER NOT NULL DEFAULT 0,
isRecipeManager INTEGER NOT NULL DEFAULT 0,
defaultConsolidateLists INTEGER NOT NULL DEFAULT 1,
createdAt TEXT NOT NULL
)
`).run();
// Ensure defaultConsolidateLists column exists on existing users table
try {
const userCols = db.pragma('table_info(users)');
if (!userCols.some(c => c.name === 'defaultConsolidateLists')) {
db.prepare('ALTER TABLE users ADD COLUMN defaultConsolidateLists INTEGER NOT NULL DEFAULT 1').run();
}
} catch (colErr) {
log.error('Failed to check/add defaultConsolidateLists column:', colErr);
}
db.prepare(`
CREATE TABLE IF NOT EXISTS recipes (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
createdBy TEXT NOT NULL,
isPublic INTEGER NOT NULL DEFAULT 1,
title TEXT NOT NULL,
description TEXT,
servings TEXT,
prepTime TEXT,
cookTime TEXT,
totalTime TEXT,
image TEXT,
images TEXT, -- JSON stringified array
ingredients TEXT, -- JSON stringified array
instructions TEXT, -- JSON stringified array
sourceUrl TEXT,
shareToken TEXT UNIQUE, -- Unguessable sharing token
createdAt TEXT NOT NULL,
updatedAt TEXT,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS settings (
geminiApiKey TEXT DEFAULT '',
geminiModel TEXT DEFAULT 'gemini-2.5-flash'
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS recipe_shares_visits (
shareToken TEXT NOT NULL,
visitorIp TEXT NOT NULL,
visitedAt TEXT NOT NULL,
PRIMARY KEY (shareToken, visitorIp)
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS shopping_lists (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
createdBy TEXT NOT NULL,
name TEXT NOT NULL,
shareToken TEXT UNIQUE,
createdAt TEXT NOT NULL,
updatedAt TEXT,
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS shopping_list_items (
id TEXT PRIMARY KEY,
listId TEXT NOT NULL,
rawText TEXT NOT NULL,
itemName TEXT NOT NULL,
quantity REAL,
unit TEXT DEFAULT '',
category TEXT NOT NULL DEFAULT 'Other',
recipeId TEXT,
recipeTitle TEXT,
checked INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
FOREIGN KEY (listId) REFERENCES shopping_lists (id) ON DELETE CASCADE
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS shopping_list_shares_visits (
shareToken TEXT NOT NULL,
visitorIp TEXT NOT NULL,
visitedAt TEXT NOT NULL,
PRIMARY KEY (shareToken, visitorIp)
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS backup_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
scheduleFrequency TEXT NOT NULL DEFAULT 'off',
scheduleTime TEXT NOT NULL DEFAULT '02:00',
scheduleDay INTEGER NOT NULL DEFAULT 0,
retentionCount INTEGER NOT NULL DEFAULT 14,
backupFormat TEXT NOT NULL DEFAULT 'both',
embedRemoteImages INTEGER NOT NULL DEFAULT 1,
s3Enabled INTEGER NOT NULL DEFAULT 0,
s3Endpoint TEXT NOT NULL DEFAULT 'https://s3.us-east-1.wasabisys.com',
s3Region TEXT NOT NULL DEFAULT 'us-east-1',
s3Bucket TEXT NOT NULL DEFAULT '',
s3Prefix TEXT NOT NULL DEFAULT 'forkfolio-backups/',
s3AccessKeyId TEXT NOT NULL DEFAULT '',
s3SecretAccessKey TEXT NOT NULL DEFAULT '',
gdriveEnabled INTEGER NOT NULL DEFAULT 0,
gdriveClientId TEXT NOT NULL DEFAULT '',
gdriveClientSecret TEXT NOT NULL DEFAULT '',
gdriveRefreshToken TEXT NOT NULL DEFAULT '',
gdriveFolderId TEXT NOT NULL DEFAULT '',
lastBackupAt TEXT,
lastBackupStatus TEXT,
lastBackupMessage TEXT
)
`).run();
db.prepare(`
CREATE TABLE IF NOT EXISTS backup_history (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
triggerType TEXT NOT NULL,
sizeBytes INTEGER NOT NULL DEFAULT 0,
userCount INTEGER NOT NULL DEFAULT 0,
recipeCount INTEGER NOT NULL DEFAULT 0,
imageCount INTEGER NOT NULL DEFAULT 0,
listCount INTEGER NOT NULL DEFAULT 0,
s3Objects TEXT DEFAULT '[]',
gdriveFiles TEXT DEFAULT '[]',
status TEXT NOT NULL,
errorDetails TEXT,
createdAt TEXT NOT NULL
)
`).run();
const backupConfigCount = db.prepare('SELECT COUNT(*) as count FROM backup_config').get().count;
if (backupConfigCount === 0) {
db.prepare('INSERT INTO backup_config (id) VALUES (1)').run();
}
try {
db.prepare("ALTER TABLE backup_config ADD COLUMN gdriveAccountEmail TEXT NOT NULL DEFAULT ''").run();
} catch (e) {}
try {
db.prepare("ALTER TABLE backup_config ADD COLUMN gdriveFolderName TEXT NOT NULL DEFAULT 'ForkFolio Backups'").run();
} catch (e) {}
try {
db.prepare("ALTER TABLE backup_history ADD COLUMN userSummary TEXT NOT NULL DEFAULT '[]'").run();
} catch (e) {}
try {
db.prepare("ALTER TABLE settings ADD COLUMN geminiModel TEXT DEFAULT 'gemini-2.5-flash'").run();
} catch (e) {}
try {
db.prepare("UPDATE settings SET geminiModel = 'gemini-2.5-flash' WHERE geminiModel IS NULL OR geminiModel = '' OR geminiModel = 'gemini-2.0-flash'").run();
} catch (e) {}
// Initialize single settings record if empty
const settingsCount = db.prepare('SELECT COUNT(*) as count FROM settings').get().count;
if (settingsCount === 0) {
db.prepare('INSERT INTO settings (geminiApiKey, geminiModel) VALUES (?, ?)').run('', 'gemini-2.5-flash');
}
// Ensure default system_admin and admin user profiles exist
try {
// Ensure default system_admin user exists to fulfill recipe foreign key constraints
const hasSystemAdmin = db.prepare("SELECT COUNT(*) as count FROM users WHERE id = 'system_admin'").get().count;
if (hasSystemAdmin === 0) {
db.prepare(`
INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('system_admin', 'system_admin', 'system_salt', 'system_hash', 0, 0, new Date().toISOString());
}
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE isAdmin = 1").get().count;
if (adminCount === 0) {
const salt = crypto.randomBytes(16).toString('hex');
const passwordHash = crypto.pbkdf2Sync('admin123', salt, 1000, 64, 'sha512').toString('hex');
db.prepare(`
INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
'u_admin',
'admin',
salt,
passwordHash,
1,
1,
new Date().toISOString()
);
log.info('--- ForkFolio Admin Initialized ---');
log.info('Default Admin Username: admin');
log.info('Default Admin Password: admin123');
log.info('--------------------------------------');
}
} catch (e) {
log.error('Failed to initialize default admin and system users:', e);
}
// Automatic Migration from legacy JSON databases if they exist
const LEGACY_USERS_FILE = path.join(DATA_DIR, 'users.json');
const LEGACY_RECIPES_FILE = path.join(DATA_DIR, 'recipes.json');
const LEGACY_SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
try {
// Temporarily disable foreign keys during bulk migrations to allow flexible insert order
db.pragma('foreign_keys = OFF');
// 1. Migrate Users
if (fs.existsSync(LEGACY_USERS_FILE)) {
const backupUsersFile = path.join(DATA_DIR, 'users.json.backup');
const data = fs.readFileSync(LEGACY_USERS_FILE, 'utf8');
const users = JSON.parse(data);
if (Array.isArray(users) && users.length > 0) {
log.info(`[Migration] Found ${users.length} legacy users. Migrating to SQLite...`);
const insertUser = db.prepare(`
INSERT OR IGNORE INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const transaction = db.transaction((userList) => {
for (const u of userList) {
insertUser.run(
u.id,
u.username.trim().toLowerCase(),
u.salt,
u.passwordHash,
u.isAdmin ? 1 : 0,
u.isRecipeManager ? 1 : 0,
u.createdAt || new Date().toISOString()
);
}
});
transaction(users);
log.info(`[Migration] User migration completed.`);
}
fs.renameSync(LEGACY_USERS_FILE, backupUsersFile);
}
// 2. Migrate Recipes
if (fs.existsSync(LEGACY_RECIPES_FILE)) {
const backupRecipesFile = path.join(DATA_DIR, 'recipes.json.backup');
const data = fs.readFileSync(LEGACY_RECIPES_FILE, 'utf8');
const recipes = JSON.parse(data);
if (Array.isArray(recipes) && recipes.length > 0) {
log.info(`[Migration] Found ${recipes.length} legacy recipes. Migrating to SQLite...`);
const insertRecipe = db.prepare(`
INSERT OR IGNORE INTO recipes (
id, userId, createdBy, isPublic, title, description, servings,
prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const transaction = db.transaction((recipeList) => {
for (const r of recipeList) {
const shareToken = r.isPublic !== false && !r.shareToken
? crypto.randomBytes(16).toString('hex')
: (r.shareToken || null);
insertRecipe.run(
r.id,
r.userId || 'system_admin',
r.createdBy || 'gourmet_system',
r.isPublic !== false ? 1 : 0,
r.title || 'Untitled Recipe',
r.description || '',
r.servings || '',
r.prepTime || '',
r.cookTime || '',
r.totalTime || '',
r.image || '',
JSON.stringify(r.images || []),
JSON.stringify(r.ingredients || []),
JSON.stringify(r.instructions || []),
r.sourceUrl || '',
shareToken,
r.createdAt || new Date().toISOString(),
r.updatedAt || null
);
}
});
transaction(recipes);
log.info(`[Migration] Recipe migration completed.`);
}
fs.renameSync(LEGACY_RECIPES_FILE, backupRecipesFile);
}
// 3. Migrate Settings
if (fs.existsSync(LEGACY_SETTINGS_FILE)) {
const backupSettingsFile = path.join(DATA_DIR, 'settings.json.backup');
const data = fs.readFileSync(LEGACY_SETTINGS_FILE, 'utf8');
const settings = JSON.parse(data);
if (settings && settings.geminiApiKey) {
log.info('[Migration] Migrating system settings key to SQLite...');
db.prepare('UPDATE settings SET geminiApiKey = ?').run(settings.geminiApiKey.trim());
log.info('[Migration] Settings migration completed.');
}
fs.renameSync(LEGACY_SETTINGS_FILE, backupSettingsFile);
}
// Restore foreign keys enforcement after migrations
db.pragma('foreign_keys = ON');
} catch (migrationError) {
// Guarantee foreign keys re-enabled even on crash
db.pragma('foreign_keys = ON');
log.error('[Migration] Failed to perform automatic SQLite migration:', migrationError);
}
// In-memory sessions storage (token -> { id, username, isAdmin, isRecipeManager })
const activeSessions = new Map();
// Structured request logging (one JSON line per request; static assets skipped)
app.use(httpLogger);
app.use(cors());
app.use(express.json({ limit: '100mb', type: ['application/json', 'text/plain'] }));
app.use(express.urlencoded({ limit: '100mb', extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
// Configure rate-limiting middleware for public shared routes
const publicShareLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per 15-minute window
message: { error: 'Too many queries from this IP. Please try again in 15 minutes.' }
});
// 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
}
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 [];
}
// Password Hashing and Salt helpers
function generateSalt() {
return crypto.randomBytes(16).toString('hex');
}
function hashPassword(password, salt) {
return crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
}
// Middleware: Authenticate Session Token
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authorization required. Please log in.' });
}
const token = authHeader.substring(7);
const session = activeSessions.get(token);
if (!session) {
return res.status(401).json({ error: 'Session expired. Please log in again.' });
}
req.user = session;
next();
}
// Helper: Deserialize JSON columns from SQLite recipe row
function formatRecipe(row) {
if (!row) return null;
let visitorCount = 0;
if (row.shareToken) {
try {
const res = db.prepare('SELECT COUNT(*) as count FROM recipe_shares_visits WHERE shareToken = ?').get(row.shareToken);
visitorCount = res ? res.count : 0;
} catch (e) {
log.error('Failed to query unique visitor count:', e);
}
}
return {
...row,
isPublic: row.isPublic === 1,
images: JSON.parse(row.images || '[]'),
ingredients: JSON.parse(row.ingredients || '[]'),
instructions: JSON.parse(row.instructions || '[]'),
visitorCount
};
}
// Public runtime configuration for client apps (e.g. canonical base URL)
app.get('/api/app-config', (req, res) => {
res.json({
appUrl: getBaseUrl(req)
});
});
// Auth API: User Registration (Requires Admin session)
app.post('/api/auth/register', requireAuth, (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied. Administrator privileges required.' });
}
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required.' });
}
const cleanUsername = username.trim().toLowerCase();
if (!/^[a-z0-9_]{3,20}$/.test(cleanUsername)) {
return res.status(400).json({ error: 'Username must be 3-20 characters long and contain only alphanumeric characters or underscores.' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters long.' });
}
try {
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(cleanUsername);
if (existing) {
return res.status(400).json({ error: 'Username is already taken.' });
}
const salt = generateSalt();
const passwordHash = hashPassword(password, salt);
const id = 'u_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
db.prepare(`
INSERT INTO users (id, username, salt, passwordHash, isAdmin, isRecipeManager, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(id, cleanUsername, salt, passwordHash, 0, 0, new Date().toISOString());
res.status(201).json({
user: {
id,
username: cleanUsername,
isAdmin: false,
isRecipeManager: false,
defaultConsolidateLists: true
}
});
} catch (error) {
log.error('Error during registration:', error);
res.status(500).json({ error: 'Failed to create account.' });
}
});
// Auth API: User Login
app.post('/api/auth/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required.' });
}
const cleanUsername = username.trim().toLowerCase();
try {
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(cleanUsername);
if (!user) {
return res.status(401).json({ error: 'Invalid username or password.' });
}
const hash = hashPassword(password, user.salt);
if (hash !== user.passwordHash) {
return res.status(401).json({ error: 'Invalid username or password.' });
}
const token = crypto.randomBytes(32).toString('hex');
const defaultConsolidateLists = user.defaultConsolidateLists !== 0;
activeSessions.set(token, {
id: user.id,
username: user.username,
isAdmin: user.isAdmin === 1,
isRecipeManager: user.isRecipeManager === 1,
defaultConsolidateLists
});
res.json({
token,
user: {
id: user.id,
username: user.username,
isAdmin: user.isAdmin === 1,
isRecipeManager: user.isRecipeManager === 1,
defaultConsolidateLists
}
});
} catch (error) {
log.error('Error during login:', error);
res.status(500).json({ error: 'Login failed.' });
}
});
// Admin API: List all users (Requires Admin session)
app.get('/api/admin/users', requireAuth, (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied. Administrator privileges required.' });
}
try {
const rows = db.prepare('SELECT id, username, isAdmin, isRecipeManager, createdAt FROM users').all();
const users = rows.map(u => ({
id: u.id,
username: u.username,
isAdmin: u.isAdmin === 1,
isRecipeManager: u.isRecipeManager === 1,
createdAt: u.createdAt
}));
res.json(users);
} catch (error) {
log.error('Error listing users:', error);
res.status(500).json({ error: 'Failed to retrieve registered users.' });
}
});
// Admin API: Delete user (Requires Admin session)
app.delete('/api/admin/users/:id', requireAuth, (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied. Administrator privileges required.' });
}
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'You cannot delete your own administrative account.' });
}
try {
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
const preserveRecipes = req.body.preserveRecipes === true;
// SQLite transaction for relational database integrity
const performDelete = db.transaction(() => {
if (preserveRecipes) {
// Reassign user's recipes to system_admin, set as public
db.prepare(`
UPDATE recipes
SET userId = 'system_admin',
createdBy = ?,
isPublic = 1
WHERE userId = ?
`).run(`${user.username} (former)`, user.id);
} else {
// Delete user's recipes
db.prepare('DELETE FROM recipes WHERE userId = ?').run(user.id);
}
// Delete user profile
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
});
performDelete();
// Invalidate active sessions
for (const [token, session] of activeSessions.entries()) {
if (session.id === user.id) {
activeSessions.delete(token);
}
}
res.json({ success: true });
} catch (error) {
log.error('Error deleting user:', error);
res.status(500).json({ error: 'Failed to delete user.' });
}
});
// Admin API: Change user role (Requires Admin session)
app.put('/api/admin/users/:id/role', requireAuth, (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied. Administrator privileges required.' });
}
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'You cannot change your own administrator role privileges.' });
}
const { isAdmin } = req.body;
if (typeof isAdmin !== 'boolean') {
return res.status(400).json({ error: 'isAdmin property must be a boolean.' });
}
try {
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
db.prepare('UPDATE users SET isAdmin = ? WHERE id = ?').run(isAdmin ? 1 : 0, user.id);
// Update sessions
for (const [token, session] of activeSessions.entries()) {
if (session.id === user.id) {
session.isAdmin = isAdmin;
activeSessions.set(token, session);
}
}
res.json({ success: true, user: { id: user.id, username: user.username, isAdmin } });
} catch (error) {
log.error('Error changing user role:', error);
res.status(500).json({ error: 'Failed to update user role.' });
}
});
// Admin API: Toggle recipe manager role (Requires Admin session)
app.put('/api/admin/users/:id/recipe-manager', requireAuth, (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied. Administrator privileges required.' });
}
const { isRecipeManager } = req.body;
if (typeof isRecipeManager !== 'boolean') {
return res.status(400).json({ error: 'isRecipeManager property must be a boolean.' });
}
try {
const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found.' });
}
db.prepare('UPDATE users SET isRecipeManager = ? WHERE id = ?').run(isRecipeManager ? 1 : 0, user.id);
// Update sessions
for (const [token, session] of activeSessions.entries()) {
if (session.id === user.id) {
session.isRecipeManager = isRecipeManager;
activeSessions.set(token, session);
}
}
res.json({ success: true, user: { id: user.id, username: user.username, isRecipeManager } });
} catch (error) {
log.error('Error toggling manager role:', error);
res.status(500).json({ error: 'Failed to update manager permission.' });
}
});
// Admin API: List all public recipe shares and visitor stats (Requires Admin session)
app.get('/api/admin/shared-recipes', requireAuth, (req, res) => {
if (!req.user.isAdmin) {
return res.status(403).json({ error: 'Access denied. Administrator privileges required.' });
}
try {
const rows = db.prepare(`
SELECT id, title, createdBy, shareToken, createdAt
FROM recipes
WHERE isPublic = 1 AND shareToken IS NOT NULL
ORDER BY createdAt DESC
`).all();
const sharedRecipes = rows.map(r => {
let visitorCount = 0;
try {
const stats = db.prepare('SELECT COUNT(*) as count FROM recipe_shares_visits WHERE shareToken = ?').get(r.shareToken);
visitorCount = stats ? stats.count : 0;
} catch (err) {
log.error('Failed to query shared recipe visitor count:', err);
}
return {
id: r.id,
title: r.title,
createdBy: r.createdBy,
shareToken: r.shareToken,
createdAt: r.createdAt,
visitorCount
};
});
res.json(sharedRecipes);
} catch (error) {
log.error('Error fetching admin shared recipes:', error);
res.status(500).json({ error: 'Failed to retrieve shared recipes.' });
}
});
// API: List authenticated user's private recipes
app.get('/api/recipes', requireAuth, (req, res) => {
try {
const rows = db.prepare('SELECT * FROM recipes WHERE userId = ? ORDER BY createdAt DESC').all(req.user.id);
const userRecipes = rows.map(formatRecipe);
res.json(userRecipes);
} catch (error) {
log.error('Error fetching recipes:', error);
res.status(500).json({ error: 'Failed to retrieve recipes.' });
}
});
// API: List all public recipes for Shared Cookbook
app.get('/api/recipes/public', requireAuth, (req, res) => {
try {
const rows = db.prepare('SELECT * FROM recipes WHERE isPublic = 1 ORDER BY createdAt DESC').all();
const publicRecipes = rows.map(formatRecipe);
res.json(publicRecipes);
} catch (error) {
log.error('Error fetching public recipes:', error);
res.status(500).json({ error: 'Failed to retrieve community recipes.' });
}
});
// API: Clone a public recipe to user's private collection
app.post('/api/recipes/clone/:id', requireAuth, (req, res) => {
try {
const sourceRecipe = db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id);
if (!sourceRecipe) {
return res.status(404).json({ error: 'Recipe not found' });
}
if (sourceRecipe.isPublic === 0) {
return res.status(403).json({ error: 'This recipe is private and cannot be cloned.' });
}
const clonedId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
db.prepare(`
INSERT INTO recipes (
id, userId, createdBy, isPublic, title, description, servings,
prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
clonedId,
req.user.id,
req.user.username,
0, // Default cloned copies to private so users can customize them privately
sourceRecipe.title || 'Untitled Recipe',
sourceRecipe.description || '',
sourceRecipe.servings || '',
sourceRecipe.prepTime || '',
sourceRecipe.cookTime || '',
sourceRecipe.totalTime || '',
sourceRecipe.image || '',
sourceRecipe.images || '[]',
sourceRecipe.ingredients || '[]',
sourceRecipe.instructions || '[]',
sourceRecipe.sourceUrl || '',
null, // Cloned copies start as private, no share token
new Date().toISOString(),
null
);
const clonedObj = formatRecipe(db.prepare('SELECT * FROM recipes WHERE id = ?').get(clonedId));
res.status(201).json(clonedObj);
} catch (error) {
log.error('Error cloning recipe:', error);
res.status(500).json({ error: 'Failed to clone recipe.' });
}
});
// API: Save new recipe
app.post('/api/recipes', requireAuth, (req, res) => {
const isPublic = req.body.isPublic !== false ? 1 : 0;
const shareToken = isPublic === 1 ? crypto.randomBytes(16).toString('hex') : null;
const newId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
try {
db.prepare(`
INSERT INTO recipes (
id, userId, createdBy, isPublic, title, description, servings,
prepTime, cookTime, totalTime, image, images, ingredients, instructions, sourceUrl, shareToken, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
newId,
req.user.id,
req.user.username,
isPublic,
req.body.title || 'Untitled Recipe',
req.body.description || '',
req.body.servings || '',
req.body.prepTime || '',
req.body.cookTime || '',
req.body.totalTime || '',
req.body.image || '',
JSON.stringify(Array.isArray(req.body.images) ? req.body.images : []),
JSON.stringify(Array.isArray(req.body.ingredients) ? req.body.ingredients : []),
JSON.stringify(Array.isArray(req.body.instructions) ? req.body.instructions : []),
req.body.sourceUrl || '',
shareToken,
new Date().toISOString(),
null
);
const savedObj = formatRecipe(db.prepare('SELECT * FROM recipes WHERE id = ?').get(newId));
res.status(201).json(savedObj);
} catch (error) {
log.error('Error saving new recipe:', error);
res.status(500).json({ error: 'Failed to save recipe.' });
}
});
// API: Update recipe
app.put('/api/recipes/:id', requireAuth, (req, res) => {
try {
const existing = db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Recipe not found' });
}
if (existing.userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) {
return res.status(403).json({ error: 'You do not have permission to modify this recipe.' });
}
const isPublic = req.body.isPublic !== undefined ? (req.body.isPublic ? 1 : 0) : existing.isPublic;
// Generate shareToken if it becomes public and doesn't have one
let shareToken = existing.shareToken;
if (isPublic === 1 && !shareToken) {
shareToken = crypto.randomBytes(16).toString('hex');
} else if (isPublic === 0) {
shareToken = null; // Revoke shareToken if it is made private
}
db.prepare(`
UPDATE recipes
SET title = ?,
description = ?,
ingredients = ?,
instructions = ?,
servings = ?,
prepTime = ?,
cookTime = ?,
totalTime = ?,
image = ?,
images = ?,
sourceUrl = ?,
isPublic = ?,
shareToken = ?,
updatedAt = ?
WHERE id = ?
`).run(
req.body.title || existing.title,
req.body.description !== undefined ? req.body.description : existing.description,
Array.isArray(req.body.ingredients) ? JSON.stringify(req.body.ingredients) : existing.ingredients,
Array.isArray(req.body.instructions) ? JSON.stringify(req.body.instructions) : existing.instructions,
req.body.servings !== undefined ? req.body.servings : existing.servings,
req.body.prepTime !== undefined ? req.body.prepTime : existing.prepTime,
req.body.cookTime !== undefined ? req.body.cookTime : existing.cookTime,
req.body.totalTime !== undefined ? req.body.totalTime : existing.totalTime,
req.body.image !== undefined ? req.body.image : existing.image,
Array.isArray(req.body.images) ? JSON.stringify(req.body.images) : existing.images,
req.body.sourceUrl !== undefined ? req.body.sourceUrl : existing.sourceUrl,
isPublic,
shareToken,
new Date().toISOString(),
req.params.id
);
const updatedObj = formatRecipe(db.prepare('SELECT * FROM recipes WHERE id = ?').get(req.params.id));
res.json(updatedObj);
} catch (error) {
log.error('Error updating recipe:', error);
res.status(500).json({ error: 'Failed to update recipe.' });
}
});
// API: Delete recipe
app.delete('/api/recipes/:id', requireAuth, (req, res) => {
try {
const existing = db.prepare('SELECT userId FROM recipes WHERE id = ?').get(req.params.id);
if (!existing) {
return res.status(404).json({ error: 'Recipe not found' });
}
if (existing.userId !== req.user.id && !req.user.isRecipeManager && !req.user.isAdmin) {
return res.status(403).json({ error: 'You do not have permission to delete this recipe.' });
}
db.prepare('DELETE FROM recipes WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error) {
log.error('Error deleting recipe:', error);
res.status(500).json({ error: 'Failed to delete recipe.' });
}
});
// Helper: Parse recipe data from raw HTML string
function parseRecipeFromHtml(html, url) {
const $ = cheerio.load(html);
// 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')) {
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 || '';
if (schemaRecipe.recipeYield) {
recipeData.servings = Array.isArray(schemaRecipe.recipeYield)
? schemaRecipe.recipeYield[0]
: schemaRecipe.recipeYield.toString();
}
recipeData.prepTime = parseISODuration(schemaRecipe.prepTime || '');
recipeData.cookTime = parseISODuration(schemaRecipe.cookTime || '');
recipeData.totalTime = parseISODuration(schemaRecipe.totalTime || '');
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;
}
recipeData.instructions = extractInstructions(schemaRecipe.recipeInstructions || schemaRecipe.recipeInstruction);
const schemaImages = normalizeImages(schemaRecipe.image);
schemaImages.forEach(img => {
if (!extractedImages.includes(img)) {
extractedImages.unshift(img);
}
});
} 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;
}
});
}
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;
}
});
}
// 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;
};
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('
')) {
const lines = html.split(/
/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;
}
});
}
}
// 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);
return recipeData;
}
// Helper: Detect if HTML response is a bot challenge page rather than real content
function isBotChallengePage(html) {
if (!html || typeof html !== 'string') return true;
const lowerHtml = html.toLowerCase();
if (lowerHtml.includes('cf-browser-verification') || lowerHtml.includes('cf_chl_opt')) return true;
if (lowerHtml.includes('checking your browser') && lowerHtml.includes('cloudflare')) return true;
if (lowerHtml.includes('akamai') && lowerHtml.includes('bot manager')) return true;
if (lowerHtml.includes('px-captcha') || lowerHtml.includes('perimeterx')) return true;
if (lowerHtml.includes('access to this page has been denied')) return true;
if (lowerHtml.includes('please verify you are a human') && !lowerHtml.includes('recipeingredient')) return true;
if (lowerHtml.includes('recaptcha') && !lowerHtml.includes('recipeingredient')) return true;
if (html.length < 500 && !lowerHtml.includes('recipe')) return true;
return false;
}
// Helper: Fetch full HTML via Jina Reader HTML mode to bypass edge bot protection (PerimeterX, Cloudflare, etc.)
async function fetchWithReaderProxy(url) {
log.info('[ReaderProxy] Fetching rendered HTML for:', url);
const response = await axios.get(`https://r.jina.ai/${url}`, {
headers: {
'X-Return-Format': 'html',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
},
timeout: 20000
});
return response.data;
}
// Helper: Fetch HTML using Puppeteer with stealth (headless browser fallback)
async function fetchWithPuppeteer(url) {
log.info('[Puppeteer] Launching headless browser for:', url);
let browser = null;
try {
browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--disable-gpu',
'--window-size=1920,1080'
]
});
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.setExtraHTTPHeaders({
'Accept-Language': 'en-US,en;q=0.9',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'
});
await page.goto(url, {
waitUntil: 'networkidle0',
timeout: 30000
});
await new Promise(resolve => setTimeout(resolve, 2000));
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight / 2);
});
await new Promise(resolve => setTimeout(resolve, 1000));
const html = await page.content();
log.info('[Puppeteer] Successfully retrieved', html.length, 'bytes of HTML');
return html;
} finally {
if (browser) {
await browser.close();
log.info('[Puppeteer] Browser closed');
}
}
}
// -------------------------------------------------------------
// TikTok Recipe Extraction (Option 1: Caption & Metadata Extraction)
// -------------------------------------------------------------
// -------------------------------------------------------------
// Social Media Recipe Extraction (Instagram & TikTok)
// -------------------------------------------------------------
// Helper: Check if a URL belongs to TikTok
function isTikTokUrl(url) {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase();
return host.includes('tiktok.com');
} catch (e) {
return false;
}
}
// Helper: Check if a URL belongs to Instagram
function isInstagramUrl(url) {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase();
return host.includes('instagram.com') || host.includes('instagr.am');
} catch (e) {
return false;
}
}
// Helper: Clean raw social caption by stripping trailing hashtags and emojis
function cleanSocialCaption(raw) {
if (!raw) return '';
let text = raw.replace(/(?:\s*#[^\s#]+)+\s*$/g, '').trim();
text = text.replace(/[\uFE00-\uFE0F\uD83C-\uDBFF\uDC00-\uDFFF\u2600-\u26FF\u2700-\u27BF]+$/, '').trim();
return text;
}
const cleanTikTokCaption = cleanSocialCaption;
// Helper: Clean raw Instagram caption from OpenGraph tags or quotes
function cleanInstagramCaption(raw) {
if (!raw) return '';
let text = raw.trim();
const quotedMatch = text.match(/(?:on Instagram:\s*["“]|:\s*["“])([\s\S]*?)["”]?$/i);
if (quotedMatch && quotedMatch[1]) {
text = quotedMatch[1];
} else {
const colonQuote = text.indexOf(': "');
if (colonQuote !== -1) {
text = text.slice(colonQuote + 3).replace(/["”]$/, '');
}
}
return cleanSocialCaption(text);
}
// Helper: Split single-line or unstructured social captions into readable candidate phrases
function splitCaptionIntoPhrases(text) {
const breakPhrases = [
'Mix it', 'Whisk', 'Add in', 'Add the', 'Add one', 'In a separate bowl:?', 'In a separate',
'In a measuring cup', 'In a greased and floured', 'In a greased', 'In a large bowl',
'For the apple:?', 'For the glaze:?', 'For the sauce:?', 'For the dough:?',
'Then add', 'Then other', 'Then 1/2', 'Swirl it', 'Sprinkle',
'Bake at', 'Glaze:?'
];
let working = text;
for (const phrase of breakPhrases) {
const re = new RegExp('\\b(' + phrase + ')', 'gi');
working = working.replace(re, '\n$1');
}
const qtyBreakRegex = /(?<=[a-zA-Z\)\,\.\!])\s+(?=(?:(?:\d+\s*(?:&|\+)?\s*)?(?:\d+[\/\.]\d+|[¼½¾⅓⅔⅛⅜⅝⅞]|\d+)\s*(?:cups?|c|tbsp|tablespoons?|tsp|teaspoons?|oz|ounces?|lbs?|sticks?|large|small|medium|cans?)\b))/gi;
working = working.replace(qtyBreakRegex, '\n');
return working.split('\n').map(s => s.trim()).filter(Boolean);
}
// Helper: Heuristic parser to extract recipe structure from a social video caption
function parseRecipeCaptionHeuristic(rawCaption, author, cover, sourceUrl) {
const caption = cleanSocialCaption(rawCaption);
if (!caption) return null;
let title = '';
const ingredients = [];
const instructions = [];
const ingHeaderMatch = caption.match(/\b(?:ingredients?|what you(?:'ll)? need)\s*[:\-]?\s*/i);
let remainingText = caption;
if (ingHeaderMatch) {
const rawTitle = caption.slice(0, ingHeaderMatch.index).trim();
const firstLine = rawTitle.split('\n')[0].trim();
title = firstLine.replace(/[:\-.]+$/, '').trim();
remainingText = caption.slice(ingHeaderMatch.index + ingHeaderMatch[0].length).trim();
} else {
const firstSentence = caption.match(/^([^.!?\n]+[.!?])/);
title = firstSentence ? firstSentence[1].replace(/[:\-.]+$/, '').trim() : caption.slice(0, 50).trim();
remainingText = caption.slice(title.length).trim();
}
const methodHeaderMatch = remainingText.match(/\b(?:instructions?|directions?|method|steps?|preparation|how to make(?: it)?)\s*[:\-]?\s*/i);
if (methodHeaderMatch) {
const ingText = remainingText.slice(0, methodHeaderMatch.index).trim();
const instText = remainingText.slice(methodHeaderMatch.index + methodHeaderMatch[0].length).trim();
const ingLines = ingText.split('\n').map(s => s.trim().replace(/^[:\-•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim()).filter(Boolean);
for (const line of ingLines) {
if (line.length >= 2) ingredients.push(line);
}
const instLines = instText.split('\n').map(s => s.trim().replace(/^\d+[\.\)]\s*/, '').replace(/^[:\-•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim()).filter(Boolean);
for (const line of instLines) {
if (line.length >= 2) instructions.push(line);
}
} else {
const segments = splitCaptionIntoPhrases(remainingText);
const instructionStarters = /^(?:mix|whisk|add|bake|stir|fold|combine|in a|then|swirl|sprinkle|pour|heat|cook|chill|serve|place|cut|slice|dice)\b/i;
const isIngredientPattern = /^(?:(?:\d+\s*(?:&|\+)?\s*)?(?:\d+[\/\.]\d+|[¼½¾⅓⅔⅛⅜⅝⅞]|\d+)\s*(?:cups?|c|tbsp|tablespoons?|tsp|teaspoons?|oz|ounces?|lbs?|sticks?|large|small|medium|cans?|pinch|pkg)?\b|[•◦⁃∙*\-+])/i;
for (let seg of segments) {
seg = seg.trim().replace(/^[:\-•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim();
if (!seg || seg.length < 2) continue;
if (instructionStarters.test(seg) || /^\d+[\.\)]\s+/.test(seg)) {
instructions.push(seg.replace(/^\d+[\.\)]\s*/, ''));
} else if (isIngredientPattern.test(seg)) {
if (/batter|pan/i.test(seg)) {
instructions.push(seg);
} else {
ingredients.push(seg);
}
} else if (/^(?:glaze|for the )/i.test(seg)) {
instructions.push(seg);
} else {
if (/(?:bowl|pan|oven|bake|mins?|minutes?|degrees?|350|mix|batter)/i.test(seg)) {
instructions.push(seg);
} else {
ingredients.push(seg);
}
}
}
}
let cookTime = '';
const bakeMatch = caption.match(/\b(?:bake(?:\s+at\s+\d+)?\s+(?:for\s+)?(\d+[\s\-to]+\d+\s*(?:mins?|minutes?|hours?|hrs?)))\b/i);
if (bakeMatch) {
cookTime = bakeMatch[1].trim();
}
const isIg = (sourceUrl || '').toLowerCase().includes('instagram');
const defaultFallback = isIg ? 'Instagram Recipe' : 'Social Media Recipe';
return {
title: title || (author ? `Recipe by ${author}` : defaultFallback),
description: caption.slice(0, 160).trim(),
image: cover || '',
images: cover ? [cover] : [],
servings: '',
prepTime: '',
cookTime,
totalTime: cookTime,
ingredients,
instructions,
sourceUrl: sourceUrl || ''
};
}
// Helper: Scrape recipe from an Instagram post or reel URL
async function scrapeInstagramRecipe(inputUrl) {
log.info('[Instagram] Starting scrape for:', inputUrl);
let canonicalUrl = inputUrl;
let caption = '';
let author = '';
let cover = '';
// 1. Resolve redirect for shortened links (e.g., instagr.am)
try {
const headRes = await axios.get(inputUrl, {
maxRedirects: 5,
validateStatus: () => true,
headers: {
'User-Agent': 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)'
},
timeout: 8000
});
if (headRes.request?.res?.responseUrl) {
canonicalUrl = headRes.request.res.responseUrl;
}
} catch (e) {
log.warn('[Instagram] Redirect note:', e.message);
}
// 2. Fetch page HTML using crawler user agents (Facebook external hit, Twitterbot)
const crawlerUserAgents = [
'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',
'Twitterbot/1.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
];
let html = null;
for (const ua of crawlerUserAgents) {
try {
const response = await axios.get(canonicalUrl, {
headers: {
'User-Agent': ua,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
},
timeout: 10000,
validateStatus: (status) => status < 400 || status === 302
});
if (response.data && typeof response.data === 'string' && response.data.includes('og:')) {
html = response.data;
break;
}
} catch (fetchErr) {
log.warn(`[Instagram] Crawler fetch failed with UA (${ua.split(' ')[0]}):`, fetchErr.message);
}
}
// 3. Extract OpenGraph and JSON-LD metadata if HTML was retrieved
if (html) {
const $ = cheerio.load(html);
cover = $('meta[property="og:image"]').attr('content') ||
$('meta[property="og:image:secure_url"]').attr('content') ||
$('meta[name="twitter:image"]').attr('content') || '';
const ogTitle = $('meta[property="og:title"]').attr('content') || '';
const authorMatch = ogTitle.match(/^(?:.*?on Instagram:\s*["“]?|)(.*?)(?:\s*\(@|\s*on Instagram|$)/i);
if (authorMatch && authorMatch[1] && !authorMatch[1].toLowerCase().includes('instagram')) {
author = authorMatch[1].trim();
}
const ogDesc = $('meta[property="og:description"]').attr('content') ||
$('meta[name="description"]').attr('content') || '';
if (ogDesc) {
caption = cleanInstagramCaption(ogDesc);
}
// Inspect script tags or embedded ld+json
$('script[type="application/ld+json"]').each((_, el) => {
try {
const json = JSON.parse($(el).html());
if (json.articleBody || json.description) {
const body = json.articleBody || json.description;
if (body.length > caption.length) {
caption = cleanInstagramCaption(body);
}
}
if (json.author?.name && !author) {
author = json.author.name;
}
if (json.image && !cover) {
cover = Array.isArray(json.image) ? json.image[0] : json.image;
}
} catch (e) {
// ignore JSON parse error
}
});
}
log.info('[Instagram] Extracted metadata - Caption length:', caption.length, 'Author:', author, 'Has cover:', !!cover);
if (caption && caption.length > 20) {
const heuristicResult = parseRecipeCaptionHeuristic(caption, author, cover, canonicalUrl);
if (heuristicResult && heuristicResult.ingredients && heuristicResult.ingredients.length > 0) {
heuristicResult._scrapingMethod = 'instagram-caption-heuristic';
heuristicResult.rawCaption = caption;
heuristicResult.author = author;
heuristicResult.suggestAiPolish = true;
return heuristicResult;
}
}
// If automatic caption extraction was blocked or did not contain readable ingredients,
// we trigger the seamless caption-paste fallback with the extracted cover and author!
const wallError = new Error(
caption
? 'Instagram caption was found, but does not appear to contain an ingredients list.'
: 'Instagram requires login to view post captions directly.'
);
wallError.requiresCaption = true;
wallError.isInstagram = true;
wallError.author = author;
wallError.coverImage = cover;
wallError.canonicalUrl = canonicalUrl;
throw wallError;
}
// Helper: Scrape recipe from a TikTok video URL using caption extraction and AI parsing
async function scrapeTikTokRecipe(inputUrl) {
log.info('[TikTok] Starting scrape for:', inputUrl);
// 1. Resolve redirect for shortened links (vm.tiktok.com, vt.tiktok.com)
let canonicalUrl = inputUrl;
try {
const headRes = await axios.get(inputUrl, {
maxRedirects: 5,
validateStatus: () => true,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
},
timeout: 8000
});
if (headRes.request?.res?.responseUrl) {
canonicalUrl = headRes.request.res.responseUrl;
}
} catch (e) {
log.warn('[TikTok] Redirect resolution note:', e.message);
}
// 2. Fetch page HTML (Strategy 1: Fast Axios fetch)
let html = null;
try {
const response = await axios.get(canonicalUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': 'https://www.tiktok.com/'
},
timeout: 12000
});
html = response.data;
} catch (err) {
log.warn('[TikTok] Direct fetch failed, trying Puppeteer:', err.message);
}
// Strategy 2: Puppeteer fallback if direct fetch failed or returned bot screen
if (!html || isBotChallengePage(html)) {
try {
html = await fetchWithPuppeteer(canonicalUrl);
} catch (pupErr) {
log.error('[TikTok] Puppeteer fallback failed:', pupErr.message);
}
}
if (!html) {
const blockErr = new Error('Could not access TikTok video page. Automated access was blocked.');
blockErr.requiresCaption = true;
blockErr.isTikTok = true;
blockErr.canonicalUrl = canonicalUrl;
throw blockErr;
}
let caption = '';
let author = '';
let cover = '';
// Extract from rehydration JSON
const rehydrationMatch = html.match(/