// ==UserScript==
// @name LINUX DO 评论/回复/短评一键导出
// @namespace https://linux.do/
// @version 1.6.1
// @description 导出楼层、回复关系和 Boost 短评,支持 TXT、JSON、在线图片 Markdown、离线图片 Markdown ZIP;支持断点续抓与部分导出。
// @match https://linux.do/t/*
// @require https://unpkg.com/turndown@7.2.0/dist/turndown.js
// @require https://unpkg.com/fflate@0.8.2/umd/index.js
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-idle
// ==/UserScript==
(() => {
'use strict';
const BATCH_SIZE = 20;
const BATCH_DELAY = 180;
const STORE_PREFIX = 'linuxdo-export-progress:';
const STORE_MAX = 8; // 最多同时保留的主题进度数
let busy = false;
let exporting = false;
let cache = null;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function getTopicId() {
const match = location.pathname.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/\d+)?(?:\.json)?\/?$/);
if (match) return Number(match[1]);
const canonical = document.querySelector('link[rel="canonical"]')?.href || '';
const fallback = canonical.match(/\/t\/(?:[^/]+\/)?(\d+)/);
return fallback ? Number(fallback[1]) : null;
}
async function fetchJson(url, retries = 4) {
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url, {
credentials: 'same-origin',
headers: {
Accept: 'application/json, text/plain, */*',
},
});
if (response.ok) return await response.json();
if ((response.status === 429 || response.status >= 500) && attempt < retries) {
const retryAfter = Number(response.headers.get('retry-after'));
await sleep(
Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 800 * (attempt + 1),
);
continue;
}
throw new Error(`HTTP ${response.status} ${response.statusText}`);
} catch (error) {
lastError = error;
if (attempt >= retries) break;
await sleep(600 * (attempt + 1));
}
}
throw lastError || new Error('请求失败');
}
function imageUrl(value) {
if (!value?.trim()) return '';
try {
const url = new URL(value.trim(), 'https://linux.do/');
return /^https?:$/.test(url.protocol) ? url.href : '';
} catch {
return '';
}
}
function parseContent(html = '') {
const images = [];
if (!html) return { text: '', markdown: '', images };
const doc = new DOMParser().parseFromString(
`
${html}
`,
'text/html',
);
const root = doc.querySelector('#linuxdo-export-root');
if (!root) return { text: '', markdown: '', images };
root.querySelectorAll('script, style').forEach((el) => el.remove());
root.querySelectorAll('img').forEach((img) => {
const alt = img.getAttribute('alt') || img.getAttribute('title') || '';
// 表情继续保留为短码,避免正文被大量表情 CDN 地址打断。
if (img.matches('.emoji, .avatar')) {
img.replaceWith(doc.createTextNode(alt));
return;
}
const lightbox = img.closest('a.lightbox');
const srcset = img.getAttribute('data-srcset') || img.getAttribute('srcset') || '';
const candidates = [
lightbox?.getAttribute('href'),
img.getAttribute('data-orig-src'),
img.getAttribute('data-original'),
img.getAttribute('data-src'),
img.getAttribute('data-lazy-src'),
img.getAttribute('src'),
...srcset.split(',').reverse().map((item) => item.trim().split(/\s+/)[0]),
];
const url = candidates.map(imageUrl).find(Boolean) || '';
const src = imageUrl(img.getAttribute('data-src')) || imageUrl(img.getAttribute('src'));
if (url && !images.some((image) => image.url === url)) {
images.push({ url, alt, src: src || url });
}
const marker = `[图片${alt ? `:${alt}` : ''}]${url ? `(${url})` : '(无可用图片地址)'}`;
// 原图预览的尺寸、下载等控件无需作为正文导出。
const placeholder = doc.createElement('span');
placeholder.setAttribute('data-export-image', url);
placeholder.setAttribute('data-export-alt', alt);
placeholder.textContent = marker;
(lightbox || img).replaceWith(placeholder);
});
const converter = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
converter.addRule('exportImages', {
filter: (node) => node.nodeName === 'SPAN' && node.hasAttribute('data-export-image'),
replacement: (content, node) => {
const url = node.getAttribute('data-export-image');
return url ? markdownImage(node.getAttribute('data-export-alt'), url) : content;
},
});
// 在纯文本链接和换行处理之前转换,保留列表、引用、代码块等结构。
const markdown = converter.turndown(root.cloneNode(true));
root.querySelectorAll('a[href]').forEach((a) => {
const href = a.getAttribute('href') || '';
if (!href || href.startsWith('#')) return;
let abs = href;
try {
abs = new URL(href, 'https://linux.do/').href;
} catch {
/* 保留原始 href */
}
const text = (a.textContent || '').trim();
if (text.includes(abs)) return;
a.appendChild(doc.createTextNode(`(${abs})`));
});
root.querySelectorAll('br').forEach((br) => br.replaceWith(doc.createTextNode('\n')));
root
.querySelectorAll('p, div, li, blockquote, pre, h1, h2, h3, h4, h5, h6')
.forEach((el) => el.appendChild(doc.createTextNode('\n')));
const text = (root.textContent || '')
.replace(/\u00a0/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
return { text, markdown, images };
}
function escapeMarkdown(value = '') {
return String(value).replace(/\\/g, '\\\\').replace(/([`*_{}\[\]<>#|!])/g, '\\$1');
}
function markdownImage(alt, url) {
const label = escapeMarkdown(alt || '图片').replace(/[\r\n]+/g, ' ');
const destination = url.replace(//g, '%3E').replace(/[\r\n]/g, '');
return ``;
}
function getLikeCount(post) {
const like = Array.isArray(post.actions_summary)
? post.actions_summary.find((item) => item.id === 2)
: null;
return Number(like?.count || 0);
}
// ---------- 进度持久化(断点续抓) ----------
function loadProgress(topicId) {
try {
const raw = localStorage.getItem(STORE_PREFIX + topicId);
if (!raw) return null;
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.posts) || !Array.isArray(data.stream)) return null;
return data;
} catch {
return null;
}
}
function saveProgress(topicId, data) {
try {
localStorage.setItem(
STORE_PREFIX + topicId,
JSON.stringify({ savedAt: Date.now(), ...data }),
);
// 简单淘汰:超过上限时删掉最旧的其它主题
const keys = Object.keys(localStorage)
.filter((key) => key.startsWith(STORE_PREFIX) && key !== STORE_PREFIX + topicId)
.map((key) => [key, Number(localStorage.getItem(key)?.match(/"savedAt":(\d+)/)?.[1] || 0)])
.sort((a, b) => a[1] - b[1]);
for (let i = 0; i < keys.length - (STORE_MAX - 1); i++) {
localStorage.removeItem(keys[i][0]);
}
} catch {
/* 存储满等情况下静默失败,不影响导出 */
}
}
function clearProgress(topicId) {
try {
localStorage.removeItem(STORE_PREFIX + topicId);
} catch {
/* ignore */
}
}
async function fetchAllTopicPosts(setStatus) {
const topicId = getTopicId();
if (!topicId) throw new Error('没有识别到当前主题 ID');
setStatus('读取主题信息…');
const topic = await fetchJson(`/t/${topicId}.json`);
const stream = topic?.post_stream?.stream || [];
const initialPosts = topic?.post_stream?.posts || [];
if (!stream.length) throw new Error('主题没有返回 post_stream.stream');
const progress = loadProgress(topicId) || { stream, posts: [] };
// 已有进度但楼层流变了(主题有新楼/删楼):保留交集,能续则续
const postMap = new Map(progress.posts.map((post) => [String(post.id), post]));
for (const post of initialPosts) postMap.set(String(post.id), post);
const have = new Set(postMap.keys());
const missingIds = stream.filter((id) => !have.has(String(id)));
let delay = BATCH_DELAY;
let consecutive429 = 0;
const MAX_429 = 8; // 连续限流次数达到上限就中断(进度已落地,可续传)
const isResumed = missingIds.length > 0 && postMap.size > missingIds.length;
for (let i = 0; i < missingIds.length; ) {
const chunk = missingIds.slice(i, i + BATCH_SIZE);
const params = new URLSearchParams();
chunk.forEach((id) => params.append('post_ids[]', String(id)));
setStatus(
`抓取帖子:${have.size}/${stream.length}` +
(isResumed ? `(续传,本次待抓 ${missingIds.length})` : ''),
);
let data;
try {
data = await fetchJson(`/t/${topicId}/posts.json?${params.toString()}`);
consecutive429 = 0;
if (delay > BATCH_DELAY) delay = Math.max(BATCH_DELAY, Math.floor(delay / 2));
} catch (error) {
const is429 = /HTTP 429/.test(error?.message || '');
if (!is429) throw error;
consecutive429 += 1;
if (consecutive429 >= MAX_429) {
// 放弃本次:进度已保存,由调用方走部分导出/续传路径
const err = new Error(`连续 ${MAX_429} 次被限流(429),已中止抓取`);
err.rateLimited = true;
throw err;
}
delay = Math.min(delay * 2, 30000);
const waitSec = Math.ceil((delay * 2) / 1000);
// 限流:先把已有进度落地,再等待重试
saveProgress(topicId, { stream, topic, posts: [...postMap.values()] });
setStatus(
`被限流(429),已保存进度 ${have.size}/${stream.length},` +
`${waitSec} 秒后自动重试(${consecutive429}/${MAX_429})…`,
);
await sleep(delay * 2);
continue; // 不推进 i,重试同一批
}
for (const post of data?.post_stream?.posts || []) {
const key = String(post.id);
if (!postMap.has(key)) {
postMap.set(key, post);
have.add(key);
}
}
i += BATCH_SIZE;
// 每批落地一次进度:中途失败/关页都不丢
if (missingIds.length > BATCH_SIZE) {
saveProgress(topicId, { stream, topic, posts: [...postMap.values()] });
}
setStatus(`抓取帖子:${have.size}/${stream.length}`);
if (i < missingIds.length) await sleep(delay);
}
// 无论如何先落地
saveProgress(topicId, { stream, topic, posts: [...postMap.values()] });
const orderedPosts = stream.map((id) => postMap.get(String(id))).filter(Boolean);
const missingCount = stream.length - orderedPosts.length;
setStatus(`整理数据:${orderedPosts.length}/${stream.length}`);
return { topic, posts: orderedPosts, missingCount };
}
function normalizeBoost(boost) {
const user = boost?.user || {};
const content = parseContent(boost?.cooked || '');
return {
boost_id: boost?.id ?? null,
username: user.username || '',
display_name: user.name || user.display_username || user.username || '',
user_id: user.id ?? null,
text: content.text,
markdown: content.markdown,
images: content.images,
};
}
function normalizePosts(topic, posts) {
const byNumber = new Map(posts.map((post) => [post.post_number, post]));
const topicUrl = `${location.origin}/t/${topic.slug || 'topic'}/${topic.id}`;
return posts.map((post) => {
const replyNo = post.reply_to_post_number ?? null;
const replyTarget = replyNo ? byNumber.get(replyNo) : null;
const replyUser = post.reply_to_user || null;
const boosts = Array.isArray(post.boosts) ? post.boosts.map(normalizeBoost) : [];
const content = parseContent(post.cooked || '');
return {
post_number: post.post_number,
post_id: post.id,
username: post.username || '',
display_name: post.name || post.display_username || post.username || '',
created_at: post.created_at || '',
updated_at: post.updated_at || '',
reply_to_post_number: replyNo,
reply_to_username: replyUser?.username || replyTarget?.username || null,
reply_to_display_name:
replyUser?.name ||
replyTarget?.name ||
replyTarget?.display_username ||
replyUser?.username ||
replyTarget?.username ||
null,
like_count: getLikeCount(post),
reply_count: Number(post.reply_count || 0),
boost_count: boosts.length,
boosts,
text: content.text,
markdown: content.markdown,
images: content.images,
hidden: Boolean(post.hidden),
deleted_at: post.deleted_at || null,
url: `${topicUrl}/${post.post_number}`,
};
});
}
function formatDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date);
}
function formatUser(displayName, username) {
if (displayName && username && displayName !== username) {
return `${displayName} (@${username})`;
}
if (username) return `@${username}`;
return displayName || '[未知用户]';
}
function buildTxt(topic, posts, missingCount) {
const topicUrl = `${location.origin}/t/${topic.slug || 'topic'}/${topic.id}`;
const totalBoosts = posts.reduce((sum, post) => sum + post.boosts.length, 0);
const lines = [
`主题:${topic.title || document.title}`,
`链接:${topicUrl}`,
`导出时间:${formatDate(new Date().toISOString())}`,
`总帖子:${posts.length}(含主楼)`,
`总回复:${Math.max(0, posts.length - 1)}`,
`总短评(Boost):${totalBoosts}`,
missingCount ? `注意:另有 ${missingCount} 条帖子因权限/删除等原因未能读取。` : '',
'',
].filter((line, index) => line !== '' || index >= 6);
for (const post of posts) {
lines.push('='.repeat(72));
lines.push(`[${post.post_number}楼] ${formatUser(post.display_name, post.username)}`);
lines.push(`时间:${formatDate(post.created_at)}`);
lines.push(`链接:${post.url}`);
if (post.reply_to_post_number) {
lines.push(
`回复:${post.reply_to_post_number}楼 · ${formatUser(
post.reply_to_display_name,
post.reply_to_username,
)}`,
);
} else if (post.post_number === 1) {
lines.push('关系:主楼');
} else {
lines.push('关系:普通跟帖(未指定直接回复楼层)');
}
lines.push(
`互动:${post.like_count} 赞 · ${post.reply_count} 个直接回复 · ${post.boosts.length} 条短评`,
);
lines.push('');
lines.push(post.text || (post.deleted_at ? '[已删除]' : '[无可见正文]'));
if (post.boosts.length) {
lines.push('');
lines.push(`【本楼短评 / Boost:${post.boosts.length} 条】`);
post.boosts.forEach((boost, index) => {
const boostText = boost.text || '[仅表情/无文字]';
lines.push(
` ${index + 1}. ${formatUser(boost.display_name, boost.username)}:${boostText}`,
);
});
}
lines.push('');
}
return lines.join('\n');
}
function buildMarkdown(topic, posts, missingCount, localImages = new Map(), failedCount = 0) {
const topicUrl = `${location.origin}/t/${topic.slug || 'topic'}/${topic.id}`;
const lines = [
`# ${escapeMarkdown(topic.title || document.title)}`,
'',
`主题链接:<${topicUrl}>`,
'',
`导出时间:${formatDate(new Date().toISOString())}`,
'',
`共 ${posts.length} 帖(含主楼),${posts.reduce((n, p) => n + p.boosts.length, 0)} 条短评。`,
'',
];
if (missingCount) lines.push(`> 有 ${missingCount} 帖未能读取。`, '');
if (failedCount) lines.push(`> ${failedCount} 张图片下载失败,已保留在线地址;详情见 images-failures.json。`, '');
const body = (item) => {
let markdown = item.markdown || escapeMarkdown(item.text || '[无可见正文]');
const destinations = new Map([...localImages].map(([url, path]) => [
url.replace(//g, '%3E').replace(/[\r\n]/g, ''), path,
]));
markdown = markdown.replace(/(!\[(?:\\.|[^\]\\])*\]\()<([^>\r\n]+)>(\))/g,
(match, prefix, url, suffix) => destinations.has(url) ? `${prefix}<${destinations.get(url)}>${suffix}` : match);
return markdown;
};
for (const post of posts) {
lines.push('---', '', `## ${post.post_number}楼 · ${escapeMarkdown(formatUser(post.display_name, post.username))}`, '');
lines.push(`时间:${formatDate(post.created_at)} · [楼层链接](<${post.url}>)`, '');
if (post.reply_to_post_number) {
lines.push(`回复:${post.reply_to_post_number}楼 · ${escapeMarkdown(formatUser(post.reply_to_display_name, post.reply_to_username))}`, '');
}
lines.push(`互动:${post.like_count} 赞 · ${post.reply_count} 个直接回复 · ${post.boosts.length} 条短评`, '', body(post), '');
if (post.boosts.length) {
lines.push('### 短评 / Boost', '');
post.boosts.forEach((boost, index) => {
lines.push(`**${index + 1}. ${escapeMarkdown(formatUser(boost.display_name, boost.username))}**`, '');
lines.push(body(boost).split('\n').map((line) => `> ${line}`).join('\n'), '');
});
}
}
return lines.join('\n');
}
function downloadImage(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'arraybuffer',
timeout: 45000,
onload: (response) => {
const type = response.responseHeaders.match(/^content-type:\s*([^;\r\n]+)/im)?.[1]?.trim().toLowerCase() || '';
if (response.status < 200 || response.status >= 300) {
reject(new Error(`HTTP ${response.status}`));
} else if (!response.response?.byteLength) {
reject(new Error('图片内容为空'));
} else if (!type.startsWith('image/')) {
reject(new Error(`服务器未返回图片(${type || '未知类型'})`));
} else {
// GM 返回的 ArrayBuffer 可能来自扩展上下文,转为当前沙箱的字节数组。
resolve({ bytes: new Uint8Array(response.response).slice(), type });
}
},
onerror: () => reject(new Error('网络错误')),
ontimeout: () => reject(new Error('下载超时')),
onabort: () => reject(new Error('下载中止')),
});
});
}
async function packZip(files, setStatus) {
const chunks = [];
let zipError = null;
let finished = false;
const zip = new fflate.Zip((error, chunk, final) => {
if (error) {
zipError = error;
return;
}
if (chunk?.length) chunks.push(chunk);
if (final) finished = true;
});
const total = files.reduce((sum, file) => sum + file.bytes.length, 0);
let written = 0;
const chunkSize = 1024 * 1024;
try {
setStatus(`正在生成 ZIP:0% · 0/${files.length} 个文件`);
await sleep(0);
for (let index = 0; index < files.length; index++) {
const file = files[index];
// 参考语雀导出脚本的 ZipPassThrough:图片无需重复压缩,逐块写入。
// 不使用 JSZip.generateAsync,避开其在部分油猴沙箱中的异步调度挂起。
const entry = new fflate.ZipPassThrough(file.path);
zip.add(entry);
if (!file.bytes.length) entry.push(new Uint8Array(0), true);
for (let offset = 0; offset < file.bytes.length; offset += chunkSize) {
const end = Math.min(offset + chunkSize, file.bytes.length);
entry.push(file.bytes.subarray(offset, end), end === file.bytes.length);
if (zipError) throw zipError;
written += end - offset;
const percent = total ? Math.min(99, Math.floor(written / total * 100)) : 0;
setStatus(`正在生成 ZIP:${percent}% · ${index + 1}/${files.length} · ${file.path}`);
// 给浏览器绘制进度的机会,也避免大图片长时间阻塞页面。
await sleep(0);
}
if (zipError) throw zipError;
}
zip.end();
if (zipError) throw zipError;
// ZipPassThrough 同步输出,结束后应立即收到 final;异常不能无限等待。
if (!finished) throw new Error('ZIP 未完成写入,请刷新页面后重试');
const blob = new Blob(chunks, { type: 'application/zip' });
setStatus(`ZIP 已生成:100% · ${(blob.size / 1024 / 1024).toFixed(1)} MB,准备下载…`);
return blob;
} catch (error) {
zip.terminate();
throw error;
}
}
async function buildOfflineZip(data, setStatus) {
const files = [];
const unique = new Map();
data.posts.forEach((post) => {
[post, ...post.boosts].forEach((item) => {
(item.images || []).forEach((image) => unique.set(image.url, image));
});
});
const images = [...unique.values()];
const localImages = new Map();
const failures = [];
const extensions = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/avif': 'avif', 'image/bmp': 'bmp', 'image/x-icon': 'ico' };
let next = 0;
let completed = 0;
const worker = async () => {
while (next < images.length) {
const index = next++;
const image = images[index];
let lastError;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const result = await downloadImage(image.url);
const suffix = new URL(image.url).pathname.match(/\.(png|jpe?g|gif|webp|svg|avif|bmp|ico|tiff?)$/i)?.[1]?.toLowerCase();
const path = `images/${String(index + 1).padStart(4, '0')}.${extensions[result.type] || suffix || 'img'}`;
files.push({ path, bytes: result.bytes });
localImages.set(image.url, path);
lastError = null;
break;
} catch (error) {
lastError = error;
if (attempt === 0) await sleep(1000);
}
}
if (lastError) failures.push({ url: image.url, error: lastError.message });
completed++;
setStatus(`下载图片:${completed}/${images.length},失败 ${failures.length} 张`);
}
};
await Promise.all(Array.from({ length: Math.min(3, images.length) }, worker));
const encoder = new TextEncoder();
files.push({ path: `${data.baseName}.md`, bytes: encoder.encode(buildMarkdown(data.topic, data.posts, data.missingCount, localImages, failures.length)) });
if (failures.length) files.push({ path: 'images-failures.json', bytes: encoder.encode(JSON.stringify(failures, null, 2)) });
const blob = await packZip(files, setStatus);
return { blob, savedCount: localImages.size, failedCount: failures.length };
}
function buildJson(topic, posts, missingCount) {
const totalBoosts = posts.reduce((sum, post) => sum + post.boosts.length, 0);
return JSON.stringify(
{
source: 'LINUX DO / Discourse',
topic: {
id: topic.id,
title: topic.title || document.title,
slug: topic.slug || 'topic',
url: `${location.origin}/t/${topic.slug || 'topic'}/${topic.id}`,
posts_count: posts.length,
replies_count: Math.max(0, posts.length - 1),
boosts_count: totalBoosts,
highest_post_number: topic.highest_post_number ?? null,
missing_count: missingCount,
},
exported_at: new Date().toISOString(),
posts,
},
null,
2,
);
}
function safeFileName(name) {
return (
(name || 'linuxdo-topic')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 90) || 'linuxdo-topic'
);
}
function downloadText(content, fileName, mime) {
const blob = new Blob(['\ufeff', content], { type: `${mime};charset=utf-8` });
downloadBlob(blob, fileName);
}
function downloadBlob(blob, fileName) {
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), 60000);
}
function isCacheForCurrentTopic() {
const topicId = getTopicId();
return Boolean(topicId && cache && cache.topicId === topicId);
}
function cacheTimeText(value) {
if (!value) return '';
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(value);
}
async function fetchAndCache(setStatus, { partial = false } = {}) {
if (busy) return null;
busy = true;
try {
const { topic, posts: rawPosts, missingCount } = await fetchAllTopicPosts(setStatus);
const posts = normalizePosts(topic, rawPosts);
const totalBoosts = posts.reduce((sum, post) => sum + post.boosts.length, 0);
const topicId = topic.id;
cache = {
topicId,
topic,
posts,
missingCount,
totalBoosts,
baseName: safeFileName(topic.title || document.title),
fetchedAt: new Date(),
partial,
};
// 全量到手后进度就没用了,清掉释放空间
if (!partial && !missingCount) clearProgress(topicId);
const partialTag = partial ? '【部分】' : '';
setStatus(
`${partialTag}已获取:${posts.length} 帖 + ${totalBoosts} 条短评${
missingCount ? `,${missingCount} 帖未读取` : ''
}${partial ? ',可点「继续抓取」补全' : ''} · 可导出 TXT / JSON / MD / ZIP`,
);
return cache;
} finally {
busy = false;
}
}
// 抓取失败(如限流中断)时,尝试用已落地的进度构建部分缓存
function buildPartialCacheFromProgress(setStatus, error) {
const topicId = getTopicId();
const progress = topicId ? loadProgress(topicId) : null;
if (!progress || !progress.posts.length) return null;
try {
const topic = progress.topic || { id: topicId, slug: 'topic', title: document.title };
const byId = new Map(progress.posts.map((post) => [String(post.id), post]));
const orderedPosts = (progress.stream || [])
.map((id) => byId.get(String(id)))
.filter(Boolean);
if (!orderedPosts.length) return null;
const posts = normalizePosts(topic, orderedPosts);
const totalBoosts = posts.reduce((sum, post) => sum + post.boosts.length, 0);
const missingCount = (progress.stream || []).length - orderedPosts.length;
cache = {
topicId,
topic,
posts,
missingCount,
totalBoosts,
baseName: safeFileName(topic.title || document.title),
fetchedAt: new Date(),
partial: true,
};
setStatus(`中断(${error?.message || error}),已保留 ${posts.length} 帖,可导出部分内容或点「继续抓取」`);
return cache;
} catch {
return null;
}
}
async function getCurrentData(setStatus) {
if (isCacheForCurrentTopic()) return cache;
try {
return await fetchAndCache(setStatus);
} catch (error) {
console.error('[LINUX DO 导出]', error);
const partial = buildPartialCacheFromProgress(setStatus, error);
if (partial) return partial;
throw error;
}
}
async function exportTopic(format, setStatus) {
if (busy || exporting) return;
exporting = true;
try {
const data = await getCurrentData(setStatus);
if (!data) return;
const { topic, posts, missingCount, totalBoosts, baseName, fetchedAt } = data;
let imageStatus = '';
if (format === 'md-zip') {
const result = await buildOfflineZip(data, setStatus);
downloadBlob(result.blob, `${baseName}.zip`);
imageStatus = ` · 已保存 ${result.savedCount} 张图片${result.failedCount ? `,${result.failedCount} 张失败(保留在线链接)` : ''}`;
} else if (format === 'md') {
downloadText(buildMarkdown(topic, posts, missingCount), `${baseName}.md`, 'text/markdown');
} else if (format === 'json') {
downloadText(buildJson(topic, posts, missingCount), `${baseName}.json`, 'application/json');
} else {
downloadText(buildTxt(topic, posts, missingCount), `${baseName}.txt`, 'text/plain');
}
setStatus(
`已导出 ${format.toUpperCase()}:${posts.length} 帖 + ${totalBoosts} 条短评${data.partial ? '(部分数据)' : ''}${imageStatus} · 数据 ${cacheTimeText(fetchedAt)}`,
);
} catch (error) {
console.error('[LINUX DO 导出]', error);
setStatus(`失败:${error?.message || error}`);
alert(`LINUX DO 导出失败:\n${error?.message || error}`);
} finally {
exporting = false;
}
}
async function refreshTopic(setStatus) {
if (busy || exporting) return;
try {
await fetchAndCache(setStatus);
} catch (error) {
console.error('[LINUX DO 继续抓取]', error);
// 失败时进度已在 localStorage,提示用户可再试
setStatus(`抓取未完成:${error?.message || error}(进度已保存,再点「继续抓取」可续传)`);
}
}
async function resetTopic(setStatus) {
if (busy || exporting) return;
const topicId = getTopicId();
if (!topicId) return;
if (!confirm('清空本主题的本地抓取进度并重新获取?(不影响已导出的文件)')) return;
clearProgress(topicId);
cache = null;
setStatus('已清空缓存,重新获取中…');
await refreshTopic(setStatus);
}
function createPanel() {
if (document.getElementById('linuxdo-comment-exporter-root')) return;
const root = document.createElement('div');
root.id = 'linuxdo-comment-exporter-root';
root.style.cssText = [
'position:fixed',
'right:0',
'top:52%',
'transform:translateY(-50%)',
'z-index:999999',
'display:flex',
'align-items:center',
'pointer-events:none',
].join(';');
const drawer = document.createElement('div');
drawer.style.cssText = [
'width:0',
'opacity:0',
'overflow:hidden',
'transition:width .22s ease, opacity .18s ease, margin-right .22s ease',
'margin-right:0',
'pointer-events:auto',
].join(';');
const panel = document.createElement('div');
panel.style.cssText = [
'width:246px',
'padding:10px',
'border:1px solid rgba(127,127,127,.28)',
'border-radius:14px',
'background:var(--secondary, #fff)',
'color:var(--primary, #222)',
'box-shadow:0 8px 28px rgba(0,0,0,.16)',
'font:13px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
].join(';');
const fab = document.createElement('button');
fab.type = 'button';
fab.title = '展开评论导出面板';
fab.setAttribute('aria-label', '展开评论导出面板');
fab.style.cssText = [
'pointer-events:auto',
'width:50px',
'height:50px',
'border:none',
'border-radius:999px 0 0 999px',
'margin-right:-2px',
'padding:0 12px 0 10px',
'cursor:pointer',
'background:#f2a3bf',
'color:#fff',
'box-shadow:0 8px 22px rgba(0,0,0,.14)',
'display:flex',
'align-items:center',
'justify-content:flex-start',
'font-size:20px',
'font-weight:700',
].join(';');
fab.textContent = '⇤';
let expanded = false;
const title = document.createElement('div');
title.textContent = 'LINUX DO 完整评论导出';
title.style.cssText = 'font-weight:700;margin-bottom:8px;';
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:6px;';
const makeButton = (text, onClick, fullWidth = false) => {
const button = document.createElement('button');
button.type = 'button';
button.textContent = text;
button.style.cssText = [
fullWidth ? 'width:100%' : 'flex:1',
'cursor:pointer',
'border:1px solid rgba(127,127,127,.28)',
'border-radius:8px',
'padding:8px 6px',
'background:var(--tertiary-low, #eee)',
'color:inherit',
].join(';');
button.addEventListener('click', onClick);
return button;
};
const status = document.createElement('div');
status.style.cssText = 'margin-top:8px;font-size:12px;opacity:.78;word-break:break-word;';
status.textContent = '自动抓取全楼,支持断点续传;MD 使用在线图片,MD ZIP 下载图片供离线查看';
const setStatus = (text) => {
status.textContent = text;
};
const setExpanded = (value) => {
expanded = Boolean(value);
drawer.style.width = expanded ? '266px' : '0';
drawer.style.opacity = expanded ? '1' : '0';
drawer.style.marginRight = expanded ? '8px' : '0';
fab.textContent = expanded ? '⇥' : '⇤';
fab.title = expanded ? '收起评论导出面板' : '展开评论导出面板';
fab.setAttribute('aria-label', fab.title);
};
row.appendChild(makeButton('导出 TXT', () => exportTopic('txt', setStatus)));
row.appendChild(makeButton('导出 JSON', () => exportTopic('json', setStatus)));
const markdownRow = document.createElement('div');
markdownRow.style.cssText = 'margin-top:6px;display:flex;gap:6px;';
markdownRow.appendChild(makeButton('MD 在线图片', () => exportTopic('md', setStatus)));
markdownRow.appendChild(makeButton('MD ZIP 离线', () => exportTopic('md-zip', setStatus)));
const refreshRow = document.createElement('div');
refreshRow.style.cssText = 'margin-top:6px;display:flex;gap:6px;';
refreshRow.appendChild(makeButton('继续抓取', () => refreshTopic(setStatus)));
refreshRow.appendChild(makeButton('清缓存重来', () => resetTopic(setStatus)));
const hint = document.createElement('div');
hint.style.cssText = 'margin-top:6px;font-size:12px;opacity:.78;word-break:break-word;';
const refreshHint = () => {
const topicId = getTopicId();
const progress = topicId ? loadProgress(topicId) : null;
if (progress && progress.posts?.length) {
hint.textContent = `本地有断点:已缓存 ${progress.posts.length} 帖,点「继续抓取」续传`;
} else {
hint.textContent = '';
}
};
refreshHint();
panel.appendChild(title);
panel.appendChild(row);
panel.appendChild(markdownRow);
panel.appendChild(refreshRow);
panel.appendChild(hint);
panel.appendChild(status);
drawer.appendChild(panel);
root.appendChild(drawer);
root.appendChild(fab);
document.body.appendChild(root);
fab.addEventListener('click', (event) => {
event.stopPropagation();
setExpanded(!expanded);
});
drawer.addEventListener('click', (event) => {
event.stopPropagation();
});
document.addEventListener('click', (event) => {
if (!root.contains(event.target)) setExpanded(false);
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') setExpanded(false);
});
setExpanded(false);
}
createPanel();
})();