diff --git a/public/app.js b/public/app.js index 59dfdc7..0cb0aa2 100644 --- a/public/app.js +++ b/public/app.js @@ -553,6 +553,24 @@ async function cloneActiveRecipe() { } } +async function copyTextToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.opacity = '0'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); +} + +let shareBtnResetTimeout = null; + async function shareActiveRecipe() { if (!activeRecipe) return; @@ -595,10 +613,20 @@ async function shareActiveRecipe() { if (activeRecipe.shareToken) { const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`; try { - await navigator.clipboard.writeText(shareLink); - alert(`Public share link successfully copied to your clipboard!\n\n${shareLink}`); + await copyTextToClipboard(shareLink); + if (elements.btnDetailShare) { + if (shareBtnResetTimeout) clearTimeout(shareBtnResetTimeout); + elements.btnDetailShare.classList.add('copied-feedback'); + elements.btnDetailShare.innerHTML = ` Copied!`; + lucide.createIcons(); + shareBtnResetTimeout = setTimeout(() => { + elements.btnDetailShare.classList.remove('copied-feedback'); + elements.btnDetailShare.innerHTML = ``; + lucide.createIcons(); + }, 2000); + } } catch (clipErr) { - console.warn('Clipboard write failed. Displaying fallback alert:', clipErr); + console.warn('Clipboard write failed. Displaying fallback prompt:', clipErr); prompt('Public Share Link:', shareLink); } } else { @@ -940,6 +968,14 @@ function showRecipeDetails(recipe) { activeRecipe = recipe; currentMultiplier = 1; elements.currentServingsVal.textContent = '1x'; + if (shareBtnResetTimeout) { + clearTimeout(shareBtnResetTimeout); + shareBtnResetTimeout = null; + } + if (elements.btnDetailShare) { + elements.btnDetailShare.classList.remove('copied-feedback'); + elements.btnDetailShare.innerHTML = ``; + } // Set hero cover banner image if (recipe.image) { @@ -1502,10 +1538,22 @@ function renderAdminSharedRecipes(recipes) { elements.adminSharesList.appendChild(tr); // Bind click events inside the row - document.getElementById(copyActionId).addEventListener('click', async () => { + const copyBtn = document.getElementById(copyActionId); + let rowCopyTimeout = null; + copyBtn.addEventListener('click', async () => { try { - await navigator.clipboard.writeText(shareLink); - alert(`Public share link successfully copied to your clipboard!\n\n${shareLink}`); + await copyTextToClipboard(shareLink); + if (rowCopyTimeout) clearTimeout(rowCopyTimeout); + copyBtn.innerHTML = ` Copied!`; + copyBtn.style.borderColor = 'var(--accent)'; + copyBtn.style.color = 'var(--accent)'; + lucide.createIcons(); + rowCopyTimeout = setTimeout(() => { + copyBtn.innerHTML = ` Copy Link`; + copyBtn.style.borderColor = ''; + copyBtn.style.color = ''; + lucide.createIcons(); + }, 2000); } catch (clipErr) { prompt('Public Share Link:', shareLink); } diff --git a/public/style.css b/public/style.css index f7286d5..a239f39 100644 --- a/public/style.css +++ b/public/style.css @@ -179,6 +179,17 @@ h1, h2, h3, h4, h5, h6 { background-color: var(--border-color); } +.btn-icon-round.copied-feedback { + width: auto; + padding: 0 14px; + gap: 6px; + background-color: var(--accent); + border-color: var(--accent); + color: var(--text-white); + font-size: 0.85rem; + font-weight: 600; +} + .btn-danger-hover:hover { background-color: var(--danger); color: var(--text-white); @@ -2159,6 +2170,17 @@ h1, h2, h3, h4, h5, h6 { padding: 16px 20px; } + .modal-footer { + position: sticky; + bottom: 0; + flex-shrink: 0; + z-index: 2; + padding: 12px 20px; + border-top: 1px solid var(--border-color); + background-color: var(--bg-card); + box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.15); + } + /* Restructure Detail Layout for Single-Column Bottom Sheet */ .modal-box.max-width-xl { max-height: 92vh; diff --git a/server.js b/server.js index f1dd876..b09eac6 100644 --- a/server.js +++ b/server.js @@ -1162,11 +1162,27 @@ function isBotChallengePage(html) { if (lowerHtml.includes('cf-browser-verification') || lowerHtml.includes('cf_chl_opt')) return true; if (lowerHtml.includes('checking your browser') && lowerHtml.includes('cloudflare')) return true; if (lowerHtml.includes('akamai') && lowerHtml.includes('bot manager')) return true; + if (lowerHtml.includes('px-captcha') || lowerHtml.includes('perimeterx')) return true; + if (lowerHtml.includes('access to this page has been denied')) return true; + if (lowerHtml.includes('please verify you are a human') && !lowerHtml.includes('recipeingredient')) return true; if (lowerHtml.includes('recaptcha') && !lowerHtml.includes('recipeingredient')) return true; if (html.length < 500 && !lowerHtml.includes('recipe')) return true; return false; } +// Helper: Fetch full HTML via Jina Reader HTML mode to bypass edge bot protection (PerimeterX, Cloudflare, etc.) +async function fetchWithReaderProxy(url) { + console.log('[ReaderProxy] Fetching rendered HTML for:', url); + const response = await axios.get(`https://r.jina.ai/${url}`, { + headers: { + 'X-Return-Format': 'html', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' + }, + timeout: 20000 + }); + return response.data; +} + // Helper: Fetch HTML using Puppeteer with stealth (headless browser fallback) async function fetchWithPuppeteer(url) { console.log('[Puppeteer] Launching headless browser for:', url); @@ -1327,7 +1343,7 @@ app.post('/api/scrape', requireAuth, async (req, res) => { try { let html = null; - let usedPuppeteer = false; + let scrapingMethod = 'axios'; // Strategy 1: Fast axios fetch try { @@ -1352,27 +1368,47 @@ app.post('/api/scrape', requireAuth, async (req, res) => { html = response.data; if (isBotChallengePage(html)) { - console.log('[Scraper] Axios response appears to be a bot challenge page, falling back to Puppeteer...'); + console.log('[Scraper] Axios response appears to be a bot challenge page, falling back...'); html = null; } } catch (axiosError) { const status = axiosError.response ? axiosError.response.status : 0; if (status === 403 || status === 503 || status === 429) { - console.log(`[Scraper] Axios blocked with HTTP ${status}, falling back to Puppeteer...`); + console.log(`[Scraper] Axios blocked with HTTP ${status}, falling back...`); } else { throw axiosError; } } - // Strategy 2: Puppeteer with stealth (headless browser fallback) + // Strategy 2: Reader Proxy HTML fetch (fast bypass for PerimeterX / Cloudflare edge blocks) if (!html) { try { - html = await fetchWithPuppeteer(url); - usedPuppeteer = true; + const readerHtml = await fetchWithReaderProxy(url); + if (!isBotChallengePage(readerHtml)) { + html = readerHtml; + scrapingMethod = 'reader-proxy'; + } else { + console.log('[ReaderProxy] Returned challenge page, falling back to Puppeteer...'); + } + } catch (readerError) { + console.warn('[ReaderProxy] Fallback failed, trying Puppeteer:', readerError.message); + } + } + + // Strategy 3: Puppeteer with stealth (headless browser fallback) + if (!html) { + try { + const puppeteerHtml = await fetchWithPuppeteer(url); + if (!isBotChallengePage(puppeteerHtml)) { + html = puppeteerHtml; + scrapingMethod = 'puppeteer-stealth'; + } else { + throw new Error('Blocked by bot challenge page in headless browser.'); + } } catch (puppeteerError) { console.error('[Puppeteer] Fallback also failed:', puppeteerError.message); return res.status(500).json({ - error: 'This website could not be accessed. Both standard and browser-based scraping methods failed.', + error: 'This website could not be accessed. All scraping methods were blocked.', details: puppeteerError.message, isBlocked: true }); @@ -1380,7 +1416,7 @@ app.post('/api/scrape', requireAuth, async (req, res) => { } const recipeData = parseRecipeFromHtml(html, url); - recipeData._scrapingMethod = usedPuppeteer ? 'puppeteer-stealth' : 'axios'; + recipeData._scrapingMethod = scrapingMethod; res.json(recipeData); } catch (error) {