// ==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 `![${label}](<${destination}>)`; } 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(); })();