feat: add Instagram recipe scraping and caption extraction support

This commit is contained in:
Chris 2026-11-01 02:23:16 -05:00
parent 095bd54684
commit fa95266675
3 changed files with 482 additions and 34 deletions

View file

@ -41,6 +41,7 @@ const elements = {
urlScrapeForm: document.getElementById('url-scrape-form'),
scrapeUrlInput: document.getElementById('scrape-url-input'),
btnScrapeSubmit: document.getElementById('btn-scrape-submit'),
btnSocialImport: document.getElementById('btn-social-import'),
btnManualAdd: document.getElementById('btn-manual-add'),
btnSettings: document.getElementById('btn-settings'),
settingsModal: document.getElementById('settings-modal'),
@ -276,6 +277,8 @@ function registerEvents() {
// URL Form Submission
elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit);
elements.btnSocialImport?.addEventListener('click', () => openSocialCaptionModal({}));
document.getElementById('social-caption-form')?.addEventListener('submit', handleSocialCaptionSubmit);
// Manual Add Form Opening
elements.btnManualAdd.addEventListener('click', openManualAdd);
@ -620,6 +623,21 @@ async function handleScrapeSubmit(e) {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
// Check if this is an Instagram or social link requiring caption paste
if (errorData.requiresCaption) {
hideScrapeLoading();
openSocialCaptionModal({
url: errorData.sourceUrl || url,
isInstagram: errorData.isInstagram,
isTikTok: errorData.isTikTok,
author: errorData.author || '',
coverImage: errorData.coverImage || '',
errorMessage: errorData.error
});
return;
}
if (response.status === 403 || errorData.isBlocked) {
throw new Error('BLOCKED');
}
@ -640,6 +658,147 @@ async function handleScrapeSubmit(e) {
}
}
// Open Social Caption Extractor Modal (Instagram / TikTok)
function openSocialCaptionModal(options = {}) {
const { url = '', isInstagram = false, isTikTok = false, author = '', coverImage = '' } = options;
const titleEl = document.getElementById('social-modal-title');
const iconEl = document.getElementById('social-modal-icon');
const hintEl = document.getElementById('social-modal-hint');
const urlInput = document.getElementById('social-input-url');
const authorInput = document.getElementById('social-input-author');
const coverInput = document.getElementById('social-input-cover');
const captionText = document.getElementById('social-caption-text');
const previewBox = document.getElementById('social-post-preview');
const previewCover = document.getElementById('social-post-cover');
const previewAuthor = document.getElementById('social-post-author');
const previewUrl = document.getElementById('social-post-url-display');
const isIg = isInstagram || url.toLowerCase().includes('instagram.com') || url.toLowerCase().includes('instagr.am');
const isTt = isTikTok || url.toLowerCase().includes('tiktok.com');
if (isIg) {
if (titleEl) titleEl.textContent = 'Instagram Recipe Import';
if (iconEl) {
iconEl.setAttribute('data-lucide', 'instagram');
iconEl.style.color = '#e1306c';
}
if (hintEl) {
hintEl.textContent = 'Instagram restricts automated scraping of post captions. Simply copy the caption text from the Reel or post and paste it below—ForkFolio will parse the recipe and format the ingredients automatically!';
}
} else if (isTt) {
if (titleEl) titleEl.textContent = 'TikTok Recipe Import';
if (iconEl) {
iconEl.setAttribute('data-lucide', 'video');
iconEl.style.color = 'var(--primary)';
}
if (hintEl) {
hintEl.textContent = 'TikTok video caption could not be accessed directly. Paste the video description or recipe text below to extract the recipe automatically!';
}
} else {
if (titleEl) titleEl.textContent = 'Social Media Recipe Import';
if (iconEl) {
iconEl.setAttribute('data-lucide', 'sparkles');
iconEl.style.color = 'var(--primary)';
}
if (hintEl) {
hintEl.textContent = 'Paste any recipe caption or culinary text from Instagram, TikTok, or social posts below to extract and format it with AI!';
}
}
if (urlInput) urlInput.value = url || '';
if (authorInput) authorInput.value = author || '';
if (coverInput) coverInput.value = coverImage || '';
if (captionText) captionText.value = '';
if (previewBox) {
if (url || author || coverImage) {
previewBox.style.display = 'flex';
if (previewCover) {
if (coverImage) {
previewCover.src = coverImage;
previewCover.style.display = 'block';
} else {
previewCover.style.display = 'none';
}
}
if (previewAuthor) {
previewAuthor.textContent = author ? `By ${author}` : (isIg ? 'Instagram Post' : 'Social Post');
}
if (previewUrl) {
previewUrl.textContent = url || '';
}
} else {
previewBox.style.display = 'none';
}
}
openModal('social-caption-modal');
lucide.createIcons();
if (captionText) {
setTimeout(() => captionText.focus(), 150);
}
}
// Handle Social Caption Form Submission
async function handleSocialCaptionSubmit(e) {
e.preventDefault();
const captionText = document.getElementById('social-caption-text');
const rawCaption = captionText?.value?.trim();
const url = document.getElementById('social-input-url')?.value?.trim() || '';
const author = document.getElementById('social-input-author')?.value?.trim() || '';
const coverImage = document.getElementById('social-input-cover')?.value?.trim() || '';
const submitBtn = document.getElementById('btn-social-extract');
if (!rawCaption) return;
const originalBtnHtml = submitBtn ? submitBtn.innerHTML : '';
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i data-lucide="loader-2" class="spin"></i> <span>Extracting Recipe...</span>';
lucide.createIcons();
}
try {
const response = await fetch('/api/scrape', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...getAuthHeaders()
},
body: JSON.stringify({
url,
rawCaption,
author,
coverImage
})
});
if (response.status === 401) {
logout();
return;
}
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to parse recipe from caption.');
}
closeModal('social-caption-modal');
if (captionText) captionText.value = '';
elements.scrapeUrlInput.value = '';
openDraftPreview(data);
} catch (err) {
alert(err.message || 'Error parsing caption.');
} finally {
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnHtml;
lucide.createIcons();
}
}
}
// User-initiated AI Polish action for draft recipe
async function handleAiPolish() {
if (!elements.btnAiPolish) return;

View file

@ -73,12 +73,15 @@
<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 link or TikTok video here (e.g., allrecipes.com, TikTok...)" autocomplete="off">
<input type="url" id="scrape-url-input" placeholder="Paste recipe link, Instagram Reel, or TikTok video here..." autocomplete="off">
</div>
<div class="hero-buttons-group" style="display: flex; gap: 8px;">
<button type="submit" class="btn btn-primary" id="btn-scrape-submit">
<i data-lucide="wand-2"></i> <span>Extract Recipe</span>
</button>
<button type="button" class="btn btn-outline" id="btn-social-import" title="Import from Instagram, TikTok, or Social Caption">
<i data-lucide="instagram"></i> <span>Social Import</span>
</button>
<input type="file" id="scan-image-input" accept="image/*" style="display: none;">
<button type="button" class="btn btn-accent" id="btn-scan-image" style="background-color: var(--secondary); color: var(--text-white);" title="Scan Recipe from Photo">
<i data-lucide="camera"></i> <span>Scan Photo</span>
@ -625,6 +628,51 @@
</div>
</div>
<!-- SOCIAL POST CAPTION EXTRACTOR MODAL (INSTAGRAM & TIKTOK) -->
<div class="modal-overlay" id="social-caption-modal">
<div class="modal-box max-width-md">
<div class="modal-header">
<h3 style="display: flex; align-items: center; gap: 8px;">
<i data-lucide="instagram" id="social-modal-icon" style="color: #e1306c;"></i>
<span id="social-modal-title">Instagram Recipe Import</span>
</h3>
<button class="close-btn" onclick="closeModal('social-caption-modal')"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<p class="form-help" id="social-modal-hint" style="margin-bottom: 14px; font-size: 0.9rem; line-height: 1.4;">
Instagram restricts automated scraping of post captions. Simply copy the caption text from the Reel or post and paste it below—ForkFolio will parse the recipe and format the ingredients automatically!
</p>
<div id="social-post-preview" style="display: none; align-items: center; gap: 12px; margin-bottom: 14px; padding: 10px; background: rgba(0,0,0,0.04); border-radius: 8px; border: 1px solid var(--border-color, rgba(255,255,255,0.1));">
<img id="social-post-cover" src="" alt="Post cover" style="width: 54px; height: 54px; object-fit: cover; border-radius: 6px; display: none;">
<div style="overflow: hidden;">
<div id="social-post-author" style="font-weight: 600; font-size: 0.95rem;"></div>
<div id="social-post-url-display" style="font-size: 0.8rem; color: var(--text-muted); word-break: break-all;"></div>
</div>
</div>
<form id="social-caption-form">
<input type="hidden" id="social-input-url">
<input type="hidden" id="social-input-author">
<input type="hidden" id="social-input-cover">
<div class="form-group" style="margin-bottom: 14px;">
<label for="social-caption-text" style="display: flex; justify-content: space-between; font-weight: 600;">
<span>Post Caption / Recipe Text *</span>
</label>
<textarea id="social-caption-text" rows="8" placeholder="Paste the post caption here...&#10;&#10;e.g.&#10;Creamy Garlic Tuscan Chicken&#10;Ingredients:&#10;2 chicken breasts&#10;1 cup heavy cream&#10;1 cup spinach&#10;Directions: 1. Heat pan..." required style="width: 100%; font-family: inherit; font-size: 0.9rem;"></textarea>
</div>
</form>
</div>
<div class="modal-footer" style="display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" class="btn btn-outline" onclick="closeModal('social-caption-modal')">Cancel</button>
<button type="submit" form="social-caption-form" class="btn btn-primary" id="btn-social-extract">
<i data-lucide="wand-2"></i> <span>Extract Recipe</span>
</button>
</div>
</div>
</div>
<!-- SCRAPE PREVIEW & EDIT DIALOG -->
<div class="modal-overlay" id="save-modal">
<div class="modal-box max-width-lg">

307
server.js
View file

@ -1383,6 +1383,10 @@ async function fetchWithPuppeteer(url) {
// 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 {
@ -1394,13 +1398,41 @@ function isTikTokUrl(url) {
}
}
// Helper: Clean raw TikTok caption by stripping trailing hashtags and emojis
function cleanTikTokCaption(raw) {
// 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) {
@ -1426,20 +1458,20 @@ function splitCaptionIntoPhrases(text) {
// Helper: Heuristic parser to extract recipe structure from a social video caption
function parseRecipeCaptionHeuristic(rawCaption, author, cover, sourceUrl) {
const caption = cleanTikTokCaption(rawCaption);
const caption = cleanSocialCaption(rawCaption);
if (!caption) return null;
let title = '';
let description = '';
const ingredients = [];
const instructions = [];
const ingHeaderMatch = caption.match(/\b(?:ingredients?|what you(?:'ll)? need)\s*[:\-]?/i);
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();
title = rawTitle.replace(/[:\-.]+$/, '').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]+[.!?])/);
@ -1447,30 +1479,46 @@ function parseRecipeCaptionHeuristic(rawCaption, author, cover, sourceUrl) {
remainingText = caption.slice(title.length).trim();
}
const segments = splitCaptionIntoPhrases(remainingText);
const methodHeaderMatch = remainingText.match(/\b(?:instructions?|directions?|method|steps?|preparation|how to make(?: it)?)\s*[:\-]?\s*/i);
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;
if (methodHeaderMatch) {
const ingText = remainingText.slice(0, methodHeaderMatch.index).trim();
const instText = remainingText.slice(methodHeaderMatch.index + methodHeaderMatch[0].length).trim();
for (let seg of segments) {
seg = seg.trim().replace(/^[:\-•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim();
if (!seg || seg.length < 2) continue;
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);
}
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)) {
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 {
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);
if (/(?:bowl|pan|oven|bake|mins?|minutes?|degrees?|350|mix|batter)/i.test(seg)) {
instructions.push(seg);
} else {
ingredients.push(seg);
}
}
}
}
@ -1481,8 +1529,11 @@ function parseRecipeCaptionHeuristic(rawCaption, author, cover, sourceUrl) {
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}` : 'TikTok Recipe'),
title: title || (author ? `Recipe by ${author}` : defaultFallback),
description: caption.slice(0, 160).trim(),
image: cover || '',
images: cover ? [cover] : [],
@ -1496,6 +1547,132 @@ function parseRecipeCaptionHeuristic(rawCaption, author, cover, 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);
@ -1545,7 +1722,11 @@ async function scrapeTikTokRecipe(inputUrl) {
}
if (!html) {
throw new Error('Could not access TikTok video page. Automated access was blocked.');
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 = '';
@ -1588,14 +1769,26 @@ async function scrapeTikTokRecipe(inputUrl) {
log.info('[TikTok] Extracted caption length:', caption.length, 'Author:', author);
if (!caption) {
throw new Error('No caption or description found on this TikTok video. The creator might not have written recipe details.');
const noCapErr = new Error('No caption or description found on this TikTok video. The creator might not have written recipe details.');
noCapErr.requiresCaption = true;
noCapErr.isTikTok = true;
noCapErr.author = author;
noCapErr.coverImage = cover;
noCapErr.canonicalUrl = canonicalUrl;
throw noCapErr;
}
// Fast & Local Heuristic Engine First (Zero API cost, instant execution)
log.info('[TikTok] Running local heuristic parser on video caption...');
const heuristicResult = parseRecipeCaptionHeuristic(caption, author, cover, canonicalUrl);
if (!heuristicResult.ingredients || heuristicResult.ingredients.length === 0) {
throw new Error('This TikTok caption does not appear to contain an ingredients list. The recipe might be in the audio, video comments, or on-screen text.');
const noIngErr = new Error('This TikTok caption does not appear to contain an ingredients list. The recipe might be in the audio, video comments, or on-screen text.');
noIngErr.requiresCaption = true;
noIngErr.isTikTok = true;
noIngErr.author = author;
noIngErr.coverImage = cover;
noIngErr.canonicalUrl = canonicalUrl;
throw noIngErr;
}
heuristicResult._scrapingMethod = 'tiktok-caption-heuristic';
@ -2448,11 +2641,54 @@ app.post('/api/recipes/:id/revoke-share', requireAuth, (req, res) => {
}
});
// API: Scrape Recipe from Link
// API: Scrape Recipe from Link or Social Post Caption
app.post('/api/scrape', requireAuth, async (req, res) => {
const { url } = req.body;
const { url, rawCaption, author: passedAuthor, coverImage: passedCover } = req.body;
// Direct Social Caption / Text Extraction (Instagram, TikTok, or manual paste)
if (rawCaption && typeof rawCaption === 'string' && rawCaption.trim()) {
try {
const heuristicResult = parseRecipeCaptionHeuristic(
rawCaption,
passedAuthor || '',
passedCover || '',
url || ''
);
if (!heuristicResult || !heuristicResult.ingredients || heuristicResult.ingredients.length === 0) {
return res.status(422).json({
error: 'Could not detect ingredients in the pasted text. Please make sure the text includes ingredients or try pasting additional details.'
});
}
heuristicResult._scrapingMethod = 'social-caption-paste';
heuristicResult.rawCaption = rawCaption;
heuristicResult.author = passedAuthor || '';
heuristicResult.suggestAiPolish = true;
return res.json(heuristicResult);
} catch (parseErr) {
return res.status(422).json({ error: parseErr.message || 'Failed to parse recipe from caption.' });
}
}
if (!url) {
return res.status(400).json({ error: 'URL is required' });
return res.status(400).json({ error: 'URL or caption is required' });
}
// Specialized Scraper: Instagram Post & Reel Recipes
if (isInstagramUrl(url)) {
try {
const igResult = await scrapeInstagramRecipe(url);
return res.json(igResult);
} catch (igError) {
log.warn('[Instagram Scraper] Scrape note:', igError.message);
return res.status(422).json({
error: igError.message || 'Instagram post details could not be read automatically.',
requiresCaption: true,
isInstagram: true,
sourceUrl: url,
coverImage: igError.coverImage || '',
author: igError.author || ''
});
}
}
// Specialized Scraper: TikTok Video Recipes
@ -2463,7 +2699,12 @@ app.post('/api/scrape', requireAuth, async (req, res) => {
} catch (tiktokError) {
log.error('[TikTok Scraper] Error:', tiktokError.message);
return res.status(422).json({
error: tiktokError.message || 'Failed to extract recipe from TikTok video.'
error: tiktokError.message || 'Failed to extract recipe from TikTok video.',
requiresCaption: true,
isTikTok: true,
sourceUrl: url,
coverImage: tiktokError.coverImage || '',
author: tiktokError.author || ''
});
}
}