Export fanfics to TXT/EPUB from mtslash thread pages.
// ==UserScript==
// @name MTSLASH Exporter
// @namespace https://www.mtslash.life/
// @version 1.0.3
// @description Export fanfics to TXT/EPUB from mtslash thread pages.
// @author qom
// @match *://www.mtslash.life/forum.php?mod=viewthread*
// @match *://www.mtslash.life/thread-*-*-*.html*
// @grant none
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
"use strict";
const APP_ID = "mtslash-exporter";
const STYLE_ID = `${APP_ID}-style`;
const PANEL_ID = `${APP_ID}-panel`;
const LAUNCHER_ID = `${APP_ID}-launcher`;
const STORAGE_KEY = `${APP_ID}-settings`;
const LOG_LIMIT = 120;
const CRC_TABLE = createCrc32Table();
const state = {
running: false,
cancelled: false,
logs: [],
};
const defaults = {
authorMode: "lz",
format: "epub",
chapterMode: "simple",
customHeadingPattern: "",
customHeadingFlags: "",
};
if (!isSupportedThreadPage(document, location.href)) {
return;
}
ensureStyles();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
function init() {
if (document.getElementById(PANEL_ID)) {
return;
}
const panel = buildPanel();
const launcher = buildLauncher(panel);
document.body.appendChild(panel);
document.body.appendChild(launcher);
setPanelVisibility(panel, launcher, true);
log("面板已注入,等待导出。");
}
function buildPanel() {
const settings = loadSettings();
const panel = document.createElement("section");
panel.id = PANEL_ID;
panel.innerHTML = `
<div class="${APP_ID}__header">
<strong>MTSLASH Exporter</strong>
<button class="${APP_ID}__close-button" type="button" data-role="closePanel" aria-label="关闭面板">
${closeSvg()}
</button>
</div>
<div class="${APP_ID}__row">
<label>范围</label>
<select data-role="authorMode">
<option value="all">全部楼层</option>
<option value="lz">仅楼主</option>
<option value="uid">指定 UID</option>
</select>
</div>
<div class="${APP_ID}__row" data-role="uidRow" hidden>
<label>UID</label>
<input data-role="uid" type="text" placeholder="输入目标用户 UID" />
</div>
<div class="${APP_ID}__row">
<label>格式</label>
<select data-role="format">
<option value="txt">TXT</option>
<option value="epub">EPUB</option>
</select>
</div>
<div class="${APP_ID}__row">
<label>章节</label>
<select data-role="chapterMode">
<option value="simple">Chapter N</option>
<option value="custom">自定义正则</option>
</select>
</div>
<div class="${APP_ID}__settings" data-role="settingsPanel">
<div class="${APP_ID}__field">
<label for="${APP_ID}-custom-pattern">分节正则</label>
<input id="${APP_ID}-custom-pattern" data-role="customHeadingPattern" type="text" placeholder="例如 ^(?:第[0-9一二三四五六七八九十]+章|番外)$" value="${escapeAttribute(settings.customHeadingPattern)}" />
</div>
<div class="${APP_ID}__field">
<label for="${APP_ID}-custom-flags">Flags</label>
<input id="${APP_ID}-custom-flags" data-role="customHeadingFlags" type="text" placeholder="例如 i" value="${escapeAttribute(settings.customHeadingFlags)}" />
</div>
<p class="${APP_ID}__hint">选择“自定义正则”时,按逐行匹配章节标题处理。</p>
</div>
<div class="${APP_ID}__row ${APP_ID}__actions">
<button class="${APP_ID}__button ${APP_ID}__button--primary" data-role="export">导出</button>
<button class="${APP_ID}__button ${APP_ID}__button--secondary" data-role="cancel" disabled>取消</button>
</div>
<div class="${APP_ID}__meta">
<div class="${APP_ID}__log-label">运行日志</div>
<div class="${APP_ID}__status" data-role="status">空闲</div>
</div>
<textarea class="${APP_ID}__log" data-role="log" readonly></textarea>
`;
const authorMode = panel.querySelector('[data-role="authorMode"]');
const uidInput = panel.querySelector('[data-role="uid"]');
const exportBtn = panel.querySelector('[data-role="export"]');
const cancelBtn = panel.querySelector('[data-role="cancel"]');
const closeBtn = panel.querySelector('[data-role="closePanel"]');
const chapterMode = panel.querySelector('[data-role="chapterMode"]');
const settingsPanel = panel.querySelector('[data-role="settingsPanel"]');
const patternInput = panel.querySelector('[data-role="customHeadingPattern"]');
const flagsInput = panel.querySelector('[data-role="customHeadingFlags"]');
const uidRow = panel.querySelector('[data-role="uidRow"]');
authorMode.value = defaults.authorMode;
panel.querySelector('[data-role="format"]').value = defaults.format;
chapterMode.value = settings.chapterMode || defaults.chapterMode;
uidRow.hidden = authorMode.value !== "uid";
authorMode.addEventListener("change", () => {
uidRow.hidden = authorMode.value !== "uid";
if (authorMode.value !== "uid") {
uidInput.value = "";
}
});
chapterMode.addEventListener("change", () => {
syncSettingsPanelState();
persistPanelSettings();
});
patternInput.addEventListener("change", persistPanelSettings);
flagsInput.addEventListener("change", persistPanelSettings);
function syncSettingsPanelState() {
settingsPanel.hidden = chapterMode.value !== "custom";
}
function persistPanelSettings() {
const normalizedFlags = normalizeRegexFlags(flagsInput.value);
flagsInput.value = normalizedFlags;
saveSettings({
chapterMode: chapterMode.value,
customHeadingPattern: patternInput.value.trim(),
customHeadingFlags: normalizedFlags,
});
}
syncSettingsPanelState();
closeBtn.addEventListener("click", () => {
const launcher = document.getElementById(LAUNCHER_ID);
setPanelVisibility(panel, launcher, false);
});
exportBtn.addEventListener("click", async () => {
if (state.running) {
return;
}
try {
await runExport(panel);
} catch (error) {
setStatus(panel, `失败:${error.message}`);
log(`失败:${error.stack || error.message}`);
} finally {
setRunning(panel, false);
}
});
cancelBtn.addEventListener("click", () => {
state.cancelled = true;
log("已请求取消,当前页结束后停止。");
setStatus(panel, "正在取消...");
});
return panel;
}
function buildLauncher(panel) {
const launcher = document.createElement("button");
launcher.id = LAUNCHER_ID;
launcher.type = "button";
launcher.innerHTML = gearSvg();
launcher.setAttribute("aria-label", "打开导出面板");
enableLauncherDragging(launcher);
launcher.addEventListener("click", () => {
if (launcher.dataset.dragged === "true") {
launcher.dataset.dragged = "false";
return;
}
setPanelVisibility(panel, launcher, true);
});
return launcher;
}
function setPanelVisibility(panel, launcher, visible) {
if (panel) {
panel.hidden = !visible;
}
if (launcher) {
launcher.hidden = visible;
}
}
function enableLauncherDragging(launcher) {
let dragState = null;
launcher.addEventListener("pointerdown", (event) => {
if (event.button !== 0) {
return;
}
const rect = launcher.getBoundingClientRect();
dragState = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
moved: false,
};
launcher.dataset.dragged = "false";
launcher.setPointerCapture(event.pointerId);
});
launcher.addEventListener("pointermove", (event) => {
if (!dragState || event.pointerId !== dragState.pointerId) {
return;
}
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (!dragState.moved && Math.hypot(deltaX, deltaY) < 15) {
return;
}
dragState.moved = true;
launcher.dataset.dragged = "true";
const left = clamp(event.clientX - dragState.offsetX, 8, window.innerWidth - launcher.offsetWidth - 8);
const top = clamp(event.clientY - dragState.offsetY, 8, window.innerHeight - launcher.offsetHeight - 8);
launcher.style.left = `${left}px`;
launcher.style.top = `${top}px`;
launcher.style.right = "auto";
launcher.style.bottom = "auto";
});
launcher.addEventListener("pointerup", (event) => {
if (!dragState || event.pointerId !== dragState.pointerId) {
return;
}
launcher.releasePointerCapture(event.pointerId);
const wasDragged = dragState.moved;
dragState = null;
launcher.dataset.dragged = "false";
if (!wasDragged) {
setPanelVisibility(panel, launcher, true);
}
});
launcher.addEventListener("pointercancel", (event) => {
if (!dragState || event.pointerId !== dragState.pointerId) {
return;
}
launcher.releasePointerCapture(event.pointerId);
dragState = null;
launcher.dataset.dragged = "false";
});
launcher.addEventListener("click", (event) => {
event.preventDefault();
// Handled in pointerup to prevent touch capture bugs
});
}
async function runExport(panel) {
const authorMode = panel.querySelector('[data-role="authorMode"]').value;
const format = panel.querySelector('[data-role="format"]').value;
const chapterMode = panel.querySelector('[data-role="chapterMode"]').value;
const targetUidRaw = panel.querySelector('[data-role="uid"]').value.trim();
const targetUid = targetUidRaw || undefined;
const customHeadingPattern = panel.querySelector('[data-role="customHeadingPattern"]').value.trim();
const customHeadingFlags = normalizeRegexFlags(panel.querySelector('[data-role="customHeadingFlags"]').value);
const customHeadingRegex = buildCustomHeadingRegex(chapterMode, customHeadingPattern, customHeadingFlags);
if (authorMode === "uid" && !/^\d+$/.test(targetUid || "")) {
throw new Error("指定作者模式需要填写纯数字 UID");
}
state.logs = [];
state.cancelled = false;
setRunning(panel, true);
setStatus(panel, "准备抓取...");
log(`开始导出,格式=${format},范围=${authorMode}${targetUid ? `:${targetUid}` : ""},章节模式=${chapterMode}`);
const context = extractThreadContext(document, location.href);
const pageTargets = createPageTargets(context);
log(`目标页数:${pageTargets.length},当前线程总页数:${context.pageCount}`);
const collectedPosts = [];
const failures = [];
let firstPageDoc = context.currentPage === 1 ? document : null;
for (let i = 0; i < pageTargets.length; i += 1) {
if (state.cancelled) {
break;
}
const target = pageTargets[i];
setStatus(panel, `抓取第 ${target.page} 页 (${i + 1}/${pageTargets.length})`);
try {
const pageDoc = target.page === context.currentPage
? document
: await fetchThreadPage(target.url);
const pageContext = extractThreadContext(pageDoc, target.url);
const posts = extractPosts(pageDoc, pageContext);
if (pageContext.currentPage === 1 && !firstPageDoc) {
firstPageDoc = pageDoc;
}
collectedPosts.push(...posts);
log(`第 ${target.page} 页完成,解析 ${posts.length} 条帖子`);
} catch (error) {
failures.push({ page: target.page, message: error.message });
log(`第 ${target.page} 页失败:${error.message}`);
}
if (i < pageTargets.length - 1 && !state.cancelled) {
await delay(randomInt(1200, 2200));
}
}
const filtered = filterPosts(collectedPosts, context, authorMode, targetUid);
if (!filtered.length) {
throw new Error("过滤后没有可导出的正文");
}
const frontMatter = extractFrontMatter(firstPageDoc || document);
const exportPayload = {
context,
posts: filtered,
authorMode,
chapterMode,
customHeadingRegex,
targetUid,
frontMatter,
failures,
partial: state.cancelled || failures.length > 0,
};
const normalizedFM = normalizeFrontMatter(frontMatter, context);
const resolvedAuthor = normalizedFM["作者"] || context.lzName || filtered[0]?.authorName;
const filename = buildFilename(context.title, authorMode, resolvedAuthor, format);
if (format === "epub") {
setStatus(panel, "正在生成 EPUB...");
const epubBlob = buildEpub(exportPayload);
downloadBlob(filename, epubBlob);
} else {
const txt = renderTxt(exportPayload);
downloadTextFile(filename, txt);
}
const doneMessage = state.cancelled
? `已取消并导出已完成部分,共 ${filtered.length} 条`
: failures.length
? `部分成功,已导出 ${filtered.length} 条,失败页 ${failures.map((item) => item.page).join(", ")}`
: `导出完成,共 ${filtered.length} 条`;
setStatus(panel, doneMessage);
log(doneMessage);
}
function extractThreadContext(doc, url) {
const normalizedUrl = new URL(url, location.origin);
const title = extractThreadTitle(doc);
if (!title) {
throw new Error("未找到帖子标题,当前页面可能不是可访问的线程页");
}
const pageData = parsePageData(doc, normalizedUrl);
const tid = extractTid(normalizedUrl, doc);
const posts = collectPostNodes(doc);
if (!posts.length) {
throw new Error("未找到帖子楼层,页面可能是权限页或异常页");
}
// Extract LZ (original poster) info.
// On non-first pages, posts[0] is NOT the OP. We use multiple strategies
// ordered by reliability:
//
// 1. #tath header — desktop template, explicitly labeled "楼主: XXX".
// Contains <a href="space-uid-XXXXX.html">楼主名</a>.
//
// 2. Mobile "只看楼主" link — mobile template only, a single link whose
// TEXT is "只看楼主" with authorid=<OP_UID> in href.
// IMPORTANT: Desktop has "只看该作者" links per-post with different
// authorids — those are NOT the OP! Must match by link text.
//
// 3. posts[0] fallback — only correct on page 1.
let lzUid = null;
let lzName = null;
// Strategy 1: #tath header (desktop template — most reliable)
const tathHeader = doc.querySelector('#tath');
if (tathHeader) {
// #tath has two links: avatar (no text) and name link (has text).
// We need the one with actual text content.
const tathLinks = tathHeader.querySelectorAll('a[href*="space-uid-"], a[href*="mod=space"]');
for (const lzLink of tathLinks) {
const href = lzLink.getAttribute('href') || '';
const uidMatch = href.match(/uid[-=](\d+)/);
const name = textOf(lzLink) || lzLink.getAttribute('title') || '';
if (uidMatch) {
lzUid = uidMatch[1];
if (name) {
lzName = name;
}
}
}
}
// Strategy 2: Mobile "只看楼主" link (must match by text, not just href)
if (!lzUid) {
const allAuthoridLinks = doc.querySelectorAll('a[href*="authorid"]');
for (const link of allAuthoridLinks) {
const linkText = (link.textContent || '').trim();
if (linkText === '只看楼主') {
const href = link.getAttribute('href') || '';
const uidMatch = href.match(/authorid[=](\d+)/);
if (uidMatch) {
lzUid = uidMatch[1];
}
break;
}
}
}
// Strategy 3: First post fallback (works only on page 1)
if (!lzUid) {
lzUid = extractUidFromPost(posts[0]);
lzName = extractAuthorName(posts[0]);
}
// If we got lzUid but not lzName, try to find the name from matching posts
if (lzUid && !lzName) {
for (const postNode of posts) {
if (extractUidFromPost(postNode) === lzUid) {
lzName = extractAuthorName(postNode);
break;
}
}
}
// Sanitize lzName: filter out known Discuz UI labels that could
// be accidentally captured as author names.
const uiNoiseNames = ['只看楼主', '只看该作者', '收藏', '回复', '举报', '未知作者'];
if (lzName && uiNoiseNames.includes(lzName)) {
lzName = null;
}
return {
title,
tid,
pageCount: pageData.pageCount,
currentPage: pageData.currentPage,
canonicalBase: pageData.canonicalBase,
canonicalUrl: buildCanonicalThreadUrl(tid),
currentUrl: normalizedUrl.toString(),
lzUid,
lzName,
};
}
function parsePageData(doc, currentUrl) {
const pager = findThreadPager(doc, currentUrl);
const pagerValues = new Set();
const currentPageFromUrl = parsePageValue(currentUrl) || 1;
const mobilePageSelect = pager ? pager.querySelector('select#dumppage, select[name="page"]') : null;
if (pager) {
pager.querySelectorAll("a, strong, em").forEach((node) => {
const value = parseInt((node.textContent || "").trim(), 10);
if (!Number.isNaN(value)) {
pagerValues.add(value);
}
if (!(node instanceof HTMLAnchorElement)) {
return;
}
try {
const target = new URL(node.getAttribute("href"), currentUrl);
if (!isSameThreadPage(target, currentUrl)) {
return;
}
const pageValue = parsePageValue(target);
if (pageValue) {
pagerValues.add(pageValue);
}
} catch (_error) {
// Ignore malformed pagination hrefs.
}
});
}
if (mobilePageSelect) {
Array.from(mobilePageSelect.options).forEach((option) => {
const value = parseInt(option.value || option.textContent || "", 10);
if (!Number.isNaN(value)) {
pagerValues.add(value);
}
});
}
const pageInput = pager ? pager.querySelector('label input[type="text"]') : null;
const mobileCurrentOption = mobilePageSelect
? mobilePageSelect.options[mobilePageSelect.selectedIndex]
: null;
const currentPage = pageInput
? parseInt(pageInput.value, 10) || currentPageFromUrl
: mobileCurrentOption
? parseInt(mobileCurrentOption.value || mobileCurrentOption.textContent || "", 10) || currentPageFromUrl
: currentPageFromUrl;
let pageCount = Math.max(...Array.from(pagerValues), currentPage, 1);
const pageLabel = pager ? pager.querySelector("label") : null;
const match = pageLabel ? pageLabel.textContent.match(/\/\s*(\d+)\s*页/) : null;
if (match) {
pageCount = parseInt(match[1], 10);
} else if (mobilePageSelect && mobilePageSelect.options.length) {
pageCount = Math.max(
...Array.from(mobilePageSelect.options).map((option) => (
parseInt(option.value || option.textContent || "", 10) || 0
)),
currentPage,
1,
);
}
return {
currentPage,
pageCount,
canonicalBase: currentUrl.toString(),
};
}
function createPageTargets(context) {
const targets = [];
for (let page = 1; page <= context.pageCount; page += 1) {
const url = buildPageUrl(context, page);
targets.push({ page, url });
}
return targets;
}
function buildPageUrl(context, page) {
const currentUrl = new URL(context.currentUrl || location.href, location.origin);
if (currentUrl.pathname.includes("thread-")) {
currentUrl.pathname = `/thread-${context.tid}-${page}-1.html`;
currentUrl.search = "";
return currentUrl.toString();
}
currentUrl.searchParams.set("mod", "viewthread");
currentUrl.searchParams.set("tid", context.tid);
currentUrl.searchParams.set("page", String(page));
return currentUrl.toString();
}
function buildCanonicalThreadUrl(tid) {
return `${location.origin}/thread-${tid}-1-1.html`;
}
function findThreadPager(doc, currentUrl) {
const pagers = Array.from(doc.querySelectorAll(".pg, .page"));
if (!pagers.length) {
return null;
}
let bestPager = null;
let bestScore = -1;
pagers.forEach((pager) => {
let score = 0;
pager.querySelectorAll("a, strong, em").forEach((node) => {
const text = (node.textContent || "").trim();
if (/^\d+$/.test(text)) {
score += 1;
}
if (!(node instanceof HTMLAnchorElement)) {
return;
}
try {
const target = new URL(node.getAttribute("href"), currentUrl);
if (isSameThreadPage(target, currentUrl)) {
score += 3;
}
} catch (_error) {
// Ignore malformed pagination hrefs.
}
});
if (pager.querySelector('label input[type="text"]')) {
score += 2;
}
const mobilePageSelect = pager.querySelector('select#dumppage, select[name="page"]');
if (mobilePageSelect) {
score += 4;
score += mobilePageSelect.querySelectorAll("option").length;
}
if (score > bestScore) {
bestScore = score;
bestPager = pager;
}
});
return bestScore > 0 ? bestPager : pagers[0];
}
async function fetchThreadPage(url) {
let currentUrl = url;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
log(`请求第 ${attempt} 次:${currentUrl}`);
const response = await fetch(currentUrl, {
credentials: "include",
method: "GET",
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const buffer = await response.arrayBuffer();
const html = decodeHtml(buffer, response);
// Detect Discuz _dsign JS challenge pages:
// These are tiny pages (< 4KB) containing only a <script> that does
// location.replace(url + '?_dsign=xxx'). fetch() can't execute JS,
// so we parse out the redirect URL and retry.
const challengeUrl = extractDsignChallengeUrl(html, currentUrl);
if (challengeUrl) {
log(`检测到 _dsign 安全验证,重定向至: ${challengeUrl}`);
currentUrl = challengeUrl;
await delay(randomInt(300, 800));
continue;
}
if (looksLikeBlockedPage(html)) {
throw new Error("命中登录/权限/重载提示页");
}
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
if (!isSupportedThreadPage(doc, url)) {
throw new Error("返回内容不是有效线程页");
}
return doc;
} catch (error) {
if (attempt >= 3) {
throw error;
}
await delay(Math.min(1500 * Math.pow(2, attempt - 1), 8000) + randomInt(500, 1500));
}
}
throw new Error("抓取失败");
}
/**
* Detect and extract the redirect URL from Discuz's _dsign JS challenge page.
*
* When attackevasive is enabled, Discuz returns a small page containing only
* obfuscated JS that calls location.replace(url + '?_dsign=xxx').
* Since fetch() doesn't execute JS, we need to parse the URL ourselves.
*
* Returns the redirect URL string, or null if this is not a challenge page.
*/
function extractDsignChallengeUrl(html, originalUrl) {
// Quick check: challenge pages are very small and script-only
if (html.length > 5000) {
return null;
}
if (!html.includes('location') || !html.includes('replace')) {
return null;
}
// Must not contain normal page indicators
if (html.includes('postmessage_') || html.includes('thread_subject')) {
return null;
}
// Try to extract the URL by executing the challenge JS in a sandboxed way.
// The challenge script typically has TWO URL assignments:
// 1. location.replace(fullURL_with_dsign) — the real redirect
// 2. window.href = truncatedURL — a decoy/fallback
// We track them separately and prefer the location.replace capture.
try {
const scriptMatch = html.match(/<script[^>]*>([\s\S]*?)<\/script>/i);
if (!scriptMatch) {
return null;
}
const scriptCode = scriptMatch[1];
let locationUrl = null;
let windowUrl = null;
const locationProxy = new Proxy({}, {
set(_target, prop, value) {
if (typeof value === 'string' && value.includes('thread-')) {
locationUrl = value;
}
return true;
},
get(_target, prop) {
if (prop === 'replace' || prop === 'assign') {
return function(url) { locationUrl = url; };
}
if (prop === 'href') {
return '';
}
return undefined;
},
});
const windowProxy = new Proxy({}, {
set(_target, prop, value) {
if (typeof value === 'string' && value.includes('thread-')) {
windowUrl = value;
}
return true;
},
get(_target, prop) {
if (prop === 'href') return '';
return undefined;
},
});
// Build and run the sandboxed function
const fn = new Function(
'location', 'window',
scriptCode,
);
fn(locationProxy, windowProxy);
// Prefer the URL captured from location.replace (it has _dsign),
// fall back to window.href, and finally prefer whichever has _dsign
const candidates = [locationUrl, windowUrl].filter(Boolean);
const bestUrl = candidates.find((u) => u.includes('_dsign') || u.includes('dsign')) || candidates[0];
if (bestUrl) {
const resolved = new URL(bestUrl, originalUrl);
return resolved.toString();
}
} catch (_error) {
// Sandboxed eval failed — fall back to regex extraction
}
// Fallback: try to find _dsign value via regex in the raw script
const dsignMatch = html.match(/_dsign['"]?\s*[=+]\s*['"]([a-f0-9]+)/i) ||
html.match(/dsign=([a-f0-9]{6,})/i);
if (dsignMatch) {
try {
const base = new URL(originalUrl);
base.searchParams.set('_dsign', dsignMatch[1]);
return base.toString();
} catch (_error) {
// ignore
}
}
return null;
}
function decodeHtml(buffer, response) {
const charset = detectCharset(response);
try {
return new TextDecoder(charset).decode(buffer);
} catch (_error) {
return new TextDecoder("utf-8").decode(buffer);
}
}
function detectCharset(response) {
const contentType = response.headers.get("content-type") || "";
const headerMatch = contentType.match(/charset=([^;]+)/i);
if (headerMatch) {
return normalizeCharset(headerMatch[1]);
}
if (location.hostname === "www.mtslash.life") {
return "gbk";
}
return "utf-8";
}
function normalizeCharset(charset) {
const value = String(charset || "").trim().toLowerCase();
if (value === "gb2312" || value === "gbk" || value === "gb18030") {
return "gbk";
}
return value || "utf-8";
}
function extractPosts(doc, context) {
const postNodes = collectPostNodes(doc);
return postNodes.map((postNode) => {
const messageNode = findMessageNode(postNode);
if (!messageNode) {
return null;
}
const cleanFragment = messageNode.cloneNode(true);
cleanPostFragment(cleanFragment);
const rawHtml = messageNode.innerHTML;
const cleanHtml = cleanFragment.innerHTML;
const text = htmlToText(cleanFragment);
return {
postId: postNode.id.replace("post_", ""),
page: context.currentPage,
floor: extractFloor(postNode),
authorName: extractAuthorName(postNode),
authorUid: extractUidFromPost(postNode),
authorProfileUrl: extractAuthorProfileUrl(postNode),
publishedAt: extractPublishedAt(postNode),
isLz: extractUidFromPost(postNode) === context.lzUid,
fromMobile: /来自手机/.test(textOf(postNode.querySelector(".authi"))),
rawHtml,
cleanHtml,
text,
};
}).filter(Boolean);
}
function cleanPostFragment(root) {
root.querySelectorAll([
".pstatus",
".quote",
"blockquote",
".aimg_tip",
".pct",
".sign",
"script",
"style",
].join(",")).forEach((node) => node.remove());
root.querySelectorAll("img").forEach((img) => {
const alt = (img.getAttribute("alt") || "").trim();
const replacement = alt ? `[图片:${alt}]` : "[图片]";
img.replaceWith(document.createTextNode(replacement));
});
}
function htmlToText(root) {
const working = root.cloneNode(true);
working.querySelectorAll("br").forEach((br) => {
br.replaceWith("\n");
});
const blockSelectors = [
"p",
"div",
"section",
"article",
"li",
"tr",
"td",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
];
working.querySelectorAll(blockSelectors.join(",")).forEach((node) => {
if (!node.textContent || !node.textContent.trim()) {
return;
}
if (!node.textContent.endsWith("\n")) {
node.appendChild(document.createTextNode("\n"));
}
});
let text = working.textContent || "";
text = text.replace(/\r/g, "");
text = text.replace(/[ \t]+\n/g, "\n");
text = text.replace(/\n{3,}/g, "\n\n");
text = text.replace(/^\s+|\s+$/g, "");
const noisePatterns = [
/本帖最后由.+?编辑/g,
/电梯直达/g,
/显示全部楼层/g,
/倒序浏览/g,
/阅读模式/g,
/点评\s*回复\s*举报/g,
/只看楼主/g,
/只看该作者/g,
];
noisePatterns.forEach((pattern) => {
text = text.replace(pattern, "");
});
return text.replace(/\n{3,}/g, "\n\n").trim();
}
function extractFrontMatter(doc) {
const firstPost = collectPostNodes(doc)[0];
if (!firstPost) {
return {};
}
const knownFieldOrder = ["标题", "原作", "作者", "译者", "分级", "警告", "配对", "标签", "摘要", "注释", "原文地址"];
const aliasMap = {
cp: "配对",
CP: "配对",
配對: "配对",
tag: "标签",
tags: "标签",
Tags: "标签",
summary: "摘要",
Summary: "摘要",
简介: "摘要",
notes: "注释",
Notes: "注释",
备注: "注释",
note: "注释",
原文链接: "原文地址",
原文: "原文地址",
链接: "原文地址",
link: "原文地址",
Link: "原文地址",
分类: "分类",
类型: "分类",
};
const result = {};
const typeRows = Array.from(firstPost.querySelectorAll(".typeoption tr, .cgtl tr"));
typeRows.forEach((row) => {
const key = textOf(row.querySelector("th")).replace(/[::]\s*$/, "").trim();
const value = textOf(row.querySelector("td"));
if (!key || !value) {
return;
}
const normalizedKey = aliasMap[key] || key;
if (!(normalizedKey in result)) {
result[normalizedKey] = value;
}
});
const message = findMessageNode(firstPost);
if (!message) {
return orderFrontMatter(result, knownFieldOrder);
}
const cleanFragment = message.cloneNode(true);
cleanPostFragment(cleanFragment);
const lines = extractStructuredLines(cleanFragment).slice(0, 40);
lines.forEach((line) => {
const match = line.match(/^([^::]{1,8})\s*[::]\s*(.+)$/);
if (!match) {
return;
}
const rawKey = match[1].trim();
const value = match[2].trim();
if (!value) {
return;
}
const normalizedKey = aliasMap[rawKey] || rawKey;
if (!knownFieldOrder.includes(normalizedKey) && rawKey.length > 8) {
return;
}
if (!(normalizedKey in result)) {
result[normalizedKey] = value;
}
});
return orderFrontMatter(result, knownFieldOrder);
}
function orderFrontMatter(result, knownFieldOrder) {
const ordered = {};
knownFieldOrder.forEach((key) => {
if (key in result) {
ordered[key] = result[key];
}
});
Object.keys(result).forEach((key) => {
if (!(key in ordered)) {
ordered[key] = result[key];
}
});
return ordered;
}
function extractStructuredLines(root) {
const working = root.cloneNode(true);
working.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
let text = working.textContent || "";
text = text.replace(/\r/g, "");
text = text.replace(/\u00a0/g, " ");
text = text.replace(/[ \t]+\n/g, "\n");
text = text.replace(/\n{3,}/g, "\n\n");
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function filterPosts(posts, context, authorMode, targetUid) {
const deduped = dedupePosts(posts).sort((a, b) => {
if (a.page !== b.page) {
return a.page - b.page;
}
return (a.floor || 0) - (b.floor || 0);
});
if (authorMode === "all") {
return deduped.filter((post) => post.text);
}
if (authorMode === "lz") {
return deduped.filter((post) => post.authorUid && post.authorUid === context.lzUid && post.text);
}
return deduped.filter((post) => post.authorUid === targetUid && post.text);
}
function dedupePosts(posts) {
const map = new Map();
posts.forEach((post) => {
if (!map.has(post.postId)) {
map.set(post.postId, post);
}
});
return Array.from(map.values());
}
function renderTxt({ context, posts, authorMode, targetUid, frontMatter, failures, partial }) {
const lines = [];
const normalizedFrontMatter = normalizeFrontMatter(frontMatter, context);
const mainAuthor = authorMode === "lz"
? normalizedFrontMatter["作者"] || context.lzName
: authorMode === "uid"
? `${posts[0]?.authorName || ""} (${targetUid})`
: normalizedFrontMatter["作者"] || context.lzName || posts[0]?.authorName || "未知作者";
lines.push(`标题: ${context.title}`);
lines.push(`作者: ${mainAuthor}`);
lines.push(`来源: ${context.canonicalUrl || context.currentUrl}`);
lines.push("");
const fmEntries = Object.entries(normalizedFrontMatter || {}).filter(([key, value]) => {
if (!value) {
return false;
}
if (key === "标题") {
return false;
}
if (key === "作者" && value === mainAuthor) {
return false;
}
return true;
});
if (fmEntries.length) {
lines.push("作品信息:");
fmEntries.forEach(([key, value]) => {
lines.push(`${key}: ${value}`);
});
lines.push("");
}
if (partial) {
lines.push("状态: 部分导出");
if (failures.length) {
lines.push(`失败页: ${failures.map((item) => item.page).join(", ")}`);
}
lines.push("");
}
lines.push("==================================================");
lines.push("");
const groupedSingleAuthor = authorMode !== "all";
posts.forEach((post, index) => {
if (!groupedSingleAuthor) {
lines.push(`[第 ${post.floor || "?"} 楼] ${post.authorName}${post.publishedAt ? ` / ${post.publishedAt}` : ""}`);
lines.push("");
} else if (index > 0) {
lines.push("");
lines.push("");
}
lines.push(post.text);
});
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
}
function normalizeFrontMatter(frontMatter, context) {
const orderedKeys = ["标题", "原作", "作者", "译者", "分级", "警告", "配对", "标签", "摘要", "注释", "原文地址"];
const normalized = {};
const source = frontMatter || {};
orderedKeys.forEach((key) => {
if (source[key]) {
normalized[key] = source[key];
}
});
Object.keys(source).forEach((key) => {
if (!(key in normalized) && source[key]) {
normalized[key] = source[key];
}
});
if (!normalized["标题"]) {
normalized["标题"] = context.title;
}
if (!normalized["作者"] && context.lzName) {
normalized["作者"] = context.lzName;
}
return normalized;
}
function buildFilename(title, authorMode, authorName, format) {
const ext = format === "epub" ? "epub" : "txt";
if (authorMode === "all" || !authorName) {
return `${sanitizeFilename(title)}.${ext}`;
}
return `${sanitizeFilename(`${title} - ${authorName}`)}.${ext}`;
}
function downloadTextFile(filename, text) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
downloadBlob(filename, blob);
}
function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
function buildEpub({ context, posts, authorMode, chapterMode, customHeadingRegex, targetUid, frontMatter, failures, partial }) {
const normalizedFrontMatter = normalizeFrontMatter(frontMatter, context);
const mainAuthor = authorMode === "lz"
? normalizedFrontMatter["作者"] || context.lzName
: authorMode === "uid"
? `${posts[0]?.authorName || ""}${targetUid ? ` (${targetUid})` : ""}`
: normalizedFrontMatter["作者"] || context.lzName || posts[0]?.authorName || "未知作者";
const bookId = `urn:uuid:${makeUuidLike(context.tid, context.title, mainAuthor)}`;
const lang = "zh-CN";
const nowIso = new Date().toISOString();
const chapters = buildEpubChapters(posts, chapterMode, customHeadingRegex);
const infoXhtml = renderInfoPage(context, normalizedFrontMatter, failures, partial, mainAuthor);
const navXhtml = renderNavPage(context.title, chapters);
const opf = renderContentOpf({
context,
mainAuthor,
normalizedFrontMatter,
bookId,
lang,
nowIso,
chapters,
});
const files = [
{ name: "mimetype", data: "application/epub+zip", compress: false },
{ name: "META-INF/container.xml", data: renderContainerXml(), compress: false },
{ name: "OEBPS/nav.xhtml", data: navXhtml, compress: false },
{ name: "OEBPS/text/info.xhtml", data: infoXhtml, compress: false },
{ name: "OEBPS/content.opf", data: opf, compress: false },
...chapters.map((chapter, index) => ({
name: `OEBPS/text/chapter-${index + 1}.xhtml`,
data: renderChapterXhtml(context.title, chapter),
compress: false,
})),
];
const zipBytes = createZip(files);
return new Blob([zipBytes], { type: "application/epub+zip" });
}
function buildEpubChapters(posts, chapterMode, customHeadingRegex) {
if (chapterMode === "custom" && customHeadingRegex) {
const customChapters = buildRegexEpubChapters(posts, customHeadingRegex);
if (customChapters.length) {
return customChapters.map((chapter, index) => ({
id: `chapter-${index + 1}`,
navTitle: chapter.headingTitle || `Chapter ${index + 1}`,
headingTitle: chapter.headingTitle || "",
body: chapter.body,
}));
}
}
return posts.map((post, index) => ({
id: `chapter-${index + 1}`,
navTitle: `Chapter ${index + 1}`,
headingTitle: "",
body: post.text,
}));
}
function buildRegexEpubChapters(posts, headingRegex) {
const chapters = [];
let current = null;
posts.forEach((post) => {
const segments = splitPostIntoSegments(post.text, headingRegex);
if (!segments.length) {
return;
}
segments.forEach((segment) => {
if (segment.headingTitle) {
if (current && current.body.trim()) {
chapters.push(current);
}
current = {
headingTitle: segment.headingTitle,
body: segment.body,
};
return;
}
if (!current) {
current = {
headingTitle: "",
body: segment.body,
};
return;
}
current.body = `${current.body}\n\n${segment.body}`.trim();
});
});
if (current && current.body.trim()) {
chapters.push(current);
}
const usefulChapters = chapters.filter((chapter) => chapter.body && chapter.body.trim());
if (usefulChapters.length <= 1) {
return [];
}
return usefulChapters;
}
function splitPostIntoSegments(text, headingRegex) {
const lines = String(text || "").split(/\n+/).map((line) => line.trim());
const segments = [];
let currentHeading = "";
let currentLines = [];
lines.forEach((line) => {
if (!line) {
if (currentLines.length) {
currentLines.push("");
}
return;
}
if (isHeadingLine(line, headingRegex)) {
if (currentLines.length) {
segments.push({
headingTitle: currentHeading,
body: currentLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(),
});
}
currentHeading = line;
currentLines = [];
return;
}
currentLines.push(line);
});
if (currentHeading || currentLines.length) {
segments.push({
headingTitle: currentHeading,
body: currentLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(),
});
}
return segments.filter((segment) => segment.headingTitle || segment.body);
}
function isHeadingLine(line, headingRegex) {
const value = String(line || "").trim();
if (!value || value.length > 80 || !headingRegex) {
return false;
}
headingRegex.lastIndex = 0;
return headingRegex.test(value);
}
function renderInfoPage(context, frontMatter, failures, partial, mainAuthor) {
const infoRows = [];
infoRows.push(["标题", context.title]);
infoRows.push(["作者", mainAuthor]);
infoRows.push(["来源", context.canonicalUrl || context.currentUrl]);
Object.entries(frontMatter).forEach(([key, value]) => {
if (!value || key === "标题") {
return;
}
if (key === "作者" && value === mainAuthor) {
return;
}
infoRows.push([key, value]);
});
if (partial) {
infoRows.push(["状态", "部分导出"]);
if (failures.length) {
infoRows.push(["失败页", failures.map((item) => item.page).join(", ")]);
}
}
const rowsHtml = infoRows.map(([key, value]) => (
`<tr><th>${escapeXml(key)}</th><td>${escapeXml(value)}</td></tr>`
)).join("");
return wrapXhtmlDocument("作品信息", `
<section class="meta-page">
<h1>作品信息</h1>
<table class="meta-table">
<tbody>${rowsHtml}</tbody>
</table>
</section>
`);
}
function renderNavPage(title, chapters) {
const items = [
`<li><a href="text/info.xhtml">作品信息</a></li>`,
...chapters.map((chapter, index) => `<li><a href="text/chapter-${index + 1}.xhtml">${escapeXml(chapter.navTitle)}</a></li>`),
].join("");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>${escapeXml(title)}</title>
</head>
<body>
<nav epub:type="toc" id="toc">
<h1>${escapeXml(title)}</h1>
<ol>${items}</ol>
</nav>
</body>
</html>`;
}
function renderContentOpf({ context, mainAuthor, normalizedFrontMatter, bookId, lang, nowIso, chapters }) {
const manifestItems = [
`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
`<item id="info" href="text/info.xhtml" media-type="application/xhtml+xml"/>`,
...chapters.map((_, index) => `<item id="chapter-${index + 1}" href="text/chapter-${index + 1}.xhtml" media-type="application/xhtml+xml"/>`),
].join("\n ");
const spineItems = [
`<itemref idref="info"/>`,
...chapters.map((_, index) => `<itemref idref="chapter-${index + 1}"/>`),
].join("\n ");
const subjects = [];
["原作", "分级", "警告", "配对", "标签"].forEach((key) => {
if (normalizedFrontMatter[key]) {
subjects.push(`<dc:subject>${escapeXml(`${key}:${normalizedFrontMatter[key]}`)}</dc:subject>`);
}
});
return `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid" xml:lang="${lang}">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="bookid">${escapeXml(bookId)}</dc:identifier>
<dc:title>${escapeXml(context.title)}</dc:title>
<dc:creator>${escapeXml(mainAuthor)}</dc:creator>
<dc:language>${lang}</dc:language>
<dc:source>${escapeXml(context.canonicalUrl || context.currentUrl)}</dc:source>
${subjects.join("\n ")}
<meta property="dcterms:modified">${escapeXml(nowIso.replace(/\.\d{3}Z$/, "Z"))}</meta>
</metadata>
<manifest>
${manifestItems}
</manifest>
<spine>
${spineItems}
</spine>
</package>`;
}
function renderContainerXml() {
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 renderChapterXhtml(_bookTitle, chapter) {
const bodyHtml = textToXhtml(chapter.body);
return wrapXhtmlDocument(chapter.navTitle, `
<article>
${chapter.headingTitle ? `<h1>${escapeXml(chapter.headingTitle)}</h1>` : ""}
${bodyHtml}
</article>
`);
}
function wrapXhtmlDocument(title, bodyInnerHtml) {
const safeTitle = escapeXml(title);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>${safeTitle}</title>
</head>
<body>
${bodyInnerHtml}
</body>
</html>`;
}
function textToXhtml(text) {
const blocks = String(text || "").split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
return blocks.map((block) => {
const withBreaks = escapeXml(block).replace(/\n/g, "<br/>");
return `<p>${withBreaks}</p>`;
}).join("\n");
}
function makeUuidLike(...parts) {
const seed = parts.join("|");
let hash = 2166136261 >>> 0;
for (let i = 0; i < seed.length; i += 1) {
hash ^= seed.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
const hex = (`00000000${(hash >>> 0).toString(16)}`).slice(-8);
return `${hex}${hex.slice(0, 4)}-${hex.slice(4, 8)}-4000-8000-${hex}${hex}`;
}
function createZip(entries) {
const encoder = new TextEncoder();
const localParts = [];
const centralParts = [];
const records = [];
let offset = 0;
entries.forEach((entry) => {
const nameBytes = encoder.encode(entry.name);
const dataBytes = typeof entry.data === "string" ? encoder.encode(entry.data) : new Uint8Array(entry.data);
const crc = crc32(dataBytes);
const localHeader = new Uint8Array(30 + nameBytes.length);
const localView = new DataView(localHeader.buffer);
writeUint32(localView, 0, 0x04034b50);
writeUint16(localView, 4, 20);
writeUint16(localView, 6, 0);
writeUint16(localView, 8, 0);
writeUint16(localView, 10, 0);
writeUint16(localView, 12, 0);
writeUint32(localView, 14, crc);
writeUint32(localView, 18, dataBytes.length);
writeUint32(localView, 22, dataBytes.length);
writeUint16(localView, 26, nameBytes.length);
writeUint16(localView, 28, 0);
localHeader.set(nameBytes, 30);
localParts.push(localHeader, dataBytes);
records.push({
nameBytes,
dataBytes,
crc,
offset,
});
offset += localHeader.length + dataBytes.length;
});
const centralOffset = offset;
let centralSize = 0;
records.forEach((record) => {
const header = new Uint8Array(46 + record.nameBytes.length);
const view = new DataView(header.buffer);
writeUint32(view, 0, 0x02014b50);
writeUint16(view, 4, 20);
writeUint16(view, 6, 20);
writeUint16(view, 8, 0);
writeUint16(view, 10, 0);
writeUint16(view, 12, 0);
writeUint16(view, 14, 0);
writeUint32(view, 16, record.crc);
writeUint32(view, 20, record.dataBytes.length);
writeUint32(view, 24, record.dataBytes.length);
writeUint16(view, 28, record.nameBytes.length);
writeUint16(view, 30, 0);
writeUint16(view, 32, 0);
writeUint16(view, 34, 0);
writeUint16(view, 36, 0);
writeUint32(view, 38, 0);
writeUint32(view, 42, record.offset);
header.set(record.nameBytes, 46);
centralParts.push(header);
centralSize += header.length;
});
const end = new Uint8Array(22);
const endView = new DataView(end.buffer);
writeUint32(endView, 0, 0x06054b50);
writeUint16(endView, 4, 0);
writeUint16(endView, 6, 0);
writeUint16(endView, 8, records.length);
writeUint16(endView, 10, records.length);
writeUint32(endView, 12, centralSize);
writeUint32(endView, 16, centralOffset);
writeUint16(endView, 20, 0);
return concatUint8Arrays([...localParts, ...centralParts, end]);
}
function createCrc32Table() {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let j = 0; j < 8; j += 1) {
c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
}
table[i] = c >>> 0;
}
return table;
}
function crc32(bytes) {
let crc = 0xffffffff;
for (let i = 0; i < bytes.length; i += 1) {
crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function concatUint8Arrays(parts) {
const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
parts.forEach((part) => {
result.set(part, offset);
offset += part.length;
});
return result;
}
function writeUint16(view, offset, value) {
view.setUint16(offset, value, true);
}
function writeUint32(view, offset, value) {
view.setUint32(offset, value >>> 0, true);
}
function escapeXml(value) {
return String(value || "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function escapeAttribute(value) {
return escapeXml(value).replace(/`/g, "`");
}
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return { ...defaults };
}
const parsed = JSON.parse(raw);
return {
chapterMode: parsed.chapterMode || defaults.chapterMode,
customHeadingPattern: parsed.customHeadingPattern || defaults.customHeadingPattern,
customHeadingFlags: normalizeRegexFlags(parsed.customHeadingFlags || defaults.customHeadingFlags),
};
} catch (_error) {
return { ...defaults };
}
}
function saveSettings(settings) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
chapterMode: settings.chapterMode || defaults.chapterMode,
customHeadingPattern: settings.customHeadingPattern || "",
customHeadingFlags: normalizeRegexFlags(settings.customHeadingFlags || ""),
}));
} catch (_error) {
// ignore storage failures
}
}
function normalizeRegexFlags(value) {
const unique = Array.from(new Set(String(value || "").replace(/[^dgimsuvy]/g, "").split("")));
return unique.join("");
}
function buildCustomHeadingRegex(chapterMode, pattern, flags) {
if (chapterMode !== "custom") {
return null;
}
if (!pattern) {
throw new Error("自定义正则模式需要填写分节正则");
}
try {
return new RegExp(pattern, normalizeRegexFlags(flags));
} catch (error) {
throw new Error(`自定义分节正则无效:${error.message}`);
}
}
function gearSvg() {
return `
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M19.14 12.94c.04-.31.06-.63.06-.94s-.02-.63-.06-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.1 7.1 0 0 0-1.63-.94l-.36-2.54a.5.5 0 0 0-.49-.42h-3.84a.5.5 0 0 0-.49.42l-.36 2.54c-.58.22-1.13.53-1.63.94l-2.39-.96a.5.5 0 0 0-.6.22L2.7 8.84a.5.5 0 0 0 .12.64l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94L2.82 14.52a.5.5 0 0 0-.12.64l1.92 3.32a.5.5 0 0 0 .6.22l2.39-.96c.5.4 1.05.72 1.63.94l.36 2.54a.5.5 0 0 0 .49.42h3.84a.5.5 0 0 0 .49-.42l.36-2.54c.58-.22 1.13-.53 1.63-.94l2.39.96a.5.5 0 0 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.64l-2.03-1.58ZM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z"/>
</svg>
`;
}
function closeSvg() {
return `
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" d="M6 6 18 18M18 6 6 18"/>
</svg>
`;
}
function setRunning(panel, running) {
state.running = running;
panel.querySelector('[data-role="export"]').disabled = running;
panel.querySelector('[data-role="cancel"]').disabled = !running;
const statusNode = panel.querySelector('[data-role="status"]');
if (statusNode) {
if (running) {
statusNode.setAttribute("data-running", "true");
} else {
statusNode.removeAttribute("data-running");
}
}
}
function setStatus(panel, text) {
const node = panel.querySelector('[data-role="status"]');
if (node) {
node.textContent = text;
}
}
function log(message) {
const timestamp = new Date().toLocaleTimeString("zh-CN", { hour12: false });
state.logs.push(`[${timestamp}] ${message}`);
if (state.logs.length > LOG_LIMIT) {
state.logs.shift();
}
const logNode = document.querySelector(`#${PANEL_ID} [data-role="log"]`);
if (logNode) {
logNode.value = state.logs.join("\n");
logNode.scrollTop = logNode.scrollHeight;
}
}
function isSupportedThreadPage(doc, url) {
const parsed = new URL(url, location.origin);
const urlLooksRight = parsed.pathname.includes("thread-") || parsed.searchParams.get("mod") === "viewthread";
if (!urlLooksRight) {
return false;
}
return true; // Trust URL to support Discuz mobile templates which lack #thread_subject
}
function extractThreadTitle(doc) {
const candidates = [
"#thread_subject",
"h1.ts",
".thread_subject",
".thread-title",
".thread_tit",
".view_tit",
".tit",
".message h2 strong",
"h1",
"h2",
];
for (const selector of candidates) {
const el = doc.querySelector(selector);
if (!el) {
continue;
}
const title = cleanTitleElement(el);
if (title && !/^(提示信息|用户登录)$/.test(title)) {
return title;
}
}
const pageTitle = textOf(doc.querySelector("title")).replace(/\s*-\s*随缘居.*$/, "").trim();
return /^(提示信息|用户登录)$/.test(pageTitle) ? "" : pageTitle;
}
/**
* Extract clean title text from a DOM element.
* Removes inline UI elements (e.g., "只看楼主" links) that some
* Discuz mobile templates embed inside the title heading.
*/
function cleanTitleElement(el) {
const clone = el.cloneNode(true);
// Remove known UI child elements
clone.querySelectorAll('a[href*="authorid"], a[href*="only"]').forEach(a => a.remove());
let text = (clone.textContent || '').trim();
// Strip common trailing/leading UI noise
text = text.replace(/\s*(只看楼主|只看该作者|电梯直达|显示全部楼层|倒序浏览|阅读模式)\s*/g, '');
text = text.replace(/\s{2,}/g, ' ').trim();
return text;
}
function collectPostNodes(doc) {
const explicitNodes = Array.from(doc.querySelectorAll('div[id^="post_"], li[id^="post_"], div[id^="pid"], li[id^="pid"]'));
const fallbackNodes = Array.from(doc.querySelectorAll(".message, .postmessage")).map((node) => (
node.closest('li[id], div[id], table[id], article[id]') || node
));
const unique = new Map();
[...explicitNodes, ...fallbackNodes].forEach((node) => {
if (!node || unique.has(node)) {
return;
}
if (findMessageNode(node)) {
unique.set(node, true);
}
});
return Array.from(unique.keys());
}
function findMessageNode(postNode) {
return postNode.querySelector([
'[id^="postmessage_"]',
".message",
".postmessage",
".pcb .t_f",
].join(","));
}
function looksLikeBlockedPage(html) {
return [
"尚未登录",
"没有权限",
"页面重载开启",
"页面正在重新载入",
"提示信息",
"请输入验证码",
"操作太频繁",
"您的访问受限",
"指定的主题不存在",
"抱歉,您没有权限",
].some((needle) => html.includes(needle));
}
function extractTid(url, doc) {
const searchTid = url.searchParams.get("tid");
if (searchTid) {
return searchTid;
}
const pathMatch = url.pathname.match(/thread-(\d+)-/);
if (pathMatch) {
return pathMatch[1];
}
const copyLink = doc.querySelector('a[href*="thread-"]');
const href = copyLink ? copyLink.getAttribute("href") || "" : "";
const hrefMatch = href.match(/thread-(\d+)-/);
if (hrefMatch) {
return hrefMatch[1];
}
throw new Error("无法解析 tid");
}
function extractThreadStylePage(pathname) {
const match = pathname.match(/thread-\d+-(\d+)-/);
return match ? match[1] : null;
}
function extractThreadIdFromUrl(urlLike) {
if (!(urlLike instanceof URL)) {
return null;
}
const searchTid = urlLike.searchParams.get("tid");
if (searchTid) {
return searchTid;
}
const pathMatch = urlLike.pathname.match(/thread-(\d+)-/);
return pathMatch ? pathMatch[1] : null;
}
function isSameThreadPage(targetUrl, currentUrl) {
if (!(targetUrl instanceof URL) || !(currentUrl instanceof URL)) {
return false;
}
const currentTid = extractThreadIdFromUrl(currentUrl);
const targetTid = extractThreadIdFromUrl(targetUrl);
if (currentTid && targetTid) {
return currentTid === targetTid;
}
return targetUrl.pathname === currentUrl.pathname;
}
function parsePageValue(urlLike) {
const value = urlLike instanceof URL
? urlLike.searchParams.get("page") || extractThreadStylePage(urlLike.pathname) || "1"
: "1";
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? undefined : parsed;
}
function extractAuthorName(postNode) {
const selectors = [
".pls .authi a.xw1",
'.pls a[href*="space-uid-"]',
".user_info .name",
".userinfo .name",
'.authi a[href*="space-uid-"]',
'.authi a[href*="uid="]',
'a[href*="space-uid-"]',
'a[href*="mod=space&uid="]',
];
for (const selector of selectors) {
const name = textOf(postNode.querySelector(selector));
if (name) {
return name;
}
}
return "未知作者";
}
function extractUidFromPost(postNode) {
const href = extractAuthorProfileUrl(postNode);
if (!href) {
return undefined;
}
const match = href.match(/(?:uid=|space-uid-)(\d+)/);
return match ? match[1] : undefined;
}
function extractAuthorProfileUrl(postNode) {
const link = postNode.querySelector([
".pls .authi a.xw1",
'.pls a[href*="space-uid-"]',
'.authi a[href*="space-uid-"]',
'.authi a[href*="uid="]',
'a[href*="space-uid-"]',
'a[href*="mod=space&uid="]',
].join(","));
return link ? new URL(link.getAttribute("href"), location.origin).toString() : undefined;
}
function extractFloor(postNode) {
const floorLink =
postNode.querySelector(".plc .pi strong a") ||
postNode.querySelector('.plc .pi a[href*="findpost"]') ||
Array.from(postNode.querySelectorAll(".plc .pi a, .authi a, a")).find((node) => /#/.test(node.textContent || ""));
const floorText = textOf(floorLink) || textOf(postNode.querySelector(".plc .pi")) || textOf(postNode.querySelector(".authi")) || textOf(postNode);
const match = floorText.match(/(\d+)\s*#|第\s*(\d+)\s*楼/);
return match ? parseInt(match[1] || match[2], 10) : undefined;
}
function extractPublishedAt(postNode) {
const candidates = [
textOf(postNode.querySelector(".plc .pi .authi")),
textOf(postNode.querySelector(".authi")),
textOf(postNode.querySelector(".user_info")),
textOf(postNode.querySelector(".userinfo")),
textOf(postNode),
];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const postTimeMatch = candidate.match(/发表于\s*([^\n|]+)/);
if (postTimeMatch) {
return postTimeMatch[1].trim();
}
const genericMatch = candidate.match(/(\d{4}-\d{1,2}-\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?)/);
if (genericMatch) {
return genericMatch[1].trim();
}
}
return undefined;
}
function textOf(node) {
return node ? (node.textContent || "").replace(/\s+/g, " ").trim() : "";
}
function sanitizeFilename(input) {
return input
.replace(/[\\/:*?"<>|]+/g, "_")
.replace(/\s+/g, " ")
.trim()
.slice(0, 120);
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function ensureStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${PANEL_ID} {
--mts-panel-bg: linear-gradient(180deg, rgba(255, 251, 245, 0.98) 0%, rgba(246, 249, 252, 0.98) 100%);
--mts-border: rgba(145, 170, 193, 0.4);
--mts-text: #24364a;
--mts-subtle: #6a7d92;
--mts-accent: #1f5f95;
--mts-accent-strong: #174f7f;
--mts-accent-soft: #e8f1fa;
--mts-surface: rgba(255, 255, 255, 0.9);
box-sizing: border-box;
position: fixed;
top: 18px;
right: 18px;
z-index: 2147483647;
width: 380px;
max-width: calc(100vw - 36px);
max-height: calc(100vh - 36px);
overflow: auto;
padding: 18px;
border: 1px solid var(--mts-border);
border-radius: 18px;
box-shadow: 0 24px 60px rgba(27, 50, 75, 0.18);
background: var(--mts-panel-bg);
backdrop-filter: blur(18px);
color: var(--mts-text);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
}
#${PANEL_ID}::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.28), transparent 26%);
}
#${PANEL_ID}[hidden],
#${LAUNCHER_ID}[hidden] {
display: none !important;
}
#${PANEL_ID} .${APP_ID}__header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
#${PANEL_ID} .${APP_ID}__header strong {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.02em;
}
#${PANEL_ID} .${APP_ID}__close-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid rgba(123, 149, 173, 0.24);
background: rgba(255, 255, 255, 0.58);
color: #46627d;
cursor: pointer;
border-radius: 999px;
touch-action: manipulation;
}
#${PANEL_ID} .${APP_ID}__close-button:active {
background: rgba(232, 241, 250, 0.9);
}
#${PANEL_ID} .${APP_ID}__close-button svg {
display: block;
width: 18px;
height: 18px;
}
#${PANEL_ID} .${APP_ID}__row {
position: relative;
display: grid;
grid-template-columns: 64px 1fr;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
#${PANEL_ID} .${APP_ID}__row[hidden] {
display: none;
}
#${PANEL_ID} .${APP_ID}__row > label {
color: var(--mts-subtle);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
}
#${PANEL_ID} select,
#${PANEL_ID} input,
#${PANEL_ID} button,
#${PANEL_ID} textarea {
font: inherit;
font-size: 14px;
border-radius: 12px;
box-sizing: border-box;
}
#${PANEL_ID} select,
#${PANEL_ID} input {
min-height: 44px;
height: 44px;
padding: 10px 14px;
border: 1px solid rgba(160, 184, 205, 0.56);
background: var(--mts-surface);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
line-height: normal;
appearance: none;
-webkit-appearance: none;
color: var(--mts-text);
}
#${PANEL_ID} select:focus,
#${PANEL_ID} input:focus,
#${PANEL_ID} textarea:focus {
outline: none;
border-color: rgba(31, 95, 149, 0.65);
box-shadow: 0 0 0 3px rgba(31, 95, 149, 0.12);
}
#${PANEL_ID} input::placeholder {
color: #8c9caf;
}
#${PANEL_ID} select {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2337546f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 38px;
}
#${PANEL_ID} .${APP_ID}__actions {
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 18px;
}
#${PANEL_ID} .${APP_ID}__settings {
margin: 4px 0 12px;
padding: 14px;
border: 1px solid rgba(175, 196, 214, 0.48);
background: rgba(255, 255, 255, 0.58);
border-radius: 16px;
}
#${PANEL_ID} .${APP_ID}__field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
#${PANEL_ID} .${APP_ID}__field > label {
color: var(--mts-subtle);
font-size: 12px;
font-weight: 700;
}
#${PANEL_ID} .${APP_ID}__field:last-child {
margin-bottom: 0;
}
#${PANEL_ID} .${APP_ID}__hint {
margin: 0;
color: var(--mts-subtle);
font-size: 12px;
line-height: 1.4;
}
#${PANEL_ID} .${APP_ID}__button {
min-height: 46px;
height: 46px;
border: 1px solid transparent;
cursor: pointer;
font-weight: 700;
letter-spacing: -0.01em;
touch-action: manipulation;
transition: transform 140ms ease, background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
}
#${PANEL_ID} .${APP_ID}__button--primary {
background: var(--mts-accent);
color: #fff;
box-shadow: 0 4px 12px rgba(31, 95, 149, 0.15);
}
#${PANEL_ID} .${APP_ID}__button--secondary {
border-color: rgba(160, 184, 205, 0.64);
background: rgba(255, 255, 255, 0.7);
color: var(--mts-subtle);
}
#${PANEL_ID} .${APP_ID}__button:active {
transform: translateY(1px);
}
#${PANEL_ID} button:disabled,
#${PANEL_ID} input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
#${PANEL_ID} .${APP_ID}__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 14px 0 8px;
}
#${PANEL_ID} .${APP_ID}__status {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--mts-subtle);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
#${PANEL_ID} .${APP_ID}__status::before {
content: "";
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #8c9caf;
transition: background-color 0.2s, box-shadow 0.2s;
}
#${PANEL_ID} .${APP_ID}__status[data-running="true"]::before {
background-color: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.4);
}
#${PANEL_ID} .${APP_ID}__log-label {
color: var(--mts-subtle);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
}
#${PANEL_ID} .${APP_ID}__log {
width: 100%;
max-width: 100%;
min-height: 132px;
resize: vertical;
padding: 12px 14px;
border: 1px solid rgba(175, 196, 214, 0.52);
background: rgba(250, 252, 255, 0.82);
color: #334;
box-sizing: border-box;
overflow-wrap: anywhere;
border-radius: 14px;
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.55;
}
#${LAUNCHER_ID} {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 2147483647;
width: 52px;
height: 52px;
padding: 0;
border: 0;
border-radius: 50%;
background: linear-gradient(180deg, #ffffff 0%, #e8f1fa 100%);
color: var(--mts-accent-strong);
box-shadow: 0 12px 28px rgba(33, 52, 74, 0.18);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
user-select: none;
touch-action: none;
}
#${LAUNCHER_ID} svg {
display: block;
width: 24px;
height: 24px;
}
#${LAUNCHER_ID}:active {
cursor: grabbing;
background: #dceaf4;
}
/* Mobile Adaptations */
@media screen and (max-device-width: 800px), screen and (max-width: 600px) {
#${PANEL_ID} {
top: auto !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100%;
max-width: 100%;
max-height: min(88vh, 760px);
border: none;
border-top: 1px solid rgba(145, 170, 193, 0.32);
border-radius: 24px 24px 0 0;
padding: 18px 18px calc(20px + env(safe-area-inset-bottom));
box-shadow: 0 -16px 44px rgba(33, 52, 74, 0.14);
}
#${PANEL_ID} .${APP_ID}__header {
margin-bottom: 16px;
}
#${PANEL_ID} .${APP_ID}__header strong {
font-size: 15px;
}
#${PANEL_ID} select,
#${PANEL_ID} input {
font-size: 16px;
min-height: 50px;
height: 50px;
}
#${PANEL_ID} .${APP_ID}__button {
font-size: 16px;
min-height: 52px;
height: 52px;
}
#${PANEL_ID} .${APP_ID}__close-button {
width: 40px;
height: 40px;
}
#${PANEL_ID} .${APP_ID}__row {
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 14px;
align-items: stretch;
}
#${PANEL_ID} .${APP_ID}__row > label {
padding-left: 2px;
}
#${PANEL_ID} .${APP_ID}__actions {
grid-template-columns: 1fr 1fr;
margin-top: 18px;
}
#${PANEL_ID} .${APP_ID}__meta {
margin: 14px 0 8px;
}
#${PANEL_ID} .${APP_ID}__log {
min-height: 112px;
}
#${LAUNCHER_ID} {
bottom: calc(24px + env(safe-area-inset-bottom));
right: 16px;
width: 56px;
height: 56px;
}
#${LAUNCHER_ID} svg {
width: 28px;
height: 28px;
}
}
`;
document.head.appendChild(style);
}
})();