// ==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 => ({'<':'<','>':'>','&':'&',"'":''','"':'"'})[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.");
})();