10/11/2025, 11:46:09 PM
// ==UserScript==
// @name Chapter Saver X
// @namespace Violentmonkey Scripts
// @match https://wtr-lab.com/en/novel/*
// @grant none
// @version 1.0
// @author -
// @description 10/11/2025, 11:46:09 PM
// ==/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 Chapter Onwards</button>
<button id="jumpToCurrentBtn">Jump to Current Chapter</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:4px 0;">
<input type="checkbox" checked data-order="${ch.order}">
${ch.order}: ${ch.title}
</label>
`).join("")}
</div>
`;
document.body.appendChild(menu);
// --- menu bar "Continue" button for most recent novel ---
const menuContinueBtn = document.createElement("button");
menuContinueBtn.textContent = "Continue From Latest";
menuContinueBtn.style.flexShrink = "0";
document.querySelector("#menuHeader > div").appendChild(menuContinueBtn);
menuContinueBtn.onclick = () => {
const library = loadLibrary();
if (!library.length) return alert("Library is empty. No novel to continue.");
// pick the most recently downloaded novel
const recent = library.reduce((a,b) => (a.lastDownloaded > b.lastDownloaded ? a : b));
continueDownload(recent);
};
// --- 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);
const libraryBtn = document.createElement("button");
libraryBtn.textContent = "Library";
libraryBtn.style.flexShrink = "0";
document.querySelector("#menuHeader > div").appendChild(libraryBtn);
// --- 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";
}
};
const INTERRUPTED_KEY = "epubDownloadTemp";
function saveTempProgress(entry, chapters, orders) {
localStorage.setItem(INTERRUPTED_KEY, JSON.stringify({
id: entry.id,
title: entry.title,
coverUrl: entry.coverUrl,
chapters,
orders
}));
}
function loadTempProgress() {
const raw = localStorage.getItem(INTERRUPTED_KEY);
return raw ? JSON.parse(raw) : null;
}
function clearTempProgress() {
localStorage.removeItem(INTERRUPTED_KEY);
}
async function continueDownload(entry) {
const novelId = entry.id;
const novelTitle = entry.title;
const startChapter = entry.latestChapter + 1;
const totalChapters = entry.totalChapters;
console.info(`[DOWNLOAD] Continuing ${novelTitle} from chapter ${startChapter}`);
let coverUrl = entry.coverUrl || findCoverImageUrl(document);
let successfulChapters = [];
let successfulOrders = [];
// If there is temp progress for this novel, load it
const temp = loadTempProgress();
if (temp && temp.id === novelId) {
successfulChapters = temp.chapters;
successfulOrders = temp.orders;
console.info(`[DOWNLOAD] Resuming from temp progress: ${successfulOrders.slice(-1)[0]}`);
}
for (let ch = startChapter; ch <= totalChapters; ch++) {
try {
const html = await fetchChapterContent(ch);
successfulChapters.push(html);
successfulOrders.push(ch);
// Save temp progress after each successful chapter
saveTempProgress(entry, successfulChapters, successfulOrders);
// Update library progress
const library = loadLibrary();
const existing = library.find(e => e.id === novelId);
if (existing) {
existing.totalChapters = totalChapters;
existing.latestChapter = Math.max(...successfulOrders);
existing.coverUrl = coverUrl;
saveLibrary(library);
}
console.info(`[CONTINUE] Fetched chapter ${ch}`);
await new Promise(r => setTimeout(r, 500));
} catch (err) {
console.warn(`[CONTINUE] Chapter ${ch} failed:`, err);
// Show alert that download paused
securityAlert.textContent = "⚠️ Download paused due to failure. Refresh page to continue.";
securityAlert.style.display = "block";
return; // stop downloading further chapters
}
}
// All chapters succeeded, generate final EPUB
if (successfulChapters.length > 0) {
await downloadAsEPUB(novelTitle, successfulChapters, successfulOrders);
// Update library progress
const library = loadLibrary();
const existing = library.find(e => e.id === novelId);
if (existing) {
existing.latestChapter = Math.max(...successfulOrders);
existing.coverUrl = coverUrl;
saveLibrary(library);
}
// Clear temp storage
clearTempProgress();
renderLibrary();
console.info(`[DOWNLOAD] Completed ${novelTitle} up to chapter ${totalChapters}`);
}
}
// --- library data store key ---
const LIBRARY_KEY = "epubLibraryV1";
function loadLibrary() {
const raw = localStorage.getItem(LIBRARY_KEY);
try { return raw ? JSON.parse(raw) : []; } catch { return []; }
}
function saveLibrary(data) { localStorage.setItem(LIBRARY_KEY, JSON.stringify(data)); }
// --- tiny security alert popup ---
const securityAlert = document.createElement("div");
securityAlert.style.cssText = `
position: fixed; top: 40px; right: 10px; background:#ffcccc; color:#900;
padding:2px 6px; border-radius:6px; font-size:12px; display:none; z-index:999999;
`;
securityAlert.innerHTML = "⚠️ Security check encountered — download paused. Refresh page if stuck.";
document.body.appendChild(securityAlert);
// --- library UI panel ---
const libraryPanel = document.createElement("div");
libraryPanel.style.cssText = `
position: fixed; top: 60px; right: 380px; width: 400px; max-height: 80vh;
overflow-y: auto; background: #fff; border-radius: 12px; padding: 10px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 9999; display:none;
`;
libraryPanel.innerHTML = `
<h3>EPUB Library</h3>
<input type="text" id="librarySearch" placeholder="Search by title..." style="width:100%; margin-bottom:10px;"/>
<select id="librarySort" style="width:100%; margin-bottom:10px;">
<option value="recent">Most Recent</option>
<option value="title">Title A-Z</option>
<option value="latestChapter">Latest Chapter</option>
</select>
<div id="libraryList"></div>
`;
document.body.appendChild(libraryPanel);
// --- open library (close menu, show blank panel) ---
libraryBtn.onclick = () => {
menu.style.display = "none"; // close chapters menu
libraryPanel.style.display = "block";
};
// --- library panel style & blank contents ---
libraryPanel.style.width = "380px";
libraryPanel.style.height = "80vh";
libraryPanel.style.background = "#fff";
libraryPanel.style.border = "1px solid #ccc";
libraryPanel.style.borderRadius = "8px";
libraryPanel.style.padding = "10px";
libraryPanel.style.overflowY = "auto";
libraryPanel.style.boxShadow = "0 4px 12px rgba(0,0,0,0.15)";
libraryPanel.style.position = "fixed";
libraryPanel.style.top = "60px";
libraryPanel.style.right = "10px";
libraryPanel.style.zIndex = "9999";
libraryPanel.style.display = "none";
// --- add close button at top ---
const libraryCloseBtn = document.createElement("button");
libraryCloseBtn.textContent = "Close ✖";
libraryCloseBtn.style.cssText = "margin-bottom:10px; padding:4px 8px; cursor:pointer;";
libraryCloseBtn.onclick = () => libraryPanel.style.display = "none";
libraryPanel.prepend(libraryCloseBtn);
// --- blank container for items ---
const libraryItemsContainer = document.createElement("div");
libraryItemsContainer.id = "libraryItems";
libraryPanel.appendChild(libraryItemsContainer);
// <-- ADD THIS LINE
renderLibrary();
let tempEPUB = null;
// --- get chapter info from EPUB ---
async function getEPUBChapterInfo(file) {
await ensureJSZip();
const zip = await JSZip.loadAsync(file);
const allFiles = Object.keys(zip.files);
// Find chapter files: ch<number>.xhtml
const chapterFiles = allFiles
.map(f => f.match(/^OEBPS\/ch(\d+)\.xhtml$/i))
.filter(Boolean)
.map(m => parseInt(m[1], 10));
const latestChapter = chapterFiles.length ? Math.max(...chapterFiles) : 0;
const totalChapters = chapterFiles.length;
return { latestChapter, totalChapters };
}
// --- EPUB import button ---
const importEPUBBtn = document.createElement("button");
importEPUBBtn.textContent = "Import EPUB";
importEPUBBtn.style.cssText = "margin-bottom:10px; padding:4px 8px; cursor:pointer;";
importEPUBBtn.onclick = () => {
const inputFile = document.createElement("input");
inputFile.type = "file";
inputFile.accept = ".epub";
inputFile.onchange = async (e) => {
if (!e.target.files.length) return;
const file = e.target.files[0];
// Clean title: remove .epub, WTR-LAB suffix, and leading "Chapter XXX - "
let title = file.name.replace(/\.epub$/i, "")
.replace(/- WTR-LAB.*$/i, "")
.replace(/^Chapter\s+\d+\s*[-:]?\s*/i, "")
.trim();
// Load existing library
const library = loadLibrary();
// Check if an entry with this clean title exists
const existingIndex = library.findIndex(e => e.title === title);
// Get chapter info from EPUB
const { latestChapter: epubLatest, totalChapters: epubTotal } = await getEPUBChapterInfo(file);
// If overwriting, preserve totalChapters from old entry
const totalChapters = existingIndex >= 0 ? library[existingIndex].totalChapters : epubTotal;
const latestChapter = epubLatest;
// Remove old entry if exists
if (existingIndex >= 0) library.splice(existingIndex, 1);
// Create new entry
const entry = {
id: "epub-" + Date.now(),
title,
coverUrl: "",
latestChapter, // progress from EPUB
totalChapters, // preserve old totalChapters if overwriting
file,
lastDownloaded: Date.now(),
inLocalStorage: true // visual indicator
};
library.push(entry);
saveLibrary(library);
renderLibrary();
};
inputFile.click();
};
libraryPanel.prepend(importEPUBBtn);
// --- Render Library ---
function renderLibrary() {
const listContainer = libraryPanel.querySelector("#libraryItems");
const library = loadLibrary();
const sortMode = libraryPanel.querySelector("#librarySort")?.value || "recent";
let sorted = [...library];
if (sortMode === "recent") sorted.sort((a,b) => (b.lastDownloaded||0) - (a.lastDownloaded||0));
else if (sortMode === "title") sorted.sort((a,b) => (a.title||"").localeCompare(b.title||""));
else if (sortMode === "latestChapter") sorted.sort((a,b) => (Number(b.latestChapter)||0) - (Number(a.latestChapter)||0));
const searchQuery = libraryPanel.querySelector("#librarySearch")?.value.trim().toLowerCase() || "";
const filtered = sorted.filter(e => e.title.toLowerCase().includes(searchQuery));
listContainer.innerHTML = filtered.map(entry => `
<div style="display:grid;grid-template-columns:60px 1fr 150px;gap:10px;align-items:center;padding:6px;border-bottom:1px solid #eee;">
<img src="${entry.coverUrl||''}" style="width:60px;height:80px;object-fit:cover;border:1px solid #ccc;" />
<div>
<div style="font-weight:bold;">${entry.title}</div>
<div style="font-size:12px;">Chapters: ${entry.latestChapter||0}/${entry.totalChapters||0}</div>
</div>
<div style="display:flex;gap:4px;">
<button data-id="${entry.id}" class="continueBtn">Continue</button>
<button data-id="${entry.id}" class="deleteBtn">Delete</button>
</div>
</div>
`).join("");
// --- Continue button ---
listContainer.querySelectorAll(".continueBtn").forEach(btn => {
btn.onclick = async () => {
const entry = library.find(e => e.id === btn.dataset.id);
if (!entry) return;
console.info("[Library] Continuing download for", entry.title);
const start = entry.latestChapter + 1;
try {
const newChapters = await continueDownload(entry, start);
if (entry.file) {
const reader = new FileReader();
reader.onload = async (ev) => {
const oldBytes = new Uint8Array(ev.target.result);
const mergedBytes = new Uint8Array([...oldBytes, ...newChapters]);
const mergedFile = new File([mergedBytes], entry.title + ".epub", { type: "application/epub+zip" });
entry.file = mergedFile;
entry.latestChapter += newChapters.length;
entry.totalChapters = Math.max(entry.totalChapters, entry.latestChapter);
saveLibrary(library);
renderLibrary();
libraryPanel.style.display = "none";
};
reader.readAsArrayBuffer(entry.file);
} else {
entry.latestChapter += newChapters.length;
entry.totalChapters = Math.max(entry.totalChapters, entry.latestChapter);
saveLibrary(library);
renderLibrary();
libraryPanel.style.display = "none";
}
} catch (err) {
console.warn("[Library] Download interrupted", err);
entry.interrupted = true;
saveLibrary(library);
}
};
});
// --- Delete button ---
listContainer.querySelectorAll(".deleteBtn").forEach(btn => {
btn.onclick = () => {
const index = library.findIndex(e => e.id === btn.dataset.id);
if (index >= 0) {
library.splice(index, 1);
saveLibrary(library);
renderLibrary();
}
};
});
}
// --- Add / Update Library Entry ---
function addToLibrary(novelId, novelTitle, coverUrl, totalChapters, latestChapter, file = null) {
const library = loadLibrary();
const now = Date.now();
const existing = library.find(e => e.id === novelId);
// Normalize title: replace ":" with "-"
const normalizedTitle = novelTitle.replace(/:/g, "-").trim();
if (existing) {
existing.totalChapters = totalChapters;
existing.latestChapter = latestChapter;
existing.lastDownloaded = now;
if (coverUrl) existing.coverUrl = coverUrl;
if (file) existing.file = file;
existing.title = normalizedTitle; // ensure overwrite uses normalized title
} else {
library.push({
id: novelId,
title: normalizedTitle, // store normalized title
coverUrl: coverUrl || '',
totalChapters,
latestChapter,
lastDownloaded: now,
file
});
}
saveLibrary(library);
renderLibrary();
}
// --- 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 (patched) ---
async function fetchChapterContent(order) {
const formData = { translate: "ai", language, raw_id: id, chapter_no: order };
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"
});
let json;
try {
json = await res.json();
} catch {
console.warn(`Chapter ${order}: Failed to parse JSON`);
throw new Error("Invalid JSON");
}
if (!json?.data?.data?.body) {
console.warn(`Chapter ${order}: No body in response`, json);
throw new Error("Missing body");
}
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 ?? "Untitled"}</h1><p>${processedText.replace(/\n/g,"<br>")}</p>`;
}
// --- build all content from selected (patched) ---
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, 1000)); // throttle 1s per chapter
} catch (err) {
console.error(`Unexpected error fetching chapter ${order}:`, err);
allContent.push(`<h1>${order}: (unexpected error)</h1>`);
}
}
console.info("[INFO] Finished fetching all chapters. Check console for any failed chapters.");
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:language>en</dc:language> <dc:creator>${escapeXml(novelAuthor || "WTRLAB")}</dc:creator><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. Try to find any <picture><source srcset> pointing to CDN
const pictureSources = Array.from(dom.querySelectorAll("picture source[srcset]"))
.map(s => s.srcset)
.filter(u => u && u.includes("/cdn/series/"));
if (pictureSources.length) return pictureSources[0];
// 2. Fallback: any <img> in .image-wrap or .cover pointing to CDN
const imgs = Array.from(dom.querySelectorAll(".image-wrap img, .cover img"))
.map(i => i.src)
.filter(u => u && u.includes("/cdn/series/") && !u.includes("/placeholder"));
if (imgs.length) return imgs[0];
// 3. Next.js JSON fallback
try {
const jsonText = dom.querySelector('script#__NEXT_DATA__')?.textContent;
if (jsonText) {
const j = JSON.parse(jsonText);
return j?.props?.pageProps?.series?.cover ||
j?.props?.pageProps?.novel?.cover ||
j?.props?.initialState?.series?.cover ||
null;
}
} catch (e) {}
// 4. If nothing found, return null
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).");
}
const downloadBtn = document.getElementById("downloadEpubBtn");
// --- helper: get novel title from breadcrumb ---
function getNovelTitleFromBreadcrumb() {
const a = document.querySelector(".breadcrumb-item.active a");
if (a && a.textContent.trim()) {
return a.textContent.trim();
}
return "Novel";
}
// --- download / add to library ---
downloadBtn.addEventListener("click", async () => {
console.info("[DOWNLOAD] Starting chapter download...");
const header = document.getElementById("menuHeader");
let indicator = header.querySelector(".download-indicator");
if (!indicator) {
indicator = document.createElement("span");
indicator.className = "download-indicator";
indicator.style.cssText = `
display:inline-block; margin-left:10px; padding:2px 6px;
background:#ffd700; color:#000; border-radius:8px;
font-size:12px; font-weight:bold;
animation: blink 1s infinite;
`;
indicator.textContent = "Downloading...";
header.appendChild(indicator);
}
indicator.style.display = "inline-block";
if (!document.getElementById("blinkAnimation")) {
const style = document.createElement("style");
style.id = "blinkAnimation";
style.textContent = `
@keyframes blink { 0%,50%,100% { opacity: 1; } 25%,75% { opacity: 0.3; } }
`;
document.head.appendChild(style);
}
const novelTitle = getNovelTitleFromBreadcrumb();
const novelId = location.pathname.split("/").pop();
const selectedChapters = Array.from(menu.querySelectorAll("#chaptersList input[type=checkbox]:checked"))
.map(cb => ({
order: parseInt(cb.dataset.order),
title: cb.parentElement.textContent.trim()
}));
let coverUrl = "";
try {
const imageWrap = document.querySelector("div.image-wrap picture source[srcset]");
if (imageWrap) coverUrl = imageWrap.srcset;
} catch (err) { console.warn("[DOWNLOAD] Could not grab cover URL:", err); }
const totalChapters = chapters.length;
libraryPanel.style.display = "block";
menu.style.display = "none";
// --- Load temp progress if any ---
const temp = loadTempProgress();
let successfulChapters = temp && temp.id === novelId ? temp.chapters : [];
let successfulOrders = temp && temp.id === novelId ? temp.orders : [];
for (let ch of selectedChapters) {
if (successfulOrders.includes(ch.order)) continue; // skip already downloaded
try {
const chapterContent = await fetchChapterContent(ch.order);
if (!chapterContent || chapterContent.trim() === "") throw new Error("Empty chapter content");
successfulChapters.push(chapterContent);
successfulOrders.push(ch.order);
// Save temp progress to localStorage
saveTempProgress({ id: novelId, title: novelTitle, coverUrl }, successfulChapters, successfulOrders);
addToLibrary(novelId, novelTitle, coverUrl, totalChapters, Math.max(...successfulOrders));
libraryPanel.scrollTop = libraryPanel.scrollHeight;
console.info(`[Library] Downloaded chapter ${ch.order}: ${ch.title}`);
} catch (err) {
console.error(`[DOWNLOAD] Chapter ${ch.order} failed:`, err);
securityAlert.textContent = "⚠️ Download paused due to security check. Refresh page to continue.";
securityAlert.style.display = "block";
indicator.style.display = "none";
return; // stop loop, keep temp saved
}
}
// All selected chapters downloaded, finalize EPUB
await downloadAsEPUB(novelTitle, successfulChapters, successfulOrders);
// Clear temp storage
clearTempProgress();
indicator.style.display = "none";
console.info("[DOWNLOAD] All chapters downloaded successfully.");
});
})();