feat: add Facebook Reels recipe extraction and social caption modal support

This commit is contained in:
Chris 2026-11-01 11:28:53 -05:00
parent fe47950936
commit fc1fcbb535
4 changed files with 264 additions and 7 deletions

View file

@ -631,6 +631,7 @@ async function handleScrapeSubmit(e) {
url: errorData.sourceUrl || url,
isInstagram: errorData.isInstagram,
isTikTok: errorData.isTikTok,
isFacebook: errorData.isFacebook,
author: errorData.author || '',
coverImage: errorData.coverImage || '',
errorMessage: errorData.error
@ -658,9 +659,9 @@ async function handleScrapeSubmit(e) {
}
}
// Open Social Caption Extractor Modal (Instagram / TikTok)
// Open Social Caption Extractor Modal (Instagram / TikTok / Facebook)
function openSocialCaptionModal(options = {}) {
const { url = '', isInstagram = false, isTikTok = false, author = '', coverImage = '' } = options;
const { url = '', isInstagram = false, isTikTok = false, isFacebook = false, author = '', coverImage = '' } = options;
const titleEl = document.getElementById('social-modal-title');
const iconEl = document.getElementById('social-modal-icon');
@ -676,6 +677,7 @@ function openSocialCaptionModal(options = {}) {
const isIg = isInstagram || url.toLowerCase().includes('instagram.com') || url.toLowerCase().includes('instagr.am');
const isTt = isTikTok || url.toLowerCase().includes('tiktok.com');
const isFb = isFacebook || url.toLowerCase().includes('facebook.com') || url.toLowerCase().includes('fb.watch') || url.toLowerCase().includes('fb.com');
if (isIg) {
if (titleEl) titleEl.textContent = 'Instagram Recipe Import';
@ -695,6 +697,15 @@ function openSocialCaptionModal(options = {}) {
if (hintEl) {
hintEl.textContent = 'TikTok video caption could not be accessed directly. Paste the video description or recipe text below to extract the recipe automatically!';
}
} else if (isFb) {
if (titleEl) titleEl.textContent = 'Facebook Reel Recipe Import';
if (iconEl) {
iconEl.setAttribute('data-lucide', 'facebook');
iconEl.style.color = '#1877F2';
}
if (hintEl) {
hintEl.textContent = 'Facebook restricts automated extraction of Reel captions and comments. Simply copy the caption text or pinned recipe comment from Facebook and paste it below—ForkFolio will parse the recipe and format the ingredients automatically!';
}
} else {
if (titleEl) titleEl.textContent = 'Social Media Recipe Import';
if (iconEl) {
@ -702,7 +713,7 @@ function openSocialCaptionModal(options = {}) {
iconEl.style.color = 'var(--primary)';
}
if (hintEl) {
hintEl.textContent = 'Paste any recipe caption or culinary text from Instagram, TikTok, or social posts below to extract and format it with AI!';
hintEl.textContent = 'Paste any recipe caption or culinary text from Instagram, Facebook, TikTok, or social posts below to extract and format it with AI!';
}
}
@ -723,7 +734,7 @@ function openSocialCaptionModal(options = {}) {
}
}
if (previewAuthor) {
previewAuthor.textContent = author ? `By ${author}` : (isIg ? 'Instagram Post' : 'Social Post');
previewAuthor.textContent = author ? `By ${author}` : (isIg ? 'Instagram Post' : isFb ? 'Facebook Reel' : 'Social Post');
}
if (previewUrl) {
previewUrl.textContent = url || '';

View file

@ -73,13 +73,13 @@
<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 recipe link, Instagram Reel, or TikTok video here..." autocomplete="off">
<input type="url" id="scrape-url-input" placeholder="Paste recipe link, Instagram, Facebook Reel, or TikTok here..." autocomplete="off">
</div>
<div class="hero-buttons-group">
<button type="submit" class="btn btn-primary" id="btn-scrape-submit">
<i data-lucide="wand-2"></i> <span>Extract Recipe</span>
</button>
<button type="button" class="btn btn-outline" id="btn-social-import" title="Import from Instagram, TikTok, or Social Caption">
<button type="button" class="btn btn-outline" id="btn-social-import" title="Import from Instagram, Facebook, TikTok, or Social Caption">
<i data-lucide="instagram"></i> <span>Social Import</span>
</button>
<input type="file" id="scan-image-input" accept="image/*" style="display: none;">
@ -628,7 +628,7 @@
</div>
</div>
<!-- SOCIAL POST CAPTION EXTRACTOR MODAL (INSTAGRAM & TIKTOK) -->
<!-- SOCIAL POST CAPTION EXTRACTOR MODAL (INSTAGRAM, FACEBOOK & TIKTOK) -->
<div class="modal-overlay" id="social-caption-modal">
<div class="modal-box max-width-md">
<div class="modal-header">

182
server.js
View file

@ -1398,6 +1398,23 @@ function isTikTokUrl(url) {
}
}
// Helper: Check if a URL belongs to Facebook (Reels, Watch, Videos)
function isFacebookUrl(url) {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase();
const isFb = host.includes('facebook.com') || host.includes('fb.watch') || host.includes('fb.com');
if (!isFb) return false;
return host.includes('fb.watch') ||
parsed.pathname.includes('/reel') ||
parsed.pathname.includes('/watch') ||
parsed.pathname.includes('/videos') ||
parsed.pathname.includes('/share/');
} catch (e) {
return false;
}
}
// Helper: Check if a URL belongs to Instagram
function isInstagramUrl(url) {
try {
@ -1418,6 +1435,24 @@ function cleanSocialCaption(raw) {
}
const cleanTikTokCaption = cleanSocialCaption;
// Helper: Clean raw Facebook caption from OpenGraph tags or quotes
function cleanFacebookCaption(raw) {
if (!raw) return '';
let text = raw.trim();
const quotedMatch = text.match(/(?:on Facebook:\s*["“]|:\s*["“])([\s\S]*?)["”]?$/i);
if (quotedMatch && quotedMatch[1]) {
text = quotedMatch[1];
} else {
const colonQuote = text.indexOf(': "');
if (colonQuote !== -1) {
text = text.slice(colonQuote + 3).replace(/["”]$/, '');
}
}
text = text.replace(/\s*\|\s*By\s+[^|]+\|\s*Facebook.*$/i, '');
text = text.replace(/\s*\|\s*Facebook.*$/i, '');
return cleanSocialCaption(text);
}
// Helper: Clean raw Instagram caption from OpenGraph tags or quotes
function cleanInstagramCaption(raw) {
if (!raw) return '';
@ -1798,6 +1833,135 @@ async function scrapeTikTokRecipe(inputUrl) {
return heuristicResult;
}
// Helper: Scrape recipe from a Facebook Reel or video URL
async function scrapeFacebookRecipe(inputUrl) {
log.info('[Facebook] Starting scrape for:', inputUrl);
let canonicalUrl = inputUrl;
let caption = '';
let author = '';
let cover = '';
// 1. Resolve redirect for shortened links (e.g., fb.watch/xyz)
try {
const headRes = await axios.get(inputUrl, {
maxRedirects: 5,
validateStatus: () => true,
headers: {
'User-Agent': 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)'
},
timeout: 8000
});
if (headRes.request?.res?.responseUrl) {
canonicalUrl = headRes.request.res.responseUrl;
}
} catch (e) {
log.warn('[Facebook] Redirect note:', e.message);
}
// 2. Fetch page HTML using crawler user agents
const crawlerUserAgents = [
'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',
'Twitterbot/1.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
];
let html = null;
for (const ua of crawlerUserAgents) {
try {
const response = await axios.get(canonicalUrl, {
headers: {
'User-Agent': ua,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9'
},
timeout: 10000,
validateStatus: (status) => status < 400 || status === 302
});
if (response.data && typeof response.data === 'string' && (response.data.includes('og:') || response.data.includes('application/ld+json'))) {
html = response.data;
break;
}
} catch (fetchErr) {
log.warn(`[Facebook] Crawler fetch failed with UA (${ua.split(' ')[0]}):`, fetchErr.message);
}
}
// 3. Extract OpenGraph and JSON-LD metadata if HTML was retrieved
if (html) {
const $ = cheerio.load(html);
cover = $('meta[property="og:image"]').attr('content') ||
$('meta[property="og:image:secure_url"]').attr('content') ||
$('meta[name="twitter:image"]').attr('content') || '';
const ogTitle = $('meta[property="og:title"]').attr('content') || $('title').text().trim() || '';
const authorMatch = ogTitle.match(/^(?:.*?on Facebook:\s*["“]?|)(.*?)(?:\s*[-–|•]\s*Facebook|\s*on Facebook|$)/i);
if (authorMatch && authorMatch[1] && !authorMatch[1].toLowerCase().includes('facebook')) {
author = authorMatch[1].trim();
}
const ogDesc = $('meta[property="og:description"]').attr('content') ||
$('meta[name="description"]').attr('content') || '';
const isBoilerplate = /log in|watch popular videos|sign up for facebook|connect with friends/i.test(ogDesc);
if (ogDesc && !isBoilerplate) {
caption = cleanFacebookCaption(ogDesc);
}
// Inspect script tags or embedded ld+json
$('script[type="application/ld+json"]').each((_, el) => {
try {
const json = JSON.parse($(el).html());
if (json.articleBody || json.description) {
const body = json.articleBody || json.description;
if (body.length > caption.length && !/log in|sign up for facebook/i.test(body)) {
caption = cleanFacebookCaption(body);
}
}
if (json.author?.name && !author) {
author = json.author.name;
}
if (json.image && !cover) {
cover = Array.isArray(json.image) ? json.image[0] : json.image;
}
if (json.thumbnailUrl && !cover) {
cover = json.thumbnailUrl;
}
} catch (e) {
// ignore JSON parse error
}
});
}
log.info('[Facebook] Extracted metadata - Caption length:', caption.length, 'Author:', author, 'Has cover:', !!cover);
if (caption && caption.length > 20) {
const heuristicResult = parseRecipeCaptionHeuristic(caption, author, cover, canonicalUrl);
if (heuristicResult && heuristicResult.ingredients && heuristicResult.ingredients.length > 0) {
heuristicResult._scrapingMethod = 'facebook-caption-heuristic';
heuristicResult.rawCaption = caption;
heuristicResult.author = author;
heuristicResult.suggestAiPolish = true;
return heuristicResult;
}
}
// Fallback to social-caption-paste modal
const wallError = new Error(
caption
? 'Facebook Reel caption was found, but does not appear to contain an ingredients list.'
: 'Facebook requires login or restricts automated access to view Reel details directly.'
);
wallError.requiresCaption = true;
wallError.isFacebook = true;
wallError.author = author;
wallError.coverImage = cover;
wallError.canonicalUrl = canonicalUrl;
throw wallError;
}
// API: Polish and format recipe using Gemini AI (User-initiated)
app.post('/api/recipes/ai-polish', requireAuth, async (req, res) => {
const { rawCaption, title, ingredients, instructions } = req.body;
@ -2709,6 +2873,24 @@ app.post('/api/scrape', requireAuth, async (req, res) => {
}
}
// Specialized Scraper: Facebook Reel & Video Recipes
if (isFacebookUrl(url)) {
try {
const fbResult = await scrapeFacebookRecipe(url);
return res.json(fbResult);
} catch (fbError) {
log.warn('[Facebook Scraper] Scrape note:', fbError.message);
return res.status(422).json({
error: fbError.message || 'Facebook Reel details could not be read automatically.',
requiresCaption: true,
isFacebook: true,
sourceUrl: url,
coverImage: fbError.coverImage || '',
author: fbError.author || ''
});
}
}
try {
let html = null;
let scrapingMethod = 'axios';

64
test_facebook_scraper.js Normal file
View file

@ -0,0 +1,64 @@
const assert = require('assert');
// 1. Test URL detection helper
function isFacebookUrl(url) {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase();
const isFb = host.includes('facebook.com') || host.includes('fb.watch') || host.includes('fb.com');
if (!isFb) return false;
return host.includes('fb.watch') ||
parsed.pathname.includes('/reel') ||
parsed.pathname.includes('/watch') ||
parsed.pathname.includes('/videos') ||
parsed.pathname.includes('/share/');
} catch (e) {
return false;
}
}
function cleanSocialCaption(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;
}
function cleanFacebookCaption(raw) {
if (!raw) return '';
let text = raw.trim();
const quotedMatch = text.match(/(?:on Facebook:\s*["“]|:\s*["“])([\s\S]*?)["”]?$/i);
if (quotedMatch && quotedMatch[1]) {
text = quotedMatch[1];
} else {
const colonQuote = text.indexOf(': "');
if (colonQuote !== -1) {
text = text.slice(colonQuote + 3).replace(/["”]$/, '');
}
}
text = text.replace(/\s*\|\s*By\s+[^|]+\|\s*Facebook.*$/i, '');
text = text.replace(/\s*\|\s*Facebook.*$/i, '');
return cleanSocialCaption(text);
}
console.log('Testing isFacebookUrl...');
assert.strictEqual(isFacebookUrl('https://www.facebook.com/reel/123456789'), true);
assert.strictEqual(isFacebookUrl('https://fb.watch/abcdef123/'), true);
assert.strictEqual(isFacebookUrl('https://m.facebook.com/reel/987654321/'), true);
assert.strictEqual(isFacebookUrl('https://www.facebook.com/watch/?v=11223344'), true);
assert.strictEqual(isFacebookUrl('https://www.facebook.com/share/r/abc123xyz/'), true);
assert.strictEqual(isFacebookUrl('https://www.instagram.com/reel/xyz123'), false);
assert.strictEqual(isFacebookUrl('https://www.allrecipes.com/recipe/123/cookies'), false);
console.log('✔ isFacebookUrl tests passed!');
console.log('\nTesting cleanFacebookCaption...');
const raw1 = 'Chef Gordon on Facebook: "Crispy Garlic Butter Steak Bites. 1 lb sirloin steak, 2 tbsp butter. Cook for 5 mins #food #steak"';
const cleaned1 = cleanFacebookCaption(raw1);
assert.strictEqual(cleaned1, 'Crispy Garlic Butter Steak Bites. 1 lb sirloin steak, 2 tbsp butter. Cook for 5 mins');
const raw2 = 'Delicious Homemade Pasta Recipe | 2 cups flour, 3 eggs | By Italian Kitchen | Facebook';
const cleaned2 = cleanFacebookCaption(raw2);
assert.strictEqual(cleaned2, 'Delicious Homemade Pasta Recipe | 2 cups flour, 3 eggs');
console.log('✔ cleanFacebookCaption tests passed!');
console.log('\nAll Facebook Reel unit tests passed successfully!');