您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Extract main article using Readability + heuristics, strong filtering, cover selection (detected/upload/URL), mobile-friendly UI, draggable small button, generates EPUB with images. Tampermonkey compatible.
// ==UserScript== // @name Webpage to EPUB — Mobile optimized // @namespace http://tampermonkey.net/ // @version 7.0 // @description Extract main article using Readability + heuristics, strong filtering, cover selection (detected/upload/URL), mobile-friendly UI, draggable small button, generates EPUB with images. Tampermonkey compatible. // @author Akhlak Ur Rahman // @license MIT // @match *://*/* // @grant none // @require https://unpkg.com/@mozilla/[email protected]/Readability.js // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js // ==/UserScript== (function () { 'use strict'; /************************************************************************ * CONFIG ************************************************************************/ const CONFIG = { minImageArea: 1200 * 300, // minimal pixel area to consider an image candidate corsProxy: 'https://cors.bridged.cc/', // fallback proxy if CORS blocks image fetch; set to null to disable publisher: 'Saved with Web→EPUB', language: 'en', maxImageCandidates: 18, debug: false, buttonSize: 44, // px (draggable) persistButtonPositionKey: 'we_epub_btn_pos_v1', tapMaxMovement: 7, // px: movement threshold to consider a tap vs drag tapMaxDuration: 300 // ms: max duration for a tap }; /************************************************************************ * HELPERS ************************************************************************/ function dbg(...args) { if (CONFIG.debug) console.log('[Web→EPUB]', ...args); } function uid(prefix = '') { return prefix + Math.random().toString(36).slice(2, 9); } function safeFilename(name) { return (name || 'webpage').replace(/[\/\\?%*:|"<>]/g, '_').substr(0, 200); } // robust cross-browser UUID generator fallback function generateUUID() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { try { return crypto.randomUUID(); } catch (e) { /* fallback below */ } } // fallback implementation return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } function createEl(html) { const div = document.createElement('div'); div.innerHTML = html.trim(); return div.firstChild; } function escapeXml(str) { if (!str) return ''; return str.replace(/[<>&'"]/g, c => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]); } async function fetchAsArrayBuffer(url, useProxyIfFail = true) { try { const res = await fetch(url); if (!res.ok) throw new Error('Fetch failed ' + res.status); return await res.arrayBuffer(); } catch (err) { dbg('Direct fetch failed for', url, err); if (useProxyIfFail && CONFIG.corsProxy) { try { // If proxy looks like it expects full URL appended directly, don't add extra slash const proxyPrefix = CONFIG.corsProxy; const sep = proxyPrefix.endsWith('/') ? '' : '/'; const proxyUrl = proxyPrefix + sep + url; const res2 = await fetch(proxyUrl); if (!res2.ok) throw new Error('Proxy fetch failed ' + res2.status); return await res2.arrayBuffer(); } catch (err2) { dbg('Proxy fetch failed', err2); throw err2; } } else { throw err; } } } /************************************************************************ * STYLES + UI ************************************************************************/ const FLOAT_ID = 'we-epub-float-v2'; const MODAL_ID = 'we-epub-modal-v2'; function insertStyles() { if (document.getElementById('we-epub-styles')) return; const style = document.createElement('style'); style.id = 'we-epub-styles'; style.textContent = ` #${FLOAT_ID} { position: fixed; width: ${CONFIG.buttonSize}px; height: ${CONFIG.buttonSize}px; border-radius: 50%; background: linear-gradient(135deg,#2b8cff,#6a5cff); box-shadow: 0 6px 18px rgba(0,0,0,0.28); display:flex; align-items:center; justify-content:center; z-index:2147483646; color:white; font-weight:700; font-family:system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; -webkit-tap-highlight-color: transparent; touch-action: none; user-select:none; } #${FLOAT_ID} .icon { font-size: 16px; transform: translateY(-1px); pointer-events:none; } #${FLOAT_ID}:active { transform: scale(0.98); } /* Modal */ #${MODAL_ID} { position: fixed; left:0; right:0; top:0; bottom:0; z-index:2147483647; display:none; align-items:stretch; justify-content:center; background: rgba(0,0,0,0.35); -webkit-overflow-scrolling: touch; } #${MODAL_ID} .panel { margin:auto; width: min(980px, 96%); max-height: 96vh; background: #fff; border-radius: 12px; overflow:auto; padding: 12px; box-shadow: 0 12px 30px rgba(0,0,0,0.3); display:flex; flex-direction:column; } #${MODAL_ID} header { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:8px;} #${MODAL_ID} header h2 { margin:0; font-size:15px; } #${MODAL_ID} .close-btn { background:transparent; border:none; font-size:20px; } #${MODAL_ID} .content { flex:1 1 auto; overflow:auto; padding:6px 2px; } #${MODAL_ID} .images-grid { display:flex; flex-wrap:wrap; gap:8px; } #${MODAL_ID} .img-card { border:1px solid #e6e6e6; border-radius:8px; padding:6px; width: calc(50% - 8px); box-sizing:border-box; text-align:center; font-size:12px; } @media(min-width:520px){ #${MODAL_ID} .img-card { width: calc(33.333% - 8px); } } #${MODAL_ID} .img-card img { max-width:100%; height:120px; object-fit:cover; border-radius:6px; display:block; margin:0 auto 6px; } #${MODAL_ID} .controls { display:flex; flex-direction:column; gap:8px; margin-top:8px; } #${MODAL_ID} .actions { display:flex; gap:8px; justify-content: flex-end; margin-top:12px; } #${MODAL_ID} button.btn { padding:8px 12px; border-radius:8px; border: none; background:#2b8cff; color: white; font-weight:600; font-size:14px; } #${MODAL_ID} button.ghost { background:transparent; color:#444; border:1px solid #ddd; } #${MODAL_ID} input[type="file"] { display:block; } #${MODAL_ID} label.small { font-size:12px; color:#666; } #${MODAL_ID} .meta-row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; } #${MODAL_ID} .progress { height:6px; background:#eee; border-radius:6px; overflow:hidden; } #${MODAL_ID} .progress > i { display:block; height:100%; width:0%; background:linear-gradient(90deg,#2b8cff,#6a5cff); } #${MODAL_ID} input[type="text"], input[type="url"] { padding:8px; border-radius:6px; border:1px solid #ddd; width:100%; box-sizing:border-box;} #${MODAL_ID} .preview { border:1px solid #eee; padding:8px; border-radius:8px; max-height:220px; overflow:auto; background:#fafafa; font-size:14px; color:#111; } `; document.head.appendChild(style); } /************************************************************************ * Floating Button (small + draggable + tap detection) ************************************************************************/ function addFloatingButton() { if (document.getElementById(FLOAT_ID)) return; insertStyles(); const btn = document.createElement('div'); btn.id = FLOAT_ID; btn.innerHTML = `<span class="icon">EP</span>`; // initial placement: try load from storage const saved = localStorage.getItem(CONFIG.persistButtonPositionKey); if (saved) { try { const pos = JSON.parse(saved); if (pos.left && pos.top) { btn.style.left = pos.left; btn.style.top = pos.top; btn.style.position = 'fixed'; } else { positionDefault(btn); } } catch (e) { positionDefault(btn); } } else { positionDefault(btn); } document.body.appendChild(btn); // Drag + Tap handling let dragging = false; let startX = 0, startY = 0, origLeft = 0, origTop = 0; let touchStartTime = 0; let moved = false; let isPointerDownOnButton = false; // NEW FLAG function pointerStart(clientX, clientY) { const rect = btn.getBoundingClientRect(); origLeft = rect.left; origTop = rect.top; startX = clientX; startY = clientY; touchStartTime = Date.now(); moved = false; } function pointerMove(clientX, clientY) { const dx = clientX - startX; const dy = clientY - startY; if (Math.abs(dx) > CONFIG.tapMaxMovement || Math.abs(dy) > CONFIG.tapMaxMovement) { moved = true; dragging = true; const left = origLeft + dx; const top = origTop + dy; btn.style.left = Math.max(4, Math.min(window.innerWidth - btn.offsetWidth - 4, left)) + 'px'; btn.style.top = Math.max(4, Math.min(window.innerHeight - btn.offsetHeight - 4, top)) + 'px'; btn.style.right = ''; btn.style.bottom = ''; btn.style.position = 'fixed'; } } function pointerEnd() { const duration = Date.now() - touchStartTime; if (!moved && duration <= CONFIG.tapMaxDuration) { openModal(); // treat as tap } else { // save position after drag const rect = btn.getBoundingClientRect(); try { localStorage.setItem(CONFIG.persistButtonPositionKey, JSON.stringify({ left: rect.left + 'px', top: rect.top + 'px' })); } catch (e) { /* ignore */ } } dragging = false; moved = false; } // Touch events btn.addEventListener('touchstart', (e) => { const t = e.touches[0]; isPointerDownOnButton = true; // NEW pointerStart(t.clientX, t.clientY); e.stopPropagation(); }, { passive: false }); document.addEventListener('touchmove', (e) => { if (!isPointerDownOnButton) return; // NEW CHECK if (typeof e.touches === 'undefined' || e.touches.length === 0) return; const t = e.touches[0]; pointerMove(t.clientX, t.clientY); if (dragging) e.preventDefault(); }, { passive: false }); document.addEventListener('touchend', () => { if (!isPointerDownOnButton) return; // NEW CHECK pointerEnd(); isPointerDownOnButton = false; // RESET }); // Mouse events btn.addEventListener('mousedown', (e) => { isPointerDownOnButton = true; // NEW pointerStart(e.clientX, e.clientY); e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isPointerDownOnButton) return; // NEW CHECK pointerMove(e.clientX, e.clientY); }); document.addEventListener('mouseup', () => { if (!isPointerDownOnButton) return; // NEW CHECK pointerEnd(); isPointerDownOnButton = false; // RESET }); // Fallback click (keyboard/accessibility) btn.addEventListener('click', (e) => { if (!moved) openModal(); }); } function positionDefault(el) { el.style.right = '14px'; el.style.bottom = '14px'; el.style.position = 'fixed'; } /************************************************************************ * Modal UI ************************************************************************/ function addModal() { if (document.getElementById(MODAL_ID)) return; insertStyles(); const modal = createEl(` <div id="${MODAL_ID}"> <div class="panel" role="dialog" aria-modal="true"> <header> <h2>Create EPUB — ${location.hostname.replace(/^www\\./,'')}</h2> <div> <button class="close-btn" title="Close">×</button> </div> </header> <div class="content"> <div class="meta-row"> <div style="flex:1"> <label class="small">Title</label> <input id="we-title" type="text" placeholder="Title"> </div> <div style="width:140px"> <label class="small">Author</label> <input id="we-author" type="text" placeholder="Author"> </div> </div> <div style="margin-top:10px"> <label class="small">Detected article preview</label> <div id="we-preview" class="preview"></div> </div> <div style="margin-top:10px"> <label class="small">Detected images (pick one for cover)</label> <div class="images-grid" id="we-images"></div> <div style="margin-top:8px" class="controls"> <label class="small">Or upload a local image for cover</label> <input id="we-cover-upload" type="file" accept="image/*"> <label class="small">Or paste image URL</label> <input id="we-cover-url" type="url" placeholder="https://example.com/cover.jpg"> <div style="display:flex; gap:8px; align-items:center; margin-top:6px;"> <input id="we-use-proxy" type="checkbox" checked> <label class="small">Use proxy if CORS blocks image fetch</label> </div> </div> </div> <div style="margin-top:10px"> <label class="small">Options</label> <div style="display:flex; gap:8px; flex-wrap:wrap;"> <label><input id="we-include-images" type="checkbox" checked> Include images</label> <label><input id="we-single-chapter" type="checkbox" checked> Single chapter</label> <label title="Remove common noise like author boxes"> <input id="we-strong-filter" type="checkbox" checked> Strong filtering</label> </div> </div> <div style="margin-top:10px"> <div class="progress" id="we-progress" aria-hidden="true" style="display:none"><i></i></div> <div id="we-log" style="font-size:12px;color:#666;margin-top:6px;min-height:18px;"></div> </div> </div> <div class="actions"> <button class="btn ghost" id="we-cancel">Close</button> <button class="btn" id="we-generate">Generate EPUB</button> </div> </div> </div> `); document.body.appendChild(modal); // event bindings modal.querySelector('.close-btn').addEventListener('click', closeModal); modal.querySelector('#we-cancel').addEventListener('click', closeModal); modal.addEventListener('click', (e) => { if (e.target.id === MODAL_ID) closeModal(); }); document.getElementById('we-generate').addEventListener('click', onGenerateClicked); } function openModal() { addModal(); const modal = document.getElementById(MODAL_ID); modal.style.display = 'flex'; // populate fields populatePreviewAndImages(); } function closeModal() { const modal = document.getElementById(MODAL_ID); if (modal) modal.style.display = 'none'; } /************************************************************************ * Content extraction + STRONG FILTERING ************************************************************************/ function extractArticle() { try { const docClone = document.cloneNode(true); docClone.querySelectorAll('script, style, noscript, link[rel="preload"]').forEach(n => n.remove()); const parsed = new Readability(docClone).parse(); return parsed; } catch (e) { dbg('Readability error', e); return null; } } // Compute link density and remove nodes with high link ratio (common ad/sidebar pattern) function removeHighLinkDensityNodes(container) { const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, null, false); const toRemove = []; while (walker.nextNode()) { const el = walker.currentNode; if (el.tagName && ['P','DIV','SECTION','ARTICLE','ASIDE'].includes(el.tagName)) { const links = el.querySelectorAll('a').length; const text = el.textContent || ''; const textLen = text.trim().length; if (textLen > 0 && links / (textLen / 100) > 1.2) { // heuristic: too many links relative to text toRemove.push(el); } } } toRemove.forEach(n => n.remove()); } // Remove common ad-like selectors & tiny nodes function removeNoiseSelectors(container) { const selectors = [ 'nav', 'header', 'footer', 'aside', 'form', 'iframe', 'noscript', '[role="navigation"]', '[role="complementary"]', '.advert', '.ads', '.ad', '.adsbygoogle', '[id*="ad-"]', '[class*="ad-"]', '.share', '.sharing', '.social', '.related', '.related-articles', '.promo', '.cookie', '.cookies', '.newsletter', '.subscribe', '.subscribe-box', '.breadcrumb', '.breadcrumbs', '.comments', '#comments', '.comment' ]; selectors.forEach(sel => { container.querySelectorAll(sel).forEach(n => n.remove()); }); // Remove tiny blocks (under 30 chars) that don't contain images container.querySelectorAll('div, p, section').forEach(n => { const text = (n.textContent || '').trim(); if ((text.length < 30) && !n.querySelector('img') && !n.querySelector('video')) { const imgs = n.querySelectorAll('img').length; if (imgs === 0) n.remove(); } }); } function strongFilter(htmlString) { const wrapper = document.createElement('div'); wrapper.innerHTML = htmlString; removeNoiseSelectors(wrapper); removeHighLinkDensityNodes(wrapper); wrapper.querySelectorAll('*').forEach(el => { [...el.attributes].forEach(attr => { if (/^on/i.test(attr.name) || /analytics|tracking/.test(attr.name)) el.removeAttribute(attr.name); }); }); wrapper.querySelectorAll('img').forEach(img => { const src = img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-lazy') || ''; if (src && !/^https?:\/\//i.test(src)) { try { img.setAttribute('src', new URL(src, location.href).href); } catch (e) { /* ignore */ } } }); return wrapper.innerHTML; } function postProcessContent(htmlString) { const wrapper = document.createElement('div'); wrapper.innerHTML = htmlString; wrapper.querySelectorAll('form, nav, header, footer, aside, .breadcrumb, .breadcrumbs, .nav, .sidebar, .advertisement, .ads').forEach(n => n.remove()); wrapper.querySelectorAll('*').forEach(el => { [...el.attributes].forEach(attr => { if (/^on/i.test(attr.name)) el.removeAttribute(attr.name); }); }); wrapper.querySelectorAll('img').forEach(img => { const src = img.getAttribute('src') || img.getAttribute('data-src') || ''; if (src && !/^https?:\/\//i.test(src)) { try { const abs = new URL(src, location.href).href; img.setAttribute('src', abs); } catch (e) { /* ignore */ } } }); return wrapper.innerHTML; } /************************************************************************ * IMAGE DETECTION & MAPPING ************************************************************************/ async function detectImages(articleHTML) { const doc = document.implementation.createHTMLDocument('imgdetector'); doc.body.innerHTML = articleHTML || ''; const imgs = Array.from(doc.querySelectorAll('img')) .map(img => img.getAttribute('src') || img.getAttribute('data-src') || img.getAttribute('data-lazy') || '') .filter(Boolean); const pageImgs = Array.from(document.images).map(i => i.src).filter(Boolean); let candidates = Array.from(new Set([...imgs, ...pageImgs])); const checked = []; for (let i = 0; i < candidates.length && checked.length < CONFIG.maxImageCandidates; i++) { const src = candidates[i]; if (!src) continue; try { const el = document.createElement('img'); el.style.position = 'fixed'; el.style.left = '-9999px'; el.style.width = 'auto'; el.style.height = 'auto'; el.src = src; document.body.appendChild(el); const meta = await new Promise((resolve) => { let finished = false; const t = setTimeout(() => { if (!finished) { finished = true; resolve({ src, w: el.naturalWidth || 0, h: el.naturalHeight || 0, ok:false }); } }, 2500); el.onload = () => { if (!finished) { finished = true; clearTimeout(t); resolve({ src, w: el.naturalWidth, h: el.naturalHeight, ok:true }); } }; el.onerror = () => { if (!finished) { finished = true; clearTimeout(t); resolve({ src, w: el.naturalWidth || 0, h: el.naturalHeight || 0, ok:false }); } }; }); document.body.removeChild(el); if (meta.w * meta.h >= CONFIG.minImageArea) checked.push(meta.src); } catch (e) { dbg('img detect err', e); } } return checked; } /************************************************************************ * EPUB ASSEMBLY ************************************************************************/ function makeContainerXml() { return `<?xml version="1.0" encoding="UTF-8"?> <container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"> <rootfiles> <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/> </rootfiles> </container>`; } function makeOpf(metadata, manifestItems, spineItems) { const nowISO = new Date().toISOString(); const manifest = manifestItems.map(m => ` <item id="${m.id}" href="${m.href}" media-type="${m['media-type']}"/>`).join('\n'); const spine = spineItems.map(s => ` <itemref idref="${s}"/>`).join('\n'); return `<?xml version="1.0" encoding="utf-8"?> <package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId" version="2.0"> <metadata xmlns:dc="http://purl.org/dc/elements/1.1/"> <dc:title>${escapeXml(metadata.title)}</dc:title> <dc:language>${metadata.language}</dc:language> <dc:identifier id="BookId">urn:uuid:${metadata.uuid}</dc:identifier> <dc:creator>${escapeXml(metadata.creator)}</dc:creator> <dc:publisher>${escapeXml(metadata.publisher)}</dc:publisher> <dc:date>${nowISO}</dc:date> </metadata> <manifest> ${manifest} <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/> </manifest> <spine toc="ncx"> ${spine} </spine> </package>`; } function makeNcx(metadata, navPoints) { const nav = navPoints.map((n, i) => ` <navPoint id="navPoint-${i+1}" playOrder="${i+1}"> <navLabel><text>${escapeXml(n.label)}</text></navLabel> <content src="${n.src}"/> </navPoint>`).join('\n'); return `<?xml version="1.0" encoding="utf-8"?> <ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1"> <head> <meta name="dtb:uid" content="urn:uuid:${metadata.uuid}"/> <meta name="dtb:depth" content="1"/> </head> <docTitle><text>${escapeXml(metadata.title)}</text></docTitle> <docAuthor><text>${escapeXml(metadata.creator)}</text></docAuthor> <navMap> ${nav} </navMap> </ncx>`; } function wrapAsXHtml(title, htmlContent) { return `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>${escapeXml(title)}</title> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <style type="text/css"> body { font-family: serif; line-height:1.5; padding: 1em; color:#111; } img { max-width:100%; height:auto; display:block; margin:0.5em auto; } figure { margin: 0; padding:0; } figcaption { font-size:0.9em; color:#555; text-align:center; margin-bottom:8px; } </style> </head> <body> <h1>${escapeXml(title)}</h1> ${htmlContent} </body> </html>`; } async function collectImagesToEmbed(imageSources, includeImages, coverBlobPromise, useProxy) { const images = []; // cover first if provided if (coverBlobPromise) { try { const cover = await coverBlobPromise; if (cover) images.push({ filename: 'images/cover' + (cover.ext ? '.' + cover.ext : '.jpg'), arrayBuffer: cover.ab, 'media-type': cover.mime, cover: true, originalSrc: cover.originalSrc || null }); } catch (e) { dbg('cover fetch failed', e); } } if (!includeImages) return images; const seen = new Set(); for (const src of imageSources) { if (!src || seen.has(src)) continue; seen.add(src); try { const ab = await fetchAsArrayBuffer(src, useProxy); const extMatch = src.split('?')[0].match(/\.(jpe?g|png|gif|webp|svg)$/i); const ext = extMatch ? extMatch[1].toLowerCase() : 'jpg'; const mime = ext === 'svg' ? 'image/svg+xml' : (ext === 'jpg' ? 'image/jpeg' : 'image/' + ext); images.push({ filename: 'images/' + uid('img-') + '.' + ext, arrayBuffer: ab, 'media-type': mime, originalSrc: src }); } catch (e) { dbg('failed to fetch image', src, e); } } return images; } /************************************************************************ * Populate preview & images ************************************************************************/ function populatePreviewAndImages() { const titleInput = document.getElementById('we-title'); const authorInput = document.getElementById('we-author'); const preview = document.getElementById('we-preview'); const imagesGrid = document.getElementById('we-images'); const article = extractArticle(); if (!article) { titleInput.value = document.title || ''; authorInput.value = ''; preview.innerHTML = `<i style="color:#999">Could not extract article content on this page.</i>`; imagesGrid.innerHTML = `<div style="color:#999">No images detected.</div>`; return; } titleInput.value = article.title || document.title || ''; authorInput.value = article.byline || ''; const strong = document.getElementById('we-strong-filter') ? document.getElementById('we-strong-filter').checked : true; const previewHtml = strong ? strongFilter(article.excerpt || article.textContent.slice(0, 800)) : postProcessContent(article.excerpt || article.textContent.slice(0, 800)); preview.innerHTML = previewHtml; imagesGrid.innerHTML = `<div style="color:#777">Detecting images…</div>`; detectImages(article.content || '').then(list => { imagesGrid.innerHTML = ''; if (!list.length) { imagesGrid.innerHTML = `<div style="color:#999">No large images detected.</div>`; return; } list.forEach((src, idx) => { const card = document.createElement('div'); card.className = 'img-card'; const id = uid('radio-'); card.innerHTML = ` <img src="${src}" alt="img-${idx}" crossorigin="anonymous"> <div style="display:flex; gap:8px; align-items:center; justify-content:center"> <input type="radio" name="we-cover" id="${id}" value="${encodeURIComponent(src)}"> <label for="${id}" style="font-size:12px">Use as cover</label> </div> `; imagesGrid.appendChild(card); }); }).catch(err => { dbg('detectImages failed', err); imagesGrid.innerHTML = `<div style="color:#999">Image detection failed.</div>`; }); } /************************************************************************ * Zip / generate handler ************************************************************************/ async function onGenerateClicked() { const genBtn = document.getElementById('we-generate'); const log = document.getElementById('we-log'); const progress = document.getElementById('we-progress'); genBtn.disabled = true; log.textContent = 'Preparing…'; progress.style.display = 'block'; progress.querySelector('i').style.width = '8%'; try { const title = document.getElementById('we-title').value || document.title || 'webpage'; const author = document.getElementById('we-author').value || ''; const includeImages = document.getElementById('we-include-images').checked; const singleChapter = document.getElementById('we-single-chapter').checked; const strongFiltering = document.getElementById('we-strong-filter').checked; const useProxy = document.getElementById('we-use-proxy').checked; const article = extractArticle(); if (!article) throw new Error('Could not extract article content. Try again on a simpler article page.'); progress.querySelector('i').style.width = '20%'; // cover selection const selectedRadio = document.querySelector('input[name="we-cover"]:checked'); const coverUploadFile = document.getElementById('we-cover-upload').files && document.getElementById('we-cover-upload').files[0]; const coverUrl = document.getElementById('we-cover-url').value.trim(); let coverBlobPromise = null; if (coverUploadFile) { coverBlobPromise = (async () => { const fr = new FileReader(); return new Promise((resolve, reject) => { fr.onload = () => { const ab = fr.result; const ext = (coverUploadFile.name.split('.').pop() || 'jpg').toLowerCase(); const mime = coverUploadFile.type || (ext === 'png' ? 'image/png' : 'image/jpeg'); resolve({ ext, ab, mime, originalSrc: null }); }; fr.onerror = () => reject(new Error('Failed to read upload')); fr.readAsArrayBuffer(coverUploadFile); }); })(); } else if (coverUrl) { coverBlobPromise = (async () => { const ab = await fetchAsArrayBuffer(coverUrl, useProxy); const extMatch = coverUrl.split('?')[0].match(/\.(jpe?g|png|gif|webp|svg)$/i); const ext = extMatch ? extMatch[1] : 'jpg'; const mime = ext === 'svg' ? 'image/svg+xml' : (ext === 'jpg' ? 'image/jpeg' : 'image/' + ext); return { ext, ab, mime, originalSrc: coverUrl }; })(); } else if (selectedRadio) { const src = decodeURIComponent(selectedRadio.value); coverBlobPromise = (async () => { const ab = await fetchAsArrayBuffer(src, useProxy); const extMatch = src.split('?')[0].match(/\.(jpe?g|png|gif|webp|svg)$/i); const ext = extMatch ? extMatch[1] : 'jpg'; const mime = ext === 'svg' ? 'image/svg+xml' : (ext === 'jpg' ? 'image/jpeg' : 'image/' + ext); return { ext, ab, mime, originalSrc: src }; })(); } else { coverBlobPromise = null; } progress.querySelector('i').style.width = '30%'; log.textContent = 'Collecting images…'; // sanitize content const rawContent = article.content || ''; const sanitizedContent = strongFiltering ? strongFilter(rawContent) : postProcessContent(rawContent); // find image srcs in sanitized content preserving order const tmpDoc = document.implementation.createHTMLDocument('san'); tmpDoc.body.innerHTML = sanitizedContent; const articleImgSrcs = Array.from(tmpDoc.querySelectorAll('img')).map(i => i.src).filter(Boolean); // collect images to embed (cover + article images) const imagesToEmbed = await collectImagesToEmbed(articleImgSrcs, includeImages, coverBlobPromise, useProxy); progress.querySelector('i').style.width = '55%'; log.textContent = `Embedding ${imagesToEmbed.length} images (if any)…`; // build mapping from originalSrc -> internal filename const srcMap = {}; imagesToEmbed.forEach(img => { if (img.originalSrc) srcMap[img.originalSrc] = img.filename; if (img.cover && img.originalSrc) srcMap[img.originalSrc] = img.filename; }); // filename base mapping fallback for (const img of imagesToEmbed) { if (!img.originalSrc) continue; const base = img.originalSrc.split('?')[0].split('/').pop(); if (base) { srcMap[base] = img.filename; } } // Replace image src in content with internal refs let finalContent = sanitizedContent; Object.keys(srcMap).forEach(orig => { try { const decoded = decodeURIComponent(orig); finalContent = finalContent.split(orig).join(srcMap[orig]); if (decoded !== orig) finalContent = finalContent.split(decoded).join(srcMap[orig]); } catch (e) { finalContent = finalContent.split(orig).join(srcMap[orig]); } }); // fallback: replace by base filename occurrences imagesToEmbed.forEach(img => { try { const base = img.originalSrc ? img.originalSrc.split('?')[0].split('/').pop() : null; if (base) finalContent = finalContent.split(base).join(img.filename); } catch (e) { /* ignore */ } }); progress.querySelector('i').style.width = '70%'; // Build EPUB zip const zip = new JSZip(); zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' }); zip.folder('META-INF').file('container.xml', makeContainerXml()); const oebps = zip.folder('OEBPS'); // images folder const imagesFolder = oebps.folder('images'); const manifestItems = []; const spineItems = []; // add images to zip and manifest for (const img of imagesToEmbed) { const fname = img.filename.replace(/^images\//i, ''); imagesFolder.file(fname, img.arrayBuffer || img.ab); const mediaType = img['media-type'] || img.mime || 'image/jpeg'; const id = uid('img-'); manifestItems.push({ id, href: 'images/' + fname, 'media-type': mediaType }); if (img.cover) manifestItems.push({ id: 'cover', href: 'images/' + fname, 'media-type': mediaType }); } // create chapter(s) const textFolder = oebps.folder('text'); if (singleChapter) { textFolder.file('chapter1.xhtml', wrapAsXHtml(title, finalContent)); manifestItems.push({ id: 'chap1', href: 'text/chapter1.xhtml', 'media-type': 'application/xhtml+xml' }); spineItems.push('chap1'); } else { const docTmp = document.implementation.createHTMLDocument('split'); docTmp.body.innerHTML = finalContent; const sections = []; let current = { title: title, html: '' }; Array.from(docTmp.body.childNodes).forEach(node => { if (node.nodeType === 1 && /^H[12]$/i.test(node.tagName)) { sections.push(current); current = { title: node.textContent.trim() || ('Part ' + (sections.length + 1)), html: '' }; } else { current.html += node.outerHTML || node.textContent || ''; } }); sections.push(current); sections.forEach((s, i) => { const fname = `text/chapter${i + 1}.xhtml`; textFolder.file(`chapter${i + 1}.xhtml`, wrapAsXHtml(s.title, s.html)); manifestItems.push({ id: `chap${i + 1}`, href: `text/chapter${i + 1}.xhtml`, 'media-type': 'application/xhtml+xml' }); spineItems.push(`chap${i + 1}`); }); } // add content.opf and toc.ncx const metadata = { title, creator: author, publisher: CONFIG.publisher, language: CONFIG.language, uuid: generateUUID() }; // write styles (optional small file) oebps.file('styles.css', 'body{font-family:serif;}'); oebps.file('content.opf', makeOpf(metadata, manifestItems, spineItems)); const navPoints = spineItems.map((s, idx) => ({ label: title + (idx ? ' - ' + (idx + 1) : ''), src: `text/chapter${idx + 1}.xhtml` })); oebps.file('toc.ncx', makeNcx(metadata, navPoints)); progress.querySelector('i').style.width = '85%'; log.textContent = 'Zipping and generating EPUB…'; const blob = await zip.generateAsync({ type: 'blob', mimeType: 'application/epub+zip' }, (meta) => { const p = Math.floor(meta.percent); progress.querySelector('i').style.width = Math.min(95, 85 + (p / 100) * 10) + '%'; }); const safe = safeFilename(title); saveAs(blob, `${safe}.epub`); progress.querySelector('i').style.width = '100%'; log.textContent = 'Done — EPUB downloaded.'; } catch (err) { dbg('generate err', err); const logEl = document.getElementById('we-log'); if (logEl) logEl.textContent = 'Error: ' + (err.message || String(err)); else console.error(err); } finally { const genBtn = document.getElementById('we-generate'); if (genBtn) genBtn.disabled = false; setTimeout(() => { const pbar = document.getElementById('we-progress'); if (pbar) pbar.style.display = 'none'; }, 1500); } } /************************************************************************ * Boot ************************************************************************/ function boot() { try { addFloatingButton(); addModal(); } catch (e) { dbg('boot error', e); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();