有什么保存下载论坛帖子的工具吗?

有什么保存下载论坛帖子的工具吗?

Hhfguhvmp

(Hgfyk)

2025 年7 月 27 日 09:01

1

虽然说有书签,但是帖子可能会被编辑修改,删除,甚至论坛都有可能消失,当然了,我最不希望看到这种情况,不过无论是天涯,还有各种各样的论坛,他们的帖子基本上我后面查看的时候全部都是用截图的方式保存了,所以我希望问问有没有更加好用的工具?

12 个赞

Cknight

(cknight)

2025 年7 月 27 日 09:02

2

剪藏工具?

1 个赞

Hhfguhvmp

(Hgfyk)

2025 年7 月 27 日 09:03

3

更类似于互联网档案馆那种吧,反正只要批量简便保存就行

1 个赞

lylinuxdo2

(lylinuxdo2)

2025 年7 月 27 日 09:06

4

可以使用佬开发的插件下载器

总结

// ==UserScript==

// @name Linux.do 下崽器 (新版)

// @namespace http://linux.do/

// @version 1.0.7

// @description 备份你珍贵的水贴为Markdown,可拖拽调整按钮位置。

// @author PastKing

// @match https://www.linux.do/t/topic/*

// @match https://linux.do/t/topic/*

// @license MIT

// @icon https://cdn.linux.do/uploads/default/optimized/1X/3a18b4b0da3e8cf96f7eea15241c3d251f28a39b_2_32x32.png

// @grant none

// @require https://unpkg.com/turndown@7.1.3/dist/turndown.js

// @downloadURL https://update.greasyfork.org/scripts/511622/Linuxdo%20%E4%B8%8B%E5%B4%BD%E5%99%A8%20%28%E6%96%B0%E7%89%88%29.user.js

// @updateURL https://update.greasyfork.org/scripts/511622/Linuxdo%20%E4%B8%8B%E5%B4%BD%E5%99%A8%20%28%E6%96%B0%E7%89%88%29.meta.js

// ==/UserScript==

(function() {

'use strict';

let isDragging = false;

let isMouseDown = false;

// 创建并插入下载按钮

function createDownloadButton() {

const button = document.createElement('button');

button.textContent = '下载为 Markdown';

button.style.cssText = `

padding: 10px 15px;

font-size: 14px;

font-weight: bold;

color: #ffffff;

background-color: #0f9d58;

border: none;

border-radius: 4px;

cursor: pointer;

transition: background-color 0.3s ease;

box-shadow: 0 2px 4px rgba(0,0,0,0.2);

position: fixed;

z-index: 9999;

`;

// 添加悬停效果

button.onmouseover = function() {

this.style.backgroundColor = '#0b8043';

};

button.onmouseout = function() {

this.style.backgroundColor = '#0f9d58';

};

// 从localStorage获取保存的位置

const savedPosition = JSON.parse(localStorage.getItem('downloadButtonPosition'));

if (savedPosition) {

button.style.left = savedPosition.left;

button.style.top = savedPosition.top;

} else {

button.style.right = '20px';

button.style.bottom = '20px';

}

document.body.appendChild(button);

return button;

}

// 添加拖拽功能

function makeDraggable(element) {

let startX, startY, startLeft, startTop;

element.addEventListener('mousedown', startDragging);

document.addEventListener('mousemove', drag);

document.addEventListener('mouseup', stopDragging);

function startDragging(e) {

isMouseDown = true;

isDragging = false;

startX = e.clientX;

startY = e.clientY;

startLeft = parseInt(element.style.left) || window.innerWidth - parseInt(element.style.right) - element.offsetWidth;

startTop = parseInt(element.style.top) || window.innerHeight - parseInt(element.style.bottom) - element.offsetHeight;

e.preventDefault();

}

function drag(e) {

if (!isMouseDown) return;

isDragging = true;

const dx = e.clientX - startX;

const dy = e.clientY - startY;

element.style.left = `${startLeft + dx}px`;

element.style.top = `${startTop + dy}px`;

element.style.right = 'auto';

element.style.bottom = 'auto';

}

function stopDragging() {

if (isMouseDown && isDragging) {

localStorage.setItem('downloadButtonPosition', JSON.stringify({

left: element.style.left,

top: element.style.top

}));

}

isMouseDown = false;

setTimeout(() => {

isDragging = false;

}, 10); // 短暂延迟以确保点击事件在拖动后正确触发

}

}

// 获取文章内容

function getArticleContent() {

const titleElement = document.querySelector('#topic-title > div > h1 > a.fancy-title > span');

const contentElement = document.querySelector('#post_1 > div.row > div.topic-body.clearfix > div.regular.contents > div.cooked');

if (!titleElement || !contentElement) {

console.error('无法找到文章标题或内容');

return null;

}

return {

title: titleElement.textContent.trim(),

content: contentElement.innerHTML

};

}

// 转换为Markdown并下载

function downloadAsMarkdown() {

const article = getArticleContent();

if (!article) {

alert('无法获取文章内容,请检查网页结构是否变更。');

return;

}

const turndownService = new TurndownService({

headingStyle: 'atx',

codeBlockStyle: 'fenced'

});

// 自定义规则处理图片和链接

turndownService.addRule('images_and_links', {

filter: ['a', 'img'],

replacement: function (content, node) {

// 处理图片

if (node.nodeName === 'IMG') {

const alt = node.alt || '';

const src = node.getAttribute('src') || '';

const title = node.title ? ` "${node.title}"` : '';

return `![${alt}](${src}${title})`;

}

// 处理链接

else if (node.nodeName === 'A') {

const href = node.getAttribute('href');

const title = node.title ? ` "${node.title}"` : '';

// 检查链接是否包含图片

const img = node.querySelector('img');

if (img) {

const alt = img.alt || '';

const src = img.getAttribute('src') || '';

const imgTitle = img.title ? ` "${img.title}"` : '';

return `[![${alt}](${src}${imgTitle})](${href}${title})`;

}

// 普通链接

return `[${node.textContent}](${href}${title})`;

}

}

});

const markdown = `# ${article.title}\n\n${turndownService.turndown(article.content)}`;

const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });

const url = URL.createObjectURL(blob);

const a = document.createElement('a');

a.href = url;

a.download = `${article.title}.md`;

a.click();

URL.revokeObjectURL(url);

}

// 主函数

function main() {

const downloadButton = createDownloadButton();

makeDraggable(downloadButton);

downloadButton.addEventListener('click', function(e) {

if (!isDragging) {

downloadAsMarkdown();

}

});

}

// 运行主函数

main();

})();

但是无法获得回复 主题消息没问题

miza

(暮光闪闪)

2025 年7 月 27 日 09:08

5

Firefox的pocket挺好的,但是关闭了

Pocket 曾陪伴数百万人保存心仪的文章,发现值得深入阅读的精彩内容。然而,随着用户网络使用习惯的不断演变,我们经过慎重考虑,决定将资源重新聚焦于那些更贴合当下浏览习惯与在线需求的项目。

同求一个好工具可以在线保存的

1 个赞

libook

(libook)

2025 年7 月 27 日 09:09

6

PC浏览器可以保存网页成html文件,可以试试。

另外可以打印网页成PDF文件。

Hhfguhvmp

(Hgfyk)

2025 年7 月 27 日 09:09

7

不太行呐,很多帖子最重要的反而是回复主题,只是个问题,不过谢谢分享

lwang

2025 年7 月 27 日 09:12

8

// ==UserScript==

// @name LINUX DO 帖子内容提取器

// @namespace http://tampermonkey.net/

// @version 2.0

// @description 自动加载并提取 LINUX DO 帖子到 Markdown。简约集成UI,点击编辑楼层数,状态可视,设置保存,HTML转MD。

// @author Mozi

// @match https://linux.do/t/*

// @icon https://linux.do/uploads/default/original/1X/702b889b84630757e77697727619a9000a087780.png

// @grant none

// @run-at document-idle

// @require https://unpkg.com/turndown/dist/turndown.js

// ==/UserScript==

(function() {

'use strict';

// --- 配置 ---

const SCROLL_CHECK_INTERVAL_MS = 800;

const SCROLL_STABLE_TIME_MS = 3000;

const MAX_SCROLL_WAIT_SEC = 180;

const LS_KEY_FLOORS = 'ldExtractorMaxFloors'; // LocalStorage Key

// --- UI IDs ---

const BAR_ID = 'ld-extractor-bar';

const LIMIT_CONTROL_ID = 'ld-limit-control';

const LIMIT_DISPLAY_ID = 'ld-limit-display';

const LIMIT_INPUT_ID = 'ld-limit-input';

const ACTION_BTN_ID = 'ld-extractor-action-btn';

const ACTION_BTN_TEXT = '提取(MD)';

let isEditingLimit = false;

let isRunning = false;

console.log('LINUX DO 帖子提取脚本 (Markdown + Minimal UI) v2.0 已加载于:', window.location.href);

// --- CSS Style Injection ---

function injectStyles() {

const style = document.createElement('style');

style.textContent = `

:root {

--ld-primary-color: #339AF0;

--ld-primary-hover: #1c7ed6;

--ld-bg: rgba(248, 249, 250, 0.95);

--ld-bg-hover: rgba(233, 236, 239, 0.98);

--ld-text-color: #343a40;

--ld-border-radius: 8px;

--ld-shadow: 0 4px 18px rgba(0, 30, 60, 0.18);

--ld-transition: all 0.25s ease;

--ld-ui-height: 36px;

--ld-font-size: 13px;

}

#${BAR_ID} {

position: fixed; bottom: 25px; right: 25px; z-index: 99999;

font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

font-size: var(--ld-font-size); color: var(--ld-text-color); opacity: 0.88;

display: flex; align-items: stretch;

border-radius: var(--ld-border-radius); box-shadow: var(--ld-shadow);

overflow: hidden; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);

transition: opacity 0.3s ease, transform 0.3s ease;

border: 1px solid rgba(0,0,0,0.08);

height: var(--ld-ui-height);

}

#${BAR_ID}:hover { opacity: 1; transform: translateY(-2px); }

#${BAR_ID}.disabled { opacity: 0.7; transform: none; cursor: not-allowed; }

/* Limit Control */

#${LIMIT_CONTROL_ID} {

background: var(--ld-bg); padding: 0 12px;

display: flex; align-items: center; gap: 6px;

border-right: 1px solid rgba(0,0,0, 0.1);

cursor: pointer; transition: var(--ld-transition);

white-space: nowrap;

}

#${BAR_ID}:not(.disabled) #${LIMIT_CONTROL_ID}:hover { background: var(--ld-bg-hover); }

#${BAR_ID}.disabled #${LIMIT_CONTROL_ID} { cursor: not-allowed; background: #e9ecef; }

#${LIMIT_CONTROL_ID} label { margin:0; font-weight: 500; user-select: none;}

#${LIMIT_DISPLAY_ID} {

font-weight: 600; color: var(--ld-primary-color); min-width: 30px; text-align: center;

user-select: none;

}

#${BAR_ID}:not(.disabled) #${LIMIT_CONTROL_ID}:hover #${LIMIT_DISPLAY_ID} { text-decoration: underline dotted; }

#${LIMIT_INPUT_ID} {

width: 45px; padding: 3px 4px; border: 1px solid var(--ld-primary-color);

border-radius: 3px; text-align: center; outline: none;

font-size: var(--ld-font-size); font-weight: 600; color: var(--ld-primary-color);

background: white; box-sizing: border-box;

}

/* Action Button */

#${ACTION_BTN_ID} {

border: none; background-color: var(--ld-primary-color); color: white;

cursor: pointer; font-size: var(--ld-font-size); padding: 0 16px;

outline: none; transition: var(--ld-transition);

white-space: nowrap; font-weight: 500;

}

#${ACTION_BTN_ID}:hover:not(:disabled) { background-color: var(--ld-primary-hover); }

#${ACTION_BTN_ID}:active:not(:disabled) { filter: brightness(0.9); }

#${ACTION_BTN_ID}:disabled {

background-color: #adb5bd !important; cursor: not-allowed;

}

`;

document.head.appendChild(style);

}

// --- Turndown HTML to Markdown 转换器配置 ---

let turndownService;

function initTurndown() {

try {

turndownService = new TurndownService({

headingStyle: 'atx', hr: '---', bulletListMarker: '-',

codeBlockStyle: 'fenced', emDelimiter: '*', strongDelimiter: '**'

});

turndownService.addRule('emoji', { filter: 'img.emoji', replacement: (c, n) => n.alt || '' });

turndownService.addRule('oneboxAside', { filter: 'aside.onebox', replacement: (c, n) => { const l = n.querySelector('a[href]'); return l ? `\n[${l.innerText.trim() || l.href}](${l.href})\n` : '[Onebox]\n'; }});

turndownService.addRule('oneboxLink', { filter: 'a.onebox', replacement: (c, n) => `\n[${n.innerText.trim() || n.href}](${n.href})\n` });

turndownService.addRule('mention', { filter: n => n.matches('a.mention, a.mention-group'), replacement: (c, n) => n.textContent || '' });

turndownService.addRule('image', { filter: 'a.lightbox', replacement: (c, n) => { const i = n.querySelector('img'); const s = n.href || i?.src; return s ? `\n![${i?.alt || 'img'}](${s})\n` : ''; }});

console.log("TurndownService Init OK.");

return true;

} catch(e) {

console.error("TurndownService Init Fail!", e);

alert("Markdown 转换库加载失败!");

return false;

}

}

// --- 获取帖子元素 ---

function getPosts() {

const posts = Array.from(document.querySelectorAll('.post-stream article[data-post-id], article[data-post-id]'));

return posts.filter(post => !post.classList.contains('topic-status-info') && post.querySelector('.cooked, .post'));

}

// --- 更新限制状态、显示和存储 ---

function updateLimitState(value, display, input, button) {

const limit = parseInt(value, 10) || 0;

localStorage.setItem(LS_KEY_FLOORS, limit);

if(input) input.value = limit;

if(display) display.textContent = limit === 0 ? '全部' : limit;

if(button) button.title = `开始提取帖子内容 (范围: ${limit === 0 ? '全部楼层' : '前 ' + limit + ' 楼'})`;

console.log(`楼层限制更新: ${limit}`);

}

// --- 创建控制面板 UI ---

function createMinimalUI() {

injectStyles();

const bar = document.createElement('div');

bar.id = BAR_ID;

const limitControl = document.createElement('div');

limitControl.id = LIMIT_CONTROL_ID;

limitControl.title = "点击设置要提取的最大楼层数 (0=全部)";

const label = document.createElement('label');

label.textContent = "楼层:";

const limitDisplay = document.createElement('span');

limitDisplay.id = LIMIT_DISPLAY_ID;

const limitInput = document.createElement('input');

limitInput.type = 'number';

limitInput.id = LIMIT_INPUT_ID;

limitInput.min = 0;

limitInput.style.display = 'none'; // Hidden by default

limitControl.append(label, limitDisplay, limitInput);

const actionButton = document.createElement('button');

actionButton.id = ACTION_BTN_ID;

actionButton.textContent = ACTION_BTN_TEXT;

bar.append(limitControl, actionButton);

document.body.appendChild(bar);

// Init state

const initialValue = localStorage.getItem(LS_KEY_FLOORS);

updateLimitState(initialValue, limitDisplay, limitInput, actionButton);

return { bar, limitControl, actionButton, limitDisplay, limitInput };

}

let ui = {};

setTimeout(() => {

if(document.getElementById(BAR_ID)) return;

if(!initTurndown()) {

try { ui = createMinimalUI(); disableUI(ui, "库加载失败", '#dc3545'); } catch(e) { console.error("UI Fail", e); }

return;

};

try { ui = createMinimalUI(); addEventListeners(ui);

} catch(e) { console.error("UI Fail", e); alert("脚本UI创建失败!"); }

}, 1500);

function disableUI({bar, actionButton, limitInput}, text = null, bgColor = null) {

isRunning = true;

if(bar) bar.classList.add('disabled');

if(actionButton) {

actionButton.disabled = true;

if(text) actionButton.textContent = text;

if(bgColor) actionButton.style.backgroundColor = bgColor;

}

if(limitInput) limitInput.disabled = true;

// Ensure input is hidden if active

if(isEditingLimit && ui.limitInput && ui.limitDisplay){

ui.limitInput.style.display = 'none';

ui.limitDisplay.style.display = 'inline-block';

isEditingLimit = false;

}

}

function restoreUI({bar, actionButton, limitInput}, text = ACTION_BTN_TEXT, delay = 2500) {

setTimeout(() => {

isRunning = false;

if(bar) bar.classList.remove('disabled');

if(actionButton){

actionButton.disabled = false;

actionButton.textContent = text;

actionButton.style.backgroundColor = ''; // Reset to CSS default

}

if(limitInput) limitInput.disabled = false;

updateLimitState(localStorage.getItem(LS_KEY_FLOORS), ui.limitDisplay, ui.limitInput, ui.actionButton); // Refresh title

}, delay);

}

// --- 事件监听 ---

function addEventListeners({ bar, limitControl, actionButton, limitDisplay, limitInput }) {

if(!bar || !actionButton || !limitDisplay || !limitInput) return;

const showInput = () => {

if(isRunning) return;

limitInput.value = parseInt(localStorage.getItem(LS_KEY_FLOORS), 10) || 0; // Load current value

limitDisplay.style.display = 'none';

limitInput.style.display = 'block';

limitInput.focus(); limitInput.select();

isEditingLimit = true;

};

const saveAndHideInput = (save = true) => {

if(!isEditingLimit) return;

if(save) {

updateLimitState(limitInput.value, limitDisplay, limitInput, actionButton);

} else {

// Restore display without saving (for Escape key)

const lastValue = parseInt(localStorage.getItem(LS_KEY_FLOORS), 10) || 0;

limitDisplay.textContent = lastValue === 0 ? '全部' : lastValue;

}

limitInput.style.display = 'none';

limitDisplay.style.display = 'inline-block';

isEditingLimit = false;

};

limitControl.addEventListener('click', (e) => {

// Only trigger if not clicking the input itself while editing

if(!isRunning && !isEditingLimit) {

showInput();

e.stopPropagation(); // Prevent blur if clicking control bg

}

});

limitInput.addEventListener('blur', () => saveAndHideInput(true) );

limitInput.addEventListener('keydown', (e) => {

if (e.key === 'Enter') { e.preventDefault(); limitInput.blur(); } // blur will save

if (e.key === 'Escape') { e.preventDefault(); saveAndHideInput(false); } // Hide without saving

});

actionButton.addEventListener('click', async () => {

if(!turndownService || isRunning) return;

if(isEditingLimit) saveAndHideInput(true); // Save if input is open

if (!document.querySelector('#topic-title') || !document.querySelector('article[data-post-id]')) {

alert(`脚本错误(v2.0):无法找到关键页面元素!`);

disableUI(ui, '选择器错误!', '#dc3545');

restoreUI(ui, '选择器错误!', 4000); return;

}

const limit = parseInt(localStorage.getItem(LS_KEY_FLOORS), 10) || 0;

disableUI(ui, '准备中...');

try {

const currentLoaded = getPosts().length;

if (limit > 0 && currentLoaded >= limit) {

actionButton.textContent = `已加载 ${currentLoaded}/${limit}`;

} else {

actionButton.textContent = '滚动中... 0%';

await scrollToBottomAndLoad(actionButton, limit);

}

actionButton.textContent = '转换中...';

await new Promise(resolve => setTimeout(resolve, 800));

const content = extractContentMarkdown(limit);

if(!content || !content.text || content.postCount === 0) throw new Error("提取内容为空!");

downloadMD(content.title, content.text);

const target = limit > 0 ? limit : '全部';

restoreUI(ui, `完成! (${content.postCount}/${target})`, 4000);

} catch (error) {

console.error("SCRIPT Error:", error);

alert(`提取失败(v2.0): ${error.message}`);

disableUI(ui, '提取失败', '#dc3545');

restoreUI(ui, '提取失败', 4000);

}

});

}

async function scrollToBottomAndLoad(btn, limit = 0) {

let lastHeight = 0, stableCount = 0, totalPosts = 0;

const maxStableCount = Math.ceil(SCROLL_STABLE_TIME_MS / SCROLL_CHECK_INTERVAL_MS);

const startTime = Date.now();

try {

totalPosts = parseInt(document.querySelector('.topic-map .posts-count, .timeline-container span')?.textContent.replace(/\D/g,''), 10) || 0;

if(!totalPosts) totalPosts = parseInt(document.querySelector('.timeline-container .post-number:last-child')?.textContent.replace(/\D/g,''), 10) || 0;

} catch(e){}

const effectiveTotal = (limit > 0 && (totalPosts === 0 || limit < totalPosts)) ? limit : totalPosts;

while ( (Date.now() - startTime) < MAX_SCROLL_WAIT_SEC * 1000 ) {

window.scrollTo(0, document.body.scrollHeight);

await new Promise(resolve => setTimeout(resolve, SCROLL_CHECK_INTERVAL_MS));

const currentHeight = document.body.scrollHeight; const currentPostCount = getPosts().length;

const progressText = `(${currentPostCount}${effectiveTotal > 0 ? '/' + effectiveTotal : ''})`;

const percentage = (effectiveTotal > 0 && currentPostCount > 0) ? ' '+Math.min(100, Math.round((currentPostCount / effectiveTotal) * 100))+'%' : '';

btn.textContent = `滚动...${percentage} ${progressText}`;

if(limit > 0 && currentPostCount >= limit) { btn.textContent = `已加载 ${currentPostCount}/${limit}`; return; }

if (currentHeight > lastHeight) { lastHeight = currentHeight; stableCount = 0;

} else if (++stableCount >= maxStableCount) { btn.textContent = `加载完 ${progressText}`; return; }

if(totalPosts > 0 && limit === 0 && currentPostCount >= totalPosts ){ btn.textContent = `加载完 ${progressText}`; return; }

}

btn.textContent = `超时! (${getPosts().length})`;

}

function extractContentMarkdown(limit = 0) {

if(!turndownService) return { postCount: 0 };

const title = document.querySelector('#topic-title h1 a, #topic-title a')?.innerText.trim() || 'LINUXDO_Post';

let md = `# ${title}\n\n> 来源: ${window.location.href.split('?')[0]}\n\n`;

const allPosts = getPosts();

const posts = (limit > 0 && limit < allPosts.length) ? allPosts.slice(0, limit) : allPosts;

if (posts.length === 0) return { postCount: 0 };

posts.forEach((p, i) => {

const user = p.dataset.username || p.querySelector('.names .username a, .names span')?.innerText.trim() || 'User';

const html = p.querySelector('.cooked, .post')?.innerHTML || '[N/A]';

let postMD; try { postMD = turndownService.turndown(html).trim(); } catch(e) { postMD = `*[Error]*\n${p.querySelector('.cooked, .post')?.innerText.trim()||''}`;}

md += `---\n\n### [${i + 1}] ${user}\n\n${postMD}\n\n`;

});

return { title, text: md.trim() + '\n---\n', postCount: posts.length };

}

function downloadMD(title, text) {

const fn = (title || 'LINUXDO_POST').replace(/[\/\\:*?"<>|\s]/g, '_') + '.md';

const a = document.createElement('a');

a.href = URL.createObjectURL(new Blob([text], { type: 'text/markdown;charset=utf-8' })); a.download = fn;

document.body.appendChild(a); a.click();

setTimeout(()=>{ document.body.removeChild(a); URL.revokeObjectURL(a.href); }, 500);

}

})();

这个油猴插件是坛子里佬的分享,可以提取帖子的任意回复

Hhfguhvmp

(Hgfyk)

2025 年7 月 27 日 09:14

9

额,不行,可能是我的问题,我用chrome浏览器保存, 但是打开后一直在加载页面

abbb

(是人类)

2025 年7 月 27 日 09:15

10

SingleFile 浏览器插件,直接保存当前页面的所有信息(视频只有一帧截图)为单个html文档,需要保存原始页面的时候常用这个。

5 个赞

libook

(libook)

2025 年7 月 27 日 09:15

11

你试试打印到PDF

Cknight

(cknight)

2025 年7 月 27 日 09:16

12

我习惯用notion或者clip之类

Hhfguhvmp

(Hgfyk)

2025 年7 月 27 日 09:16

13

可以,但是页面显示感觉有点糙,不过还是非常感谢

lwang

2025 年7 月 27 日 09:18

14

// ==UserScript==

// @name Linux.do Forum Post Exporter

// @namespace https://linux.do/

// @version 1.2.0

// @description Export forum posts from linux.do with replies in JSON or HTML format

// @author Forum Exporter

// @match https://linux.do/t/*

// @match https://linux.do/t/topic/*

// @grant none

// @run-at document-idle

// ==/UserScript==

(function() {

'use strict';

// Convert image URL to base64 data URL

async function imageToBase64(url) {

try {

const response = await fetch(url);

const blob = await response.blob();

return new Promise((resolve, reject) => {

const reader = new FileReader();

reader.onloadend = () => resolve(reader.result);

reader.onerror = reject;

reader.readAsDataURL(blob);

});

} catch (error) {

console.error('Failed to convert image:', url, error);

return url; // Return original URL as fallback

}

}

// Wait for the page to fully load

function waitForPosts() {

return new Promise((resolve) => {

const checkInterval = setInterval(() => {

const posts = document.querySelectorAll('article[data-post-id]');

if (posts.length > 0) {

clearInterval(checkInterval);

resolve();

}

}, 500);

});

}

// Extract post data from a single post element

function extractPostData(postElement) {

const postId = postElement.getAttribute('data-post-id');

const postNumber = postElement.closest('.topic-post').getAttribute('data-post-number');

// Extract author information

const authorLink = postElement.querySelector('.names a');

const authorUsername = authorLink ? authorLink.textContent.trim() : '';

const authorHref = authorLink ? authorLink.getAttribute('href') : '';

// Extract avatar

const avatarImg = postElement.querySelector('.post-avatar img');

const avatarUrl = avatarImg ? avatarImg.getAttribute('src') : '';

// Extract post date

const dateElement = postElement.querySelector('.post-date .relative-date');

const postDate = dateElement ? dateElement.getAttribute('data-time') : '';

const postDateFormatted = dateElement ? dateElement.textContent.trim() : '';

// Extract post content

const contentElement = postElement.querySelector('.cooked');

const content = contentElement ? contentElement.innerHTML : '';

const contentText = contentElement ? contentElement.textContent.trim() : '';

// Extract quoted content if present

const quotes = [];

const quoteElements = postElement.querySelectorAll('aside.quote');

quoteElements.forEach(quote => {

const quotedUser = quote.getAttribute('data-username');

const quotedPost = quote.getAttribute('data-post');

const quotedContent = quote.querySelector('blockquote');

quotes.push({

username: quotedUser,

postNumber: quotedPost,

content: quotedContent ? quotedContent.innerHTML : ''

});

});

// Extract reply-to information

const replyToElement = postElement.querySelector('.reply-to-tab');

let replyTo = null;

if (replyToElement) {

const replyToUser = replyToElement.querySelector('span');

const replyToAvatar = replyToElement.querySelector('img');

replyTo = {

username: replyToUser ? replyToUser.textContent.trim() : '',

avatarUrl: replyToAvatar ? replyToAvatar.getAttribute('src') : ''

};

}

// Extract reaction counts

const reactions = [];

const reactionElements = postElement.querySelectorAll('.discourse-reactions-counter button');

reactionElements.forEach(reaction => {

const count = reaction.querySelector('.count');

const emoji = reaction.querySelector('.emoji');

if (count && emoji) {

reactions.push({

emoji: emoji.textContent.trim(),

count: parseInt(count.textContent.trim())

});

}

});

return {

postId,

postNumber: parseInt(postNumber),

author: {

username: authorUsername,

profileUrl: authorHref,

avatarUrl: avatarUrl

},

timestamp: postDate,

dateFormatted: postDateFormatted,

content: content,

contentText: contentText,

quotes: quotes,

replyTo: replyTo,

reactions: reactions

};

}

// Extract topic metadata

function extractTopicData() {

const topicTitle = document.querySelector('h1[data-topic-id] .fancy-title span');

const topicId = document.querySelector('h1[data-topic-id]')?.getAttribute('data-topic-id');

const category = document.querySelector('.badge-category__name');

const tags = [...new Set(Array.from(document.querySelectorAll('.discourse-tag')).map(tag => tag.textContent.trim()))];

return {

topicId: topicId,

title: topicTitle ? topicTitle.textContent.trim() : '',

category: category ? category.textContent.trim() : '',

tags: tags,

url: window.location.href

};

}

// Process content to convert images to base64

async function processContentImages(content) {

const tempDiv = document.createElement('div');

tempDiv.innerHTML = content;

const images = tempDiv.querySelectorAll('img');

for (const img of images) {

const src = img.getAttribute('src');

if (src) {

// Handle relative URLs

const fullUrl = src.startsWith('http') ? src : `https://linux.do${src}`;

const base64 = await imageToBase64(fullUrl);

img.setAttribute('src', base64);

}

}

return tempDiv.innerHTML;

}

// Clean content for HTML display

function cleanContentForHTML(content) {

const tempDiv = document.createElement('div');

tempDiv.innerHTML = content;

// Remove unnecessary elements

const selectorsToRemove = [

'.cooked-selection-barrier',

'div:empty',

'[aria-hidden="true"]'

];

selectorsToRemove.forEach(selector => {

tempDiv.querySelectorAll(selector).forEach(el => el.remove());

});

// Clean up whitespace

let cleaned = tempDiv.innerHTML

.replace(/\s*
\s*<\/div>/g, '

')

.replace(/

\s*<\/div>/g, '')

.replace(/\n\s*\n/g, '\n')

.trim();

return cleaned;

}

// Extract all posts from the page

async function extractAllPosts(convertImages = false) {

const posts = document.querySelectorAll('article[data-post-id]');

const postData = [];

for (const post of posts) {

const data = extractPostData(post);

if (convertImages) {

// Convert avatar images

if (data.author.avatarUrl) {

const fullUrl = data.author.avatarUrl.startsWith('http') ?

data.author.avatarUrl : `https://linux.do${data.author.avatarUrl}`;

data.author.avatarUrl = await imageToBase64(fullUrl);

}

// Convert content images

data.content = await processContentImages(data.content);

// Convert quoted content images

for (const quote of data.quotes) {

quote.content = await processContentImages(quote.content);

}

// Convert reply-to avatar

if (data.replyTo && data.replyTo.avatarUrl) {

const fullUrl = data.replyTo.avatarUrl.startsWith('http') ?

data.replyTo.avatarUrl : `https://linux.do${data.replyTo.avatarUrl}`;

data.replyTo.avatarUrl = await imageToBase64(fullUrl);

}

}

postData.push(data);

}

const topicData = extractTopicData();

return {

topic: topicData,

posts: postData,

exportDate: new Date().toISOString(),

postCount: postData.length

};

}

// Generate JSON export

function generateJSON(data) {

return JSON.stringify(data, null, 2);

}

// Generate HTML export

function generateHTML(data) {

// Clean content for each post

const cleanedPosts = data.posts.map(post => ({

...post,

content: cleanContentForHTML(post.content),

quotes: post.quotes.map(quote => ({

...quote,

content: cleanContentForHTML(quote.content)

}))

}));

const html = `

${data.topic.title} - Linux.do Forum Export

${data.topic.title}

${cleanedPosts.map(post => `

${post.author.avatarUrl ? `${post.author.username}` : ''}

${post.author.username}

#${post.postNumber}

${post.replyTo ? `

回复 @${post.replyTo.username}

` : ''}

${post.content}

`).join('')}

Exported from Linux.do on ${new Date(data.exportDate).toLocaleString()}

`;

return html;

}

// Download file

function downloadFile(content, filename, type) {

const blob = new Blob([content], { type: type });

const url = URL.createObjectURL(blob);

const a = document.createElement('a');

a.href = url;

a.download = filename;

document.body.appendChild(a);

a.click();

document.body.removeChild(a);

URL.revokeObjectURL(url);

}

// Show progress indicator

function showProgress(message) {

let progressDiv = document.getElementById('export-progress');

if (!progressDiv) {

progressDiv = document.createElement('div');

progressDiv.id = 'export-progress';

progressDiv.style.cssText = `

position: fixed;

top: 20px;

right: 20px;

background-color: #333;

color: white;

padding: 15px 20px;

border-radius: 5px;

font-size: 14px;

z-index: 10000;

box-shadow: 0 2px 10px rgba(0,0,0,0.3);

`;

document.body.appendChild(progressDiv);

}

progressDiv.textContent = message;

progressDiv.style.display = 'block';

}

function hideProgress() {

const progressDiv = document.getElementById('export-progress');

if (progressDiv) {

progressDiv.style.display = 'none';

}

}

// Create export button

function createExportButton() {

const button = document.createElement('div');

button.innerHTML = `

Embed images

`;

document.body.appendChild(button);

// Add event listeners

document.getElementById('export-json').addEventListener('click', async () => {

const embedImages = document.getElementById('embed-images').checked;

if (embedImages) {

showProgress('Converting images to base64 for JSON export...');

} else {

showProgress('Exporting to JSON...');

}

try {

const data = await extractAllPosts(embedImages); // Use embed images option

const json = generateJSON(data);

const filename = `linux-do-topic-${data.topic.topicId}-${Date.now()}.json`;

downloadFile(json, filename, 'application/json');

} catch (error) {

console.error('Export failed:', error);

alert('Export failed: ' + error.message);

} finally {

hideProgress();

}

});

document.getElementById('export-html').addEventListener('click', async () => {

const embedImages = document.getElementById('embed-images').checked;

if (embedImages) {

showProgress('Converting images to base64... This may take a moment.');

} else {

showProgress('Exporting to HTML...');

}

try {

const data = await extractAllPosts(embedImages);

const html = generateHTML(data);

const filename = `linux-do-topic-${data.topic.topicId}-${Date.now()}.html`;

downloadFile(html, filename, 'text/html');

} catch (error) {

console.error('Export failed:', error);

alert('Export failed: ' + error.message);

} finally {

hideProgress();

}

});

}

// Initialize the exporter

async function init() {

await waitForPosts();

createExportButton();

console.log('Linux.do Forum Exporter initialized');

}

// Start the script

init();

})();

也可以试试这个,油猴插件可以直接保存论坛帖子为html或者json格式

Hhfguhvmp

(Hgfyk)

2025 年7 月 27 日 09:20

15

没问题,确实非常好用

Hhfguhvmp

(Hgfyk)

2025 年7 月 27 日 09:21

16

PDF那就更不行啦,我如果想复制什么代码之类的,怎么复制呀

qaz1

(qaz1)

2025 年7 月 27 日 09:21

17

右键-保存网页, 或者自己搞个脚本

snicoe

2025 年7 月 27 日 09:21

18

可以自建Archivebox,或者用Singlefile插件直接保存为单个网页。

Hhfguhvmp

(Hgfyk)

2025 年7 月 27 日 09:23

20

抱歉,我搜了一下,全都是跟AI相关的,这个怎么保存网页。

beghorse

(BG)

2025 年7 月 27 日 09:23

21

github.com

GitHub - Ray-D-Song/web-archive: Free web archiving and sharing service based on...

Free web archiving and sharing service based on Cloudflare. 跑在 Cloudflare 上的免费网页归档和分享工具。

这里

1 个赞

相关数据

王者荣耀ad高端局思路?(王者荣耀高端局ad用谁)
官网体育在线365

王者荣耀ad高端局思路?(王者荣耀高端局ad用谁)

⌛ 08-11 👁️ 7403
元素周期表多少个元素
365bet中国官网

元素周期表多少个元素

⌛ 09-26 👁️ 6625
宠的成语
365bet中国官网

宠的成语

⌛ 07-03 👁️ 3942
幻方与幻和是什么
官网体育在线365

幻方与幻和是什么

⌛ 07-14 👁️ 2479
长虹 43E8
365sf.cn

长虹 43E8

⌛ 07-30 👁️ 9877