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) {