WTR-LAB Chapter Downloader EPUB

Use however you want

// ==UserScript==
// @name        WTR-LAB Chapter Downloader EPUB 
// @namespace   Violentmonkey Scripts
// @match       https://wtr-lab.com/en/novel/*
// @grant       none
// @version     1.1
// @author      -
// @license MIT
// @description Use however you want
// ==/UserScript==


(async () => {
  "use strict";

  // --- 0. Replacement logic ---
  const STORAGE_KEY = 'wordReplacerPairsV3';
  const data = (() => {
    const raw = localStorage.getItem(STORAGE_KEY);
    try { return raw ? JSON.parse(raw) : {}; } catch { return {}; }
  })();

  function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\",]/g, '\\$&'); }

  function isStartOfSentence(index, fullText) {
    if (index === 0) return true;
    const before = fullText.slice(0, index).replace(/\s+$/, '');
    if (/[\n\r]$/.test(before)) return true;
    if (/[.!?…]["”’')\]]*$/.test(before)) return true;
    if (/["“”'‘(\[]\s*$/.test(before)) return true;
    if (/Chapter\s+\d+:\s*,?\s*$/.test(before)) return true;
    return false;
  }

  function isInsideDialogueAtIndex(htmlText, index) {
    const quoteChars = `"'“”‘’`;
    const clean = htmlText.replace(/<[^>]*>/g, '');
    const leftText = clean.slice(0, index);
    const quoteCount = (leftText.match(new RegExp(`[${quoteChars}]`, 'g')) || []).length;
    return quoteCount % 2 === 1;
  }

  function applyPreserveCapital(orig, replacement) {
    if (!orig) return replacement;
    return (orig[0].toUpperCase() === orig[0]) ? replacement.charAt(0).toUpperCase() + replacement.slice(1) : replacement;
  }

  function applyReplacements(text, replacements) {
    let replacedText = text;
    const WILDCARD = '@';
    const punctuationRegex = /^[\W_'"“”‘’„,;:!?~()\[\]{}<>【】「」『』()《》〈〉—–-]|[\W_'"“”‘’„,;:!?~()\[\]{}<>【】「」『』()《》〈〉—–-]$/;

    for (const entry of replacements) {
      if (!entry.from || !entry.to || !entry.enabled) continue;
      const flags = entry.ignoreCapital ? 'gi' : 'g';
      let base = escapeRegex(entry.from).replace(new RegExp(`\\${WILDCARD}`, 'g'), '.');
      const firstChar = entry.from.charAt(0);
      const lastChar = entry.from.charAt(entry.from.length - 1);
      const skipBoundaries = punctuationRegex.test(firstChar) || punctuationRegex.test(lastChar);
      const patternStr = (entry.allInstances || skipBoundaries) ? base : `(?<=^|[^A-Za-z0-9])${base}(?=[^A-Za-z0-9]|$)`;
      const regex = new RegExp(patternStr, flags);

      let newText = '';
      let lastIndex = 0, match;
      while ((match = regex.exec(replacedText)) !== null) {
        const idx = match.index;
        const insideDialogue = isInsideDialogueAtIndex(replacedText, idx);
        if ((entry.insideDialogueOnly && !insideDialogue) || (entry.outsideDialogueOnly && insideDialogue)) continue;

        newText += replacedText.slice(lastIndex, idx);
        const startSentence = entry.startOfSentence && isStartOfSentence(idx, replacedText);
        let finalReplacement = entry.preserveFirstCapital ? applyPreserveCapital(match[0], entry.to) : entry.to;
        if (startSentence) finalReplacement = finalReplacement.charAt(0).toUpperCase() + finalReplacement.slice(1);

        newText += finalReplacement;
        lastIndex = idx + match[0].length;
      }
      if (lastIndex < replacedText.length) newText += replacedText.slice(lastIndex);
      replacedText = newText;
    }
    return replacedText;
  }

  function applyReplacementsToText(text, seriesIdParam = null) {
    const seriesId = seriesIdParam || (() => {
      const urlMatch = location.href.match(/\/novel\/(\d+)\//i);
      if (urlMatch) return urlMatch[1];
      const crumb = document.querySelector('.breadcrumb li.breadcrumb-item a[href*="/novel/"]');
      if (crumb) { const crumbMatch = crumb.href.match(/\/novel\/(\d+)\//i); if (crumbMatch) return crumbMatch[1]; }
      return null;
    })();

    let replacements = [];
    for (const key in data) {
      if (key === 'global' || (seriesId && key === `series-${seriesId}`) || (seriesIdParam && key === `series-${seriesIdParam}`)) {
        replacements = replacements.concat(data[key].filter(e => e.enabled));
      }
    }
    return replacements.length ? applyReplacements(text, replacements) : text;
  }

  // --- 1. Chapter info ---
  const dom = document;
  const leaves = dom.baseURI.split("/");
  const novelIndex = leaves.indexOf("novel");
  const language = leaves[novelIndex - 1];
  const id = leaves[novelIndex + 1];
  const novelLink = document.querySelector('a[href*="/novel/"]');
  const novelTitle = novelLink ? novelLink.textContent.trim().replace(/[\/\\?%*:|"<>]/g, '-') : leaves[leaves.length - 1].split("?")[0];

  const chaptersResp = await fetch(`https://wtr-lab.com/api/chapters/${id}`, { credentials: "include" });
  const chaptersJson = await chaptersResp.json();
  const chapters = chaptersJson.chapters;

// --- 2. Menu ---
const menu = document.createElement("div");
menu.style.cssText = `
  position: fixed; top: 60px; right: 20px; background: #fff; border-radius: 12px;
  padding: 0; max-height: 80vh; overflow-y: auto; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  display: none; width: 350px;
`;

// --- fixed top bar inside menu ---
menu.innerHTML = `
  <div id="menuHeader" style="
    position: sticky; top: 0; background: #fff; z-index: 10;
    padding: 10px; border-bottom: 1px solid #ddd;
  ">
    <h3 style="margin: 0 0 6px 0;">Select chapters</h3>
    <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
      <label style="flex:1;"><input type="checkbox" id="selectAllChk" checked> All</label>
      <button id="selectFromCurrentBtn">From Current</button>
      <button id="jumpToCurrentBtn">Jump</button>
      <button id="downloadEpubBtn" style="flex-shrink:0;">Download</button>
    </div>
  </div>
  <div id="chaptersList" style="padding:10px;">
    ${chapters.map(ch => `
      <label style="display:block; border-bottom:1px solid #eee; padding:8px 0;">
        <input type="checkbox" checked data-order="${ch.order}">
        ${ch.order}: ${ch.title}
      </label>
    `).join("")}
  </div>
`;
document.body.appendChild(menu);

// --- toggle button ---
const toggleBtn = document.createElement("button");
toggleBtn.textContent = "📚 Chapters";
toggleBtn.style.cssText = `position: fixed; top: 10px; right: 10px; z-index: 999999;`;
toggleBtn.onclick = () => menu.style.display = menu.style.display === "none" ? "block" : "none";
document.body.appendChild(toggleBtn);

// --- select/deselect all ---
const selectAllChk = document.getElementById("selectAllChk");
selectAllChk.addEventListener("change", () => {
  menu.querySelectorAll("#chaptersList input[type=checkbox]").forEach(cb => cb.checked = selectAllChk.checked);
});

// --- current chapter logic ---
const currentChapterOrder = parseInt(location.pathname.match(/chapter-(\d+)/)?.[1] ?? "1");

// --- select from current onward ---
document.getElementById("selectFromCurrentBtn").onclick = () => {
  menu.querySelectorAll("#chaptersList input[type=checkbox]").forEach(cb => {
    cb.checked = parseInt(cb.dataset.order) >= currentChapterOrder;
  });
  selectAllChk.checked = false;
};

// --- jump to current + highlight ---
document.getElementById("jumpToCurrentBtn").onclick = () => {
  menu.querySelectorAll("#chaptersList label").forEach(lbl => lbl.style.background = "");
  const currentCheckbox = menu.querySelector(`#chaptersList input[data-order="${currentChapterOrder}"]`);
  if (currentCheckbox) {
    currentCheckbox.scrollIntoView({ behavior: "smooth", block: "center" });
    currentCheckbox.parentElement.style.background = "#fffae6";
  }
};

// --- helper: get rendered text ---
function getRenderedText(container) {
  return Array.from(container.querySelectorAll("p[data-line], p"))
    .map(p => p.textContent)
    .join("\n")
    .trim();
}


  // --- 3. Fetch chapter content + replace glossary ---
  async function fetchChapterContent(order) {
    const formData = { translate: "ai", language, raw_id: id, chapter_no: order, retry: false, force_retry: false };
    const res = await fetch("https://wtr-lab.com/api/reader/get", {
      method: "POST", headers: { "Content-Type": "application/json;charset=UTF-8" },
      body: JSON.stringify(formData), credentials: "include"
    });
    const json = await res.json();
    const tempDiv = document.createElement("div");
    let imgCounter = 0;

    json.data.data.body.forEach(el => {
      if (el === "[image]") {
        const src = json.data.data?.images?.[imgCounter++] ?? "";
        if (src) { const img = document.createElement("img"); img.src = src; tempDiv.appendChild(img); }
      } else {
        const pnode = document.createElement("p");
        const wrapper = document.createElement("div");
        wrapper.innerHTML = el;
        pnode.textContent = wrapper.textContent;

        for (let i = 0; i < json?.data?.data?.glossary_data?.terms?.length ?? 0; i++) {
          const term = json.data.data.glossary_data.terms[i][0] ?? `※${i}⛬`;
          pnode.textContent = pnode.textContent.replaceAll(`※${i}⛬`, term);
        }
        tempDiv.appendChild(pnode);
      }
    });

    const rawText = getRenderedText(tempDiv);
    const processedText = applyReplacementsToText(rawText, id);
    return `<h1>${order}: ${json.chapter.title}</h1><p>${processedText.replace(/\n/g,"<br>")}</p>`;
  }

  async function buildAllContentFromSelected() {
    const selectedOrders = [...menu.querySelectorAll("#chaptersList input:checked")].map(cb => cb.dataset.order);
    const allContent = [];
    for (const order of selectedOrders) {
      try {
        const html = await fetchChapterContent(order);
        allContent.push(html);
        await new Promise(r => setTimeout(r, 500));
      } catch (err) { console.error(err); allContent.push(`<h1>${order}: (error)</h1>`); }
    }
    return { content: allContent, orders: selectedOrders };
  }

  // --- 4. EPUB functions (unchanged from your previous) ---
  async function ensureJSZip() { if (window.JSZip) return window.JSZip; return new Promise((res, rej) => { const s = document.createElement("script"); s.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"; s.onload = () => res(window.JSZip); s.onerror = rej; document.head.appendChild(s); }); }
  async function downloadAsEPUB(novelTitle, allContent, chapterOrders) {
    await ensureJSZip();
    const zip = new JSZip();
    zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
    const metaInf = zip.folder("META-INF");
    const oebps = zip.folder("OEBPS");
    metaInf.file("container.xml", `<?xml version="1.0"?><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>`);
    const manifestItems = chapterOrders.map(num => `<item id="ch${num}" href="ch${num}.xhtml" media-type="application/xhtml+xml"/>`).join("\n");
    const spineItems = chapterOrders.map(num => `<itemref idref="ch${num}"/>`).join("\n");
    oebps.file("content.opf", `<?xml version="1.0" encoding="utf-8"?><package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>${escapeXml(novelTitle)}</dc:title>  <dc:creator>${escapeXml(novelAuthor || "WTRLAB")}</dc:creator><dc:language>en</dc:language><dc:identifier id="BookId">urn:uuid:${crypto.randomUUID()}</dc:identifier></metadata><manifest>${manifestItems}</manifest><spine>${spineItems}</spine></package>`);
    allContent.forEach((html, idx) => oebps.file(`ch${chapterOrders[idx]}.xhtml`, `<?xml version="1.0" encoding="UTF-8"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter ${chapterOrders[idx]}</title></head><body>${html}</body></html>`));
    const blob = await zip.generateAsync({ type: "blob" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = `${sanitizeFilename(novelTitle)}.epub`;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(a.href), 2000);
  }
  function escapeXml(str) { return (str+"").replace(/[<>&'"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;',"'":'&apos;','"':'&quot;'})[c]); }
  function sanitizeFilename(name) { return (name||"book").replace(/[\/\\?%*:|"<>]/g,"-").slice(0,200); }

  // helper: find cover URL from page (tries several common selectors/meta tags)
function findCoverImageUrl(dom = document) {
  // 1. common site selector
  const el = dom.querySelector(".image-wrap img") || dom.querySelector(".cover img");
  if (el && el.src) return el.src;

  // 2. open-graph
  const og = dom.querySelector('meta[property="og:image"]');
  if (og && og.content) return og.content;

  // 3. link rel
  const linkImg = dom.querySelector('link[rel="image_src"]');
  if (linkImg && linkImg.href) return linkImg.href;

  // 4. try to fetch from known CDN pattern in your message
  // (if path contains /cdn/series/... use that)
  const imgs = Array.from(dom.querySelectorAll('img'));
  const found = imgs.find(i => /cdn\/series\/.+\.(png|jpe?g|webp)$/i.test(i.src));
  if (found) return found.src;

  // 5. fallback: try extracting from meta JSON script if present
  try {
    const nextData = dom.querySelector('script#__NEXT_DATA__')?.textContent;
    if (nextData) {
      const j = JSON.parse(nextData);
      // this structure may differ - try a couple of usual paths
      const possible =
        j?.props?.pageProps?.series?.cover ||
        j?.props?.pageProps?.novel?.cover ||
        j?.props?.initialState?.series?.cover;
      if (possible) return possible;
    }
  } catch (e) { /* ignore */ }

  return null;
}

// patched downloadAsEPUB that includes cover image if available
async function downloadAsEPUB(novelTitle, allContent, chapterOrders) {
  await ensureJSZip();

  const zip = new JSZip();
  // must be first & uncompressed
  zip.file("mimetype", "application/epub+zip", { compression: "STORE" });
  const metaInf = zip.folder("META-INF");
  const oebps = zip.folder("OEBPS");
  const imagesFolder = oebps.folder("images");

  metaInf.file(
    "container.xml",
    `<?xml version="1.0"?>
<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>`
  );

  // **** Attempt to find & embed cover image ****
  let coverHref = null;
  let coverId = "cover-image";
  try {
    const coverUrl = findCoverImageUrl(document);
    if (coverUrl) {
      // normalize URL (absolute)
      const absolute = new URL(coverUrl, location.href).href;
      // fetch binary
      const resp = await fetch(absolute, { credentials: "include" });
      if (resp.ok) {
        const buf = await resp.arrayBuffer();
        // determine extension/content-type
        const ct = resp.headers.get("content-type") || "";
        let ext = "jpg";
        if (ct.includes("png")) ext = "png";
        else if (ct.includes("webp")) ext = "webp";
        else if (ct.includes("jpeg")) ext = "jpg";
        else {
          // try from URL
          const m = absolute.match(/\.(png|jpe?g|webp)(?:$|\?)/i);
          if (m) ext = m[1].toLowerCase().replace("jpeg", "jpg");
        }
        coverHref = `images/cover.${ext}`;
        // add to zip (Uint8Array)
        imagesFolder.file(`cover.${ext}`, new Uint8Array(buf));
      } else {
        console.warn("[EPUB] cover fetch failed:", resp.status);
      }
    } else {
      console.info("[EPUB] No cover URL found on page");
    }
  } catch (err) {
    console.warn("[EPUB] cover embed skipped (error):", err);
    coverHref = null;
  }

  // Derive minimal chapter titles array (try headings in content or fallback)
  const chapterTitles = chapterOrders.map((num, i) => {
    const html = allContent[i] || "";
    const m = html.match(/<h[1-3][^>]*>([^<]+)<\/h[1-3]>/i);
    if (m && m[1]) return m[1].trim();
    try {
      if (typeof chapters !== "undefined" && Array.isArray(chapters)) {
        const found = chapters.find(c => String(c.order) === String(num));
        if (found && found.title) return found.title;
      }
    } catch (e) {}
    return `Chapter ${num}`;
  });

  // nav.xhtml (simple ToC)
  const tocEntries = chapterOrders
    .map((num, i) => `<li><a href="ch${num}.xhtml">${escapeXml(chapterTitles[i])}</a></li>`)
    .join("\n");

  const navXhtml = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
  <head><title>Table of Contents</title></head>
  <body>
    <nav epub:type="toc" id="toc"><h1>Contents</h1><ol>${tocEntries}</ol></nav>
  </body>
</html>`;
  oebps.file("nav.xhtml", navXhtml);

  // manifest entries (include cover image if present)
  const manifestItems = [
    `<item id="nav" href="nav.xhtml" properties="nav" media-type="application/xhtml+xml"/>`,
    ...chapterOrders.map(num => `<item id="ch${num}" href="ch${num}.xhtml" media-type="application/xhtml+xml"/>`)
  ];
  if (coverHref) {
    // detect media-type
    const ext = coverHref.split(".").pop().toLowerCase();
    let mtype = "image/jpeg";
    if (ext === "png") mtype = "image/png";
    else if (ext === "webp") mtype = "image/webp";
    manifestItems.splice(1, 0, `<item id="${coverId}" href="${coverHref}" media-type="${mtype}"/>`);
  }

  const manifestXml = manifestItems.join("\n");
  const spineItems = chapterOrders.map(num => `<itemref idref="ch${num}"/>`).join("\n");

  // content.opf with cover meta if present
  const metaCoverTag = coverHref ? `<meta name="cover" content="${coverId}"/>` : "";
  const opf = `<?xml version="1.0" encoding="utf-8"?>
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
    <dc:title>${escapeXml(novelTitle || "Untitled")}</dc:title>
    <dc:language>en</dc:language>
    <dc:identifier id="BookId">urn:uuid:${crypto.randomUUID()}</dc:identifier>
    ${metaCoverTag}
  </metadata>
  <manifest>
    ${manifestXml}
  </manifest>
  <spine>
    ${spineItems}
  </spine>
</package>`;
  oebps.file("content.opf", opf);

  // Add chapters
  allContent.forEach((html, idx) => {
    const order = chapterOrders[idx];
    const title = escapeXml(chapterTitles[idx] || `Chapter ${order}`);
    const safeHtml = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head><title>${title}</title></head>
  <body>${html}</body>
</html>`;
    oebps.file(`ch${order}.xhtml`, safeHtml);
  });

  // Add a simple cover.xhtml page that displays the cover (some readers use it)
  if (coverHref) {
    const coverPage = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
  <head><title>Cover</title></head>
  <body>
    <div style="text-align:center;">
      <img src="${coverHref}" alt="Cover" style="max-width:100%;height:auto;"/>
    </div>
  </body>
</html>`;
    oebps.file("cover.xhtml", coverPage);
    // include cover.xhtml in manifest and place it first in spine if desired
    // (we already added cover image manifest); optionally insert cover.xhtml
    // oebps manifest/spine modifications could be added here if you want cover.xhtml first.
  }

  // generate
  const blob = await zip.generateAsync({ type: "blob" });
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = `${sanitizeFilename(novelTitle || "book")}.epub`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(a.href), 2000);
  console.info("[EPUB] Download triggered (with cover if available).");
}


  // --- 5. Menu buttons ---
  document.getElementById("downloadEpubBtn").addEventListener("click", async () => {
    const { content, orders } = await buildAllContentFromSelected();
    await downloadAsEPUB(novelTitle || document.title || "Novel", content, orders);
  });

  console.info("Chapter menu ready — glossary replacement fully preserved.");
})();