From fa9526667556a9e57c3b1e34eb90a3c55d9896ef Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 1 Nov 2026 02:23:16 -0500 Subject: [PATCH] feat: add Instagram recipe scraping and caption extraction support --- public/app.js | 159 ++++++++++++++++++++++++ public/index.html | 50 +++++++- server.js | 307 +++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 482 insertions(+), 34 deletions(-) diff --git a/public/app.js b/public/app.js index 905582b..e1a3d34 100644 --- a/public/app.js +++ b/public/app.js @@ -41,6 +41,7 @@ const elements = { urlScrapeForm: document.getElementById('url-scrape-form'), scrapeUrlInput: document.getElementById('scrape-url-input'), btnScrapeSubmit: document.getElementById('btn-scrape-submit'), + btnSocialImport: document.getElementById('btn-social-import'), btnManualAdd: document.getElementById('btn-manual-add'), btnSettings: document.getElementById('btn-settings'), settingsModal: document.getElementById('settings-modal'), @@ -276,6 +277,8 @@ function registerEvents() { // URL Form Submission elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit); + elements.btnSocialImport?.addEventListener('click', () => openSocialCaptionModal({})); + document.getElementById('social-caption-form')?.addEventListener('submit', handleSocialCaptionSubmit); // Manual Add Form Opening elements.btnManualAdd.addEventListener('click', openManualAdd); @@ -620,6 +623,21 @@ async function handleScrapeSubmit(e) { if (!response.ok) { const errorData = await response.json().catch(() => ({})); + + // Check if this is an Instagram or social link requiring caption paste + if (errorData.requiresCaption) { + hideScrapeLoading(); + openSocialCaptionModal({ + url: errorData.sourceUrl || url, + isInstagram: errorData.isInstagram, + isTikTok: errorData.isTikTok, + author: errorData.author || '', + coverImage: errorData.coverImage || '', + errorMessage: errorData.error + }); + return; + } + if (response.status === 403 || errorData.isBlocked) { throw new Error('BLOCKED'); } @@ -640,6 +658,147 @@ async function handleScrapeSubmit(e) { } } +// Open Social Caption Extractor Modal (Instagram / TikTok) +function openSocialCaptionModal(options = {}) { + const { url = '', isInstagram = false, isTikTok = false, author = '', coverImage = '' } = options; + + const titleEl = document.getElementById('social-modal-title'); + const iconEl = document.getElementById('social-modal-icon'); + const hintEl = document.getElementById('social-modal-hint'); + const urlInput = document.getElementById('social-input-url'); + const authorInput = document.getElementById('social-input-author'); + const coverInput = document.getElementById('social-input-cover'); + const captionText = document.getElementById('social-caption-text'); + const previewBox = document.getElementById('social-post-preview'); + const previewCover = document.getElementById('social-post-cover'); + const previewAuthor = document.getElementById('social-post-author'); + const previewUrl = document.getElementById('social-post-url-display'); + + const isIg = isInstagram || url.toLowerCase().includes('instagram.com') || url.toLowerCase().includes('instagr.am'); + const isTt = isTikTok || url.toLowerCase().includes('tiktok.com'); + + if (isIg) { + if (titleEl) titleEl.textContent = 'Instagram Recipe Import'; + if (iconEl) { + iconEl.setAttribute('data-lucide', 'instagram'); + iconEl.style.color = '#e1306c'; + } + if (hintEl) { + hintEl.textContent = 'Instagram restricts automated scraping of post captions. Simply copy the caption text from the Reel or post and paste it below—ForkFolio will parse the recipe and format the ingredients automatically!'; + } + } else if (isTt) { + if (titleEl) titleEl.textContent = 'TikTok Recipe Import'; + if (iconEl) { + iconEl.setAttribute('data-lucide', 'video'); + iconEl.style.color = 'var(--primary)'; + } + 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 (titleEl) titleEl.textContent = 'Social Media Recipe Import'; + if (iconEl) { + iconEl.setAttribute('data-lucide', 'sparkles'); + 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!'; + } + } + + if (urlInput) urlInput.value = url || ''; + if (authorInput) authorInput.value = author || ''; + if (coverInput) coverInput.value = coverImage || ''; + if (captionText) captionText.value = ''; + + if (previewBox) { + if (url || author || coverImage) { + previewBox.style.display = 'flex'; + if (previewCover) { + if (coverImage) { + previewCover.src = coverImage; + previewCover.style.display = 'block'; + } else { + previewCover.style.display = 'none'; + } + } + if (previewAuthor) { + previewAuthor.textContent = author ? `By ${author}` : (isIg ? 'Instagram Post' : 'Social Post'); + } + if (previewUrl) { + previewUrl.textContent = url || ''; + } + } else { + previewBox.style.display = 'none'; + } + } + + openModal('social-caption-modal'); + lucide.createIcons(); + if (captionText) { + setTimeout(() => captionText.focus(), 150); + } +} + +// Handle Social Caption Form Submission +async function handleSocialCaptionSubmit(e) { + e.preventDefault(); + const captionText = document.getElementById('social-caption-text'); + const rawCaption = captionText?.value?.trim(); + const url = document.getElementById('social-input-url')?.value?.trim() || ''; + const author = document.getElementById('social-input-author')?.value?.trim() || ''; + const coverImage = document.getElementById('social-input-cover')?.value?.trim() || ''; + const submitBtn = document.getElementById('btn-social-extract'); + + if (!rawCaption) return; + + const originalBtnHtml = submitBtn ? submitBtn.innerHTML : ''; + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = ' Extracting Recipe...'; + lucide.createIcons(); + } + + try { + const response = await fetch('/api/scrape', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ + url, + rawCaption, + author, + coverImage + }) + }); + + if (response.status === 401) { + logout(); + return; + } + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Failed to parse recipe from caption.'); + } + + closeModal('social-caption-modal'); + if (captionText) captionText.value = ''; + elements.scrapeUrlInput.value = ''; + openDraftPreview(data); + } catch (err) { + alert(err.message || 'Error parsing caption.'); + } finally { + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = originalBtnHtml; + lucide.createIcons(); + } + } +} + // User-initiated AI Polish action for draft recipe async function handleAiPolish() { if (!elements.btnAiPolish) return; diff --git a/public/index.html b/public/index.html index 95e3936..1008823 100644 --- a/public/index.html +++ b/public/index.html @@ -73,12 +73,15 @@
- +
+
+ +
+