From e283f238cc16c17054f9d8e422c939be7fcb586f Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 1 Nov 2026 01:12:50 -0400 Subject: [PATCH] Enhance TikTok heuristic parser for unstructured single-line captions --- server.js | 139 +++++++++++++++++++++++++++++------------------------- 1 file changed, 74 insertions(+), 65 deletions(-) diff --git a/server.js b/server.js index 3c8c035..17b2540 100644 --- a/server.js +++ b/server.js @@ -1360,8 +1360,39 @@ function isTikTokUrl(url) { } } +// Helper: Clean raw TikTok caption by stripping trailing hashtags and emojis +function cleanTikTokCaption(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; +} + +// 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(caption, author, cover, sourceUrl) { +function parseRecipeCaptionHeuristic(rawCaption, author, cover, sourceUrl) { + const caption = cleanTikTokCaption(rawCaption); if (!caption) return null; let title = ''; @@ -1369,84 +1400,62 @@ function parseRecipeCaptionHeuristic(caption, author, cover, sourceUrl) { 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); + const ingHeaderMatch = caption.match(/\b(?:ingredients?|what you(?:'ll)? need)\s*[:\-]?/i); + let remainingText = caption; - 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); - }); - } + if (ingHeaderMatch) { + const rawTitle = caption.slice(0, ingHeaderMatch.index).trim(); + title = rawTitle.replace(/[:\-.]+$/, '').trim(); + remainingText = caption.slice(ingHeaderMatch.index + ingHeaderMatch[0].length).trim(); } 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); + const firstSentence = caption.match(/^([^.!?\n]+[.!?])/); + title = firstSentence ? firstSentence[1].replace(/[:\-.]+$/, '').trim() : caption.slice(0, 50).trim(); + remainingText = caption.slice(title.length).trim(); + } + + 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 { - if (!title) title = line.replace(/#[\w-]+/g, '').trim(); - else description += ' ' + line.replace(/#[\w-]+/g, '').trim(); + 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 (!title) { - title = author ? `Recipe by ${author}` : 'TikTok Recipe'; + 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(); } return { - title, - description: description.trim(), + title: title || (author ? `Recipe by ${author}` : 'TikTok Recipe'), + description: caption.slice(0, 160).trim(), image: cover || '', images: cover ? [cover] : [], servings: '', prepTime: '', - cookTime: '', - totalTime: '', + cookTime, + totalTime: cookTime, ingredients, instructions, sourceUrl: sourceUrl || ''