// ==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();
}
})();