Enhance TikTok heuristic parser for unstructured single-line captions

This commit is contained in:
Chris 2026-11-01 01:12:50 -04:00
parent 36b8706548
commit e283f238cc

139
server.js
View file

@ -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 // 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; if (!caption) return null;
let title = ''; let title = '';
@ -1369,84 +1400,62 @@ function parseRecipeCaptionHeuristic(caption, author, cover, sourceUrl) {
const ingredients = []; const ingredients = [];
const instructions = []; const instructions = [];
const ingMatch = caption.match(/\b(?:ingredients?|what you(?:'ll)? need)\b/i); const ingHeaderMatch = caption.match(/\b(?:ingredients?|what you(?:'ll)? need)\s*[:\-]?/i);
const instMatch = caption.match(/\b(?:instructions?|directions?|method|steps?)\b/i); let remainingText = caption;
if (ingMatch && instMatch && instMatch.index > ingMatch.index) { if (ingHeaderMatch) {
const introRaw = caption.slice(0, ingMatch.index).trim(); const rawTitle = caption.slice(0, ingHeaderMatch.index).trim();
const ingRaw = caption.slice(ingMatch.index + ingMatch[0].length, instMatch.index).trim(); title = rawTitle.replace(/[:\-.]+$/, '').trim();
const instRaw = caption.slice(instMatch.index + instMatch[0].length).trim(); remainingText = caption.slice(ingHeaderMatch.index + ingHeaderMatch[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 { } else {
const lines = caption.split(/\r?\n/).map(l => l.trim()).filter(Boolean); const firstSentence = caption.match(/^([^.!?\n]+[.!?])/);
let currentSection = 'intro'; title = firstSentence ? firstSentence[1].replace(/[:\-.]+$/, '').trim() : caption.slice(0, 50).trim();
for (const line of lines) { remainingText = caption.slice(title.length).trim();
if (/^(?:ingredients?|what you need)/i.test(line)) { }
currentSection = 'ingredients';
continue; const segments = splitCaptionIntoPhrases(remainingText);
}
if (/^(?:instructions?|directions?|method|steps?)/i.test(line)) { 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;
currentSection = 'instructions'; 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;
continue;
} for (let seg of segments) {
if (currentSection === 'ingredients') { seg = seg.trim().replace(/^[:\-•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim();
const cleaned = line.replace(/^[•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim(); if (!seg || seg.length < 2) continue;
if (cleaned && !cleaned.startsWith('#')) ingredients.push(cleaned);
} else if (currentSection === 'instructions') { if (instructionStarters.test(seg) || /^\d+[\.\)]\s+/.test(seg)) {
const cleaned = line.replace(/^\d+[\.\)]\s*/, '').replace(/^[•◦⁃∙*\-+]\s*/, '').replace(/#[\w-]+/g, '').trim(); instructions.push(seg.replace(/^\d+[\.\)]\s*/, ''));
if (cleaned && !cleaned.startsWith('#')) instructions.push(cleaned); } else if (isIngredientPattern.test(seg)) {
if (/batter|pan/i.test(seg)) {
instructions.push(seg);
} else { } else {
if (!title) title = line.replace(/#[\w-]+/g, '').trim(); ingredients.push(seg);
else description += ' ' + line.replace(/#[\w-]+/g, '').trim(); }
} 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) { let cookTime = '';
title = author ? `Recipe by ${author}` : 'TikTok Recipe'; 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 { return {
title, title: title || (author ? `Recipe by ${author}` : 'TikTok Recipe'),
description: description.trim(), description: caption.slice(0, 160).trim(),
image: cover || '', image: cover || '',
images: cover ? [cover] : [], images: cover ? [cover] : [],
servings: '', servings: '',
prepTime: '', prepTime: '',
cookTime: '', cookTime,
totalTime: '', totalTime: cookTime,
ingredients, ingredients,
instructions, instructions,
sourceUrl: sourceUrl || '' sourceUrl: sourceUrl || ''