mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Implement Option 1: TikTok caption recipe extraction with Gemini AI and heuristic parser
This commit is contained in:
parent
50b94bb190
commit
36b8706548
2 changed files with 282 additions and 1 deletions
|
|
@ -73,7 +73,7 @@
|
||||||
<form class="url-input-form" id="url-scrape-form">
|
<form class="url-input-form" id="url-scrape-form">
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<i data-lucide="link" class="input-icon"></i>
|
<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>
|
||||||
<div class="hero-buttons-group" style="display: flex; gap: 8px;">
|
<div class="hero-buttons-group" style="display: flex; gap: 8px;">
|
||||||
<button type="submit" class="btn btn-primary" id="btn-scrape-submit">
|
<button type="submit" class="btn btn-primary" id="btn-scrape-submit">
|
||||||
|
|
|
||||||
281
server.js
281
server.js
|
|
@ -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
|
// API: Load System Settings & User Preferences
|
||||||
app.get('/api/settings', requireAuth, (req, res) => {
|
app.get('/api/settings', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2074,6 +2342,19 @@ app.post('/api/scrape', requireAuth, async (req, res) => {
|
||||||
return res.status(400).json({ error: 'URL is required' });
|
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 {
|
try {
|
||||||
let html = null;
|
let html = null;
|
||||||
let scrapingMethod = 'axios';
|
let scrapingMethod = 'axios';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue