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() {
|
async function shareActiveRecipe() {
|
||||||
if (!activeRecipe) return;
|
if (!activeRecipe) return;
|
||||||
|
|
||||||
|
|
@ -595,10 +613,20 @@ async function shareActiveRecipe() {
|
||||||
if (activeRecipe.shareToken) {
|
if (activeRecipe.shareToken) {
|
||||||
const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`;
|
const shareLink = `${window.location.origin}/share.html?token=${activeRecipe.shareToken}`;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareLink);
|
await copyTextToClipboard(shareLink);
|
||||||
alert(`Public share link successfully copied to your clipboard!\n\n${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) {
|
} 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);
|
prompt('Public Share Link:', shareLink);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -940,6 +968,14 @@ function showRecipeDetails(recipe) {
|
||||||
activeRecipe = recipe;
|
activeRecipe = recipe;
|
||||||
currentMultiplier = 1;
|
currentMultiplier = 1;
|
||||||
elements.currentServingsVal.textContent = '1x';
|
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
|
// Set hero cover banner image
|
||||||
if (recipe.image) {
|
if (recipe.image) {
|
||||||
|
|
@ -1502,10 +1538,22 @@ function renderAdminSharedRecipes(recipes) {
|
||||||
elements.adminSharesList.appendChild(tr);
|
elements.adminSharesList.appendChild(tr);
|
||||||
|
|
||||||
// Bind click events inside the row
|
// 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 {
|
try {
|
||||||
await navigator.clipboard.writeText(shareLink);
|
await copyTextToClipboard(shareLink);
|
||||||
alert(`Public share link successfully copied to your clipboard!\n\n${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) {
|
} catch (clipErr) {
|
||||||
prompt('Public Share Link:', shareLink);
|
prompt('Public Share Link:', shareLink);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,17 @@ h1, h2, h3, h4, h5, h6 {
|
||||||
background-color: var(--border-color);
|
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 {
|
.btn-danger-hover:hover {
|
||||||
background-color: var(--danger);
|
background-color: var(--danger);
|
||||||
color: var(--text-white);
|
color: var(--text-white);
|
||||||
|
|
@ -2159,6 +2170,17 @@ h1, h2, h3, h4, h5, h6 {
|
||||||
padding: 16px 20px;
|
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 */
|
/* Restructure Detail Layout for Single-Column Bottom Sheet */
|
||||||
.modal-box.max-width-xl {
|
.modal-box.max-width-xl {
|
||||||
max-height: 92vh;
|
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('cf-browser-verification') || lowerHtml.includes('cf_chl_opt')) return true;
|
||||||
if (lowerHtml.includes('checking your browser') && lowerHtml.includes('cloudflare')) 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('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 (lowerHtml.includes('recaptcha') && !lowerHtml.includes('recipeingredient')) return true;
|
||||||
if (html.length < 500 && !lowerHtml.includes('recipe')) return true;
|
if (html.length < 500 && !lowerHtml.includes('recipe')) return true;
|
||||||
return false;
|
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)
|
// Helper: Fetch HTML using Puppeteer with stealth (headless browser fallback)
|
||||||
async function fetchWithPuppeteer(url) {
|
async function fetchWithPuppeteer(url) {
|
||||||
console.log('[Puppeteer] Launching headless browser for:', url);
|
console.log('[Puppeteer] Launching headless browser for:', url);
|
||||||
|
|
@ -1327,7 +1343,7 @@ app.post('/api/scrape', requireAuth, async (req, res) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let html = null;
|
let html = null;
|
||||||
let usedPuppeteer = false;
|
let scrapingMethod = 'axios';
|
||||||
|
|
||||||
// Strategy 1: Fast axios fetch
|
// Strategy 1: Fast axios fetch
|
||||||
try {
|
try {
|
||||||
|
|
@ -1352,27 +1368,47 @@ app.post('/api/scrape', requireAuth, async (req, res) => {
|
||||||
html = response.data;
|
html = response.data;
|
||||||
|
|
||||||
if (isBotChallengePage(html)) {
|
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;
|
html = null;
|
||||||
}
|
}
|
||||||
} catch (axiosError) {
|
} catch (axiosError) {
|
||||||
const status = axiosError.response ? axiosError.response.status : 0;
|
const status = axiosError.response ? axiosError.response.status : 0;
|
||||||
if (status === 403 || status === 503 || status === 429) {
|
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 {
|
} else {
|
||||||
throw axiosError;
|
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) {
|
if (!html) {
|
||||||
try {
|
try {
|
||||||
html = await fetchWithPuppeteer(url);
|
const readerHtml = await fetchWithReaderProxy(url);
|
||||||
usedPuppeteer = true;
|
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) {
|
} catch (puppeteerError) {
|
||||||
console.error('[Puppeteer] Fallback also failed:', puppeteerError.message);
|
console.error('[Puppeteer] Fallback also failed:', puppeteerError.message);
|
||||||
return res.status(500).json({
|
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,
|
details: puppeteerError.message,
|
||||||
isBlocked: true
|
isBlocked: true
|
||||||
});
|
});
|
||||||
|
|
@ -1380,7 +1416,7 @@ app.post('/api/scrape', requireAuth, async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipeData = parseRecipeFromHtml(html, url);
|
const recipeData = parseRecipeFromHtml(html, url);
|
||||||
recipeData._scrapingMethod = usedPuppeteer ? 'puppeteer-stealth' : 'axios';
|
recipeData._scrapingMethod = scrapingMethod;
|
||||||
|
|
||||||
res.json(recipeData);
|
res.json(recipeData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue