mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Enhance TikTok heuristic parser for unstructured single-line captions
This commit is contained in:
parent
36b8706548
commit
e283f238cc
1 changed files with 74 additions and 65 deletions
139
server.js
139
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 || ''
|
||||
|
|
|
|||
Loading…
Reference in a new issue