Implement Option 1: TikTok caption recipe extraction with Gemini AI and heuristic parser

This commit is contained in:
Chris 2026-11-01 01:05:19 -04:00
parent 50b94bb190
commit 36b8706548
2 changed files with 282 additions and 1 deletions

View file

@ -73,7 +73,7 @@
<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...)" autocomplete="off">
<input type="url" id="scrape-url-input" placeholder="Paste cooking link or TikTok video here (e.g., allrecipes.com, TikTok...)" autocomplete="off">
</div>
<div class="hero-buttons-group" style="display: flex; gap: 8px;">
<button type="submit" class="btn btn-primary" id="btn-scrape-submit">

281
server.js
View file

@ -1345,6 +1345,274 @@ async function fetchWithPuppeteer(url) {
}
}
// -------------------------------------------------------------
// TikTok Recipe Extraction (Option 1: Caption & Metadata Extraction)
// -------------------------------------------------------------
// 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: Heuristic parser to extract recipe structure from a social video caption
function parseRecipeCaptionHeuristic(caption, author, cover, sourceUrl) {
if (!caption) return null;
let title = '';
let description = '';
const ingredients = [];
const instructions = [];
const ingMatch = caption.match(/\b(?:ingredients?|what you(?:'ll)? need)\b/i);
const instMatch = caption.match(/\b(?:instructions?|directions?|method|steps?)\b/i);
if (ingMatch && instMatch && instMatch.index > ingMatch.index) {
const introRaw = caption.slice(0, ingMatch.index).trim();
const ingRaw = caption.slice(ingMatch.index + ingMatch[0].length, instMatch.index).trim();
const instRaw = caption.slice(instMatch.index + instMatch[0].length).trim();
description = introRaw.replace(/#[\w-]+/g, '').trim();
const firstSentence = description.match(/^([^.!?\n]+[.!?])/);
title = firstSentence ? firstSentence[1].replace(/[:\-.]+$/, '').trim() : (description.slice(0, 60) || 'TikTok Recipe');
// Parse Ingredients (handles newlines OR inline quantity splits)
if (ingRaw.includes('\n')) {
ingRaw.split(/\r?\n/).forEach(line => {
const cleaned = line.replace(/^[•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim();
if (cleaned) ingredients.push(cleaned);
});
} else {
const splitRegex = /(?<=[a-zA-Z\)\,\.\!])\s+(?=(?:\d+|[¼½¾⅓⅔⅛⅜⅝⅞]))/;
const parts = ingRaw.split(new RegExp(splitRegex, 'g')).map(p => p.trim()).filter(Boolean);
parts.forEach(part => {
const cleaned = part.replace(/^[•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim();
if (cleaned.length > 2) ingredients.push(cleaned);
});
}
// Parse Instructions
if (instRaw.includes('\n')) {
instRaw.split(/\r?\n/).forEach(line => {
const cleaned = line.replace(/^\d+[\.\)]\s*/, '').replace(/^[•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim();
if (cleaned) instructions.push(cleaned);
});
} else {
const stepRegex = /(?:\b\d+[\.\)]\s+)/g;
const steps = instRaw.split(stepRegex).map(s => s.replace(/#[\w-]+/g, '').trim()).filter(Boolean);
steps.forEach(step => {
if (step.length > 3) instructions.push(step);
});
}
} else {
const lines = caption.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
let currentSection = 'intro';
for (const line of lines) {
if (/^(?:ingredients?|what you need)/i.test(line)) {
currentSection = 'ingredients';
continue;
}
if (/^(?:instructions?|directions?|method|steps?)/i.test(line)) {
currentSection = 'instructions';
continue;
}
if (currentSection === 'ingredients') {
const cleaned = line.replace(/^[•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim();
if (cleaned && !cleaned.startsWith('#')) ingredients.push(cleaned);
} else if (currentSection === 'instructions') {
const cleaned = line.replace(/^\d+[\.\)]\s*/, '').replace(/^[•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim();
if (cleaned && !cleaned.startsWith('#')) instructions.push(cleaned);
} else {
if (!title) title = line.replace(/#[\w-]+/g, '').trim();
else description += ' ' + line.replace(/#[\w-]+/g, '').trim();
}
}
}
if (!title) {
title = author ? `Recipe by ${author}` : 'TikTok Recipe';
}
return {
title,
description: description.trim(),
image: cover || '',
images: cover ? [cover] : [],
servings: '',
prepTime: '',
cookTime: '',
totalTime: '',
ingredients,
instructions,
sourceUrl: sourceUrl || ''
};
}
// Helper: Scrape recipe from a TikTok video URL using caption extraction and AI parsing
async function scrapeTikTokRecipe(inputUrl) {
console.log('[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) {
console.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) {
console.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) {
console.error('[TikTok] Puppeteer fallback failed:', pupErr.message);
}
}
if (!html) {
throw new Error('Could not access TikTok video page. Automated access was blocked.');
}
let caption = '';
let author = '';
let cover = '';
// Extract from rehydration JSON
const rehydrationMatch = html.match(/<script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"[^>]*>(.*?)<\/script>/s);
if (rehydrationMatch) {
try {
const data = JSON.parse(rehydrationMatch[1]);
const item = data.__DEFAULT_SCOPE__?.['webapp.video-detail']?.itemInfo?.itemStruct;
if (item) {
caption = item.desc || '';
author = item.author?.nickname || item.author?.uniqueId || '';
cover = item.video?.cover || item.video?.dynamicCover || '';
}
} catch (parseErr) {
console.warn('[TikTok] Failed to parse rehydration JSON:', parseErr.message);
}
}
// Fallback to meta tags / DOM selectors
if (!caption) {
const $ = cheerio.load(html);
caption = $('[data-e2e="browse-video-desc"]').text().trim() ||
$('meta[property="og:description"]').attr('content') ||
$('meta[name="description"]').attr('content') || '';
if (caption.includes('Visit TikTok to discover')) caption = '';
}
if (!cover) {
const $ = cheerio.load(html);
cover = $('meta[property="og:image"]').attr('content') || '';
}
if (!author) {
const $ = cheerio.load(html);
author = $('[data-e2e="browser-nickname"]').text().trim() ||
$('[data-e2e="user-title"]').text().trim() || '';
}
console.log('[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.');
}
// 3. Try Gemini AI structuring if API key is configured
const settingsRow = db.prepare('SELECT geminiApiKey FROM settings LIMIT 1').get();
const geminiKey = settingsRow?.geminiApiKey?.trim();
const isPlaceholder = !geminiKey || geminiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE';
if (!isPlaceholder) {
try {
console.log('[TikTok] Attempting Gemini AI parsing...');
const promptText = `You are a culinary expert assistant for a recipe app. Extract and structure the recipe from this TikTok video caption. If prep time, cook time, or servings are not explicitly stated, estimate reasonably or leave as empty string. Clean up any hashtag clutter, emojis, or sponsorship notes from the title and instructions.
Creator: ${author || 'TikTok Chef'}
Caption:
"""
${caption}
"""
The output MUST be a valid JSON object matching this schema:
{
"title": "Recipe Name",
"description": "Short description of the recipe",
"servings": "e.g., 4",
"prepTime": "e.g., 10 mins",
"cookTime": "e.g., 20 mins",
"totalTime": "e.g., 30 mins",
"ingredients": ["ingredient line 1", "ingredient line 2"],
"instructions": ["Step 1", "Step 2"]
}
Return ONLY raw JSON without markdown code fences.`;
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${geminiKey}`;
const aiRes = await axios.post(geminiUrl, {
contents: [{ parts: [{ text: promptText }] }],
generationConfig: { responseMimeType: "application/json" }
}, {
headers: { 'Content-Type': 'application/json' },
timeout: 15000
});
if (aiRes.data && aiRes.data.candidates?.[0]?.content?.parts?.[0]?.text) {
const rawText = aiRes.data.candidates[0].content.parts[0].text;
const parsed = JSON.parse(rawText);
if (Array.isArray(parsed.ingredients) && parsed.ingredients.length > 0) {
parsed.image = cover || '';
parsed.images = cover ? [cover] : [];
parsed.sourceUrl = canonicalUrl;
parsed._scrapingMethod = 'tiktok-gemini-ai';
return parsed;
}
}
} catch (aiErr) {
console.warn('[TikTok] Gemini parsing note (falling back to heuristic):', aiErr.response?.data?.error?.message || aiErr.message);
}
}
// 4. Heuristic Fallback Parser
console.log('[TikTok] Using heuristic caption parser...');
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.');
}
heuristicResult._scrapingMethod = 'tiktok-caption-heuristic';
return heuristicResult;
}
// API: Load System Settings & User Preferences
app.get('/api/settings', requireAuth, (req, res) => {
try {
@ -2074,6 +2342,19 @@ app.post('/api/scrape', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'URL is required' });
}
// Specialized Scraper: TikTok Video Recipes
if (isTikTokUrl(url)) {
try {
const recipeData = await scrapeTikTokRecipe(url);
return res.json(recipeData);
} catch (tiktokError) {
console.error('[TikTok Scraper] Error:', tiktokError.message);
return res.status(422).json({
error: tiktokError.message || 'Failed to extract recipe from TikTok video.'
});
}
}
try {
let html = null;
let scrapingMethod = 'axios';