mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
fix: sticky mobile modal footer, inline copy feedback, and PerimeterX scraper fallback
This commit is contained in:
parent
7043f1afe1
commit
72b965743e
3 changed files with 120 additions and 14 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
52
server.js
52
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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue