Webpage to EPUB — Mobile optimized

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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[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">&times;</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();
  }

})();