fix: sticky mobile modal footer, inline copy feedback, and PerimeterX scraper fallback

This commit is contained in:
Chris 2026-10-29 14:52:27 -04:00
parent 7043f1afe1
commit 72b965743e
3 changed files with 120 additions and 14 deletions

View file

@ -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 = `<i data-lucide="check"></i> <span>Copied!</span>`;
lucide.createIcons();
shareBtnResetTimeout = setTimeout(() => {
elements.btnDetailShare.classList.remove('copied-feedback');
elements.btnDetailShare.innerHTML = `<i data-lucide="share-2"></i>`;
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 = `<i data-lucide="share-2"></i>`;
}
// 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 = `<i data-lucide="check" style="width: 12px; height: 12px;"></i> Copied!`;
copyBtn.style.borderColor = 'var(--accent)';
copyBtn.style.color = 'var(--accent)';
lucide.createIcons();
rowCopyTimeout = setTimeout(() => {
copyBtn.innerHTML = `<i data-lucide="copy" style="width: 12px; height: 12px;"></i> Copy Link`;
copyBtn.style.borderColor = '';
copyBtn.style.color = '';
lucide.createIcons();
}, 2000);
} catch (clipErr) {
prompt('Public Share Link:', shareLink);
}

View file

@ -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;

View file

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