从有没有什么办法能让AI读本站文章继续讨论:
最近在看一些帖子也有类似需求,方便跟AI 讨论,就顺手让Claude 撸了一个。用的是Discourse 自带的功能:
Discourse 的话,可以通过替换链接拿到markdown 格式的内容,直接复制给AI 会更好 比如这个帖子的链接是:https://linux.do/t/topic/1984943,把t/topic/替换成raw/ :https://linux.do/raw/1984943 需要分页加:?page=1
脚本会在主贴和最后的回复出新增一个
按钮,点击导出成markdown 格式的文件。
性能开销应该比较小,不会对网站造成什么负担[1],当然你也可以用的时候再收到打开脚本。
篡改猴:
// ==UserScript==
// @name Linux.do 导出 Markdown
// @namespace https://linux.do/
// @version 0.0.4
// @description 在 linux.do 帖子页面所有 topic-map 处新增「导出 MD」按钮,将帖子内容导出为 Markdown 文件
// @author hwang
// @match https://linux.do/t/topic/*
// @grant GM_xmlhttpRequest
// @connect linux.do
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ── 工具函数 ──────────────────────────────────────────────────
function getTopicId() {
const m = location.pathname.match(/\/t\/[^/]+\/(\d+)/);
return m ? m[1] : null;
}
function getCurrentPage() {
const p = new URLSearchParams(location.search).get('page');
return p ? parseInt(p, 10) : 1;
}
function getTopicTitle() {
const el = document.querySelector('h1.fancy-title, h1[data-topic-id], .topic-title h1, .fancy-title');
return el ? el.textContent.trim() : `topic-${getTopicId()}`;
}
function safeFilename(name) {
return name.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_').slice(0, 100);
}
function fetchRaw(topicId, page) {
return new Promise((resolve, reject) => {
let url = `https://linux.do/raw/${topicId}`;
if (page && page > 1) url += `?page=${page}`;
GM_xmlhttpRequest({
method: 'GET',
url,
onload(res) {
if (res.status === 200) resolve(res.responseText);
else reject(new Error(`HTTP ${res.status}: ${url}`));
},
onerror(err) {
reject(new Error('网络错误: ' + JSON.stringify(err)));
},
});
});
}
function downloadText(filename, content) {
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
}
// ── 弹窗 UI ───────────────────────────────────────────────────
function createModal() {
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'fixed', inset: '0',
background: 'rgba(0,0,0,.55)',
zIndex: '99998',
display: 'flex', alignItems: 'center', justifyContent: 'center',
});
const box = document.createElement('div');
Object.assign(box.style, {
background: '#1e2229', color: '#d4d7dc',
borderRadius: '10px', padding: '28px 32px', width: '360px',
boxShadow: '0 8px 32px rgba(0,0,0,.6)', fontFamily: 'inherit',
position: 'relative', zIndex: '99999',
});
box.innerHTML = `
<h3 style="margin:0 0 18px;font-size:16px;color:#fff;">📥 导出 Markdown</h3>
<label style="display:block;margin-bottom:10px;font-size:13px;">
导出范围
<select id="ld-export-mode" style="display:block;width:100%;margin-top:6px;padding:7px 10px;
background:#2c3038;border:1px solid #404552;border-radius:6px;color:#d4d7dc;font-size:13px;cursor:pointer;">
<option value="first">仅第 1 页(默认)</option>
<option value="current">当前页</option>
<option value="custom">指定页码</option>
<option value="all">全部页(逐页合并)</option>
</select>
</label>
<div id="ld-custom-wrap" style="display:none;margin-bottom:10px;">
<label style="font-size:13px;">页码
<input id="ld-custom-page" type="number" min="1" value="1" style="display:block;width:100%;
margin-top:6px;padding:7px 10px;background:#2c3038;border:1px solid #404552;
border-radius:6px;color:#d4d7dc;font-size:13px;box-sizing:border-box;" />
</label>
</div>
<div id="ld-status" style="font-size:12px;color:#9ba0ab;min-height:20px;margin-bottom:14px;"></div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button id="ld-cancel-btn" style="padding:8px 18px;border-radius:6px;border:1px solid #404552;
background:transparent;color:#9ba0ab;cursor:pointer;font-size:13px;">取消</button>
<button id="ld-export-btn" style="padding:8px 18px;border-radius:6px;border:none;
background:#0088cc;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">导出</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const modeSelect = box.querySelector('#ld-export-mode');
const customWrap = box.querySelector('#ld-custom-wrap');
const customInput = box.querySelector('#ld-custom-page');
const statusEl = box.querySelector('#ld-status');
const exportBtn = box.querySelector('#ld-export-btn');
const cancelBtn = box.querySelector('#ld-cancel-btn');
modeSelect.addEventListener('change', () => {
customWrap.style.display = modeSelect.value === 'custom' ? 'block' : 'none';
if (modeSelect.value === 'current') customInput.value = getCurrentPage();
});
const closeModal = () => overlay.remove();
cancelBtn.addEventListener('click', closeModal);
overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });
exportBtn.addEventListener('click', async () => {
const topicId = getTopicId();
if (!topicId) { statusEl.textContent = '❌ 无法解析 Topic ID'; return; }
exportBtn.disabled = true;
exportBtn.textContent = '导出中…';
statusEl.textContent = '';
try {
const mode = modeSelect.value;
const title = getTopicTitle();
let content = '';
if (mode === 'first') {
statusEl.textContent = '正在拉取第 1 页…';
content = await fetchRaw(topicId, 1);
} else if (mode === 'current') {
const p = getCurrentPage();
statusEl.textContent = `正在拉取第 ${p} 页…`;
content = await fetchRaw(topicId, p);
} else if (mode === 'custom') {
const p = parseInt(customInput.value, 10) || 1;
statusEl.textContent = `正在拉取第 ${p} 页…`;
content = await fetchRaw(topicId, p);
} else if (mode === 'all') {
const parts = [];
let page = 1;
while (true) {
statusEl.textContent = `正在拉取第 ${page} 页…`;
let text;
try { text = await fetchRaw(topicId, page); } catch (e) { break; }
if (!text || text.trim() === '') break;
parts.push(`<!-- Page ${page} -->\n${text}`);
if (page > 1 && parts[page - 1].trim() === parts[page - 2].trim()) {
parts.pop(); break;
}
page++;
await new Promise(r => setTimeout(r, 300));
}
content = parts.join('\n\n---\n\n');
}
if (!content) { statusEl.textContent = '⚠️ 未获取到内容,请确认页码是否有效'; return; }
const filename = `${safeFilename(title)}.md`;
downloadText(filename, content);
statusEl.style.color = '#4caf50';
statusEl.textContent = `✅ 已下载:${filename}`;
setTimeout(closeModal, 1500);
} catch (err) {
statusEl.style.color = '#f44336';
statusEl.textContent = '❌ ' + err.message;
} finally {
exportBtn.disabled = false;
exportBtn.textContent = '导出';
}
});
}
// ── 注入按钮 ──────────────────────────────────────────────────
function findButtonContainer(topicMap) {
for (const sel of ['.topic-map__buttons', '.topic-footer-main-controls', '.buttons']) {
const el = topicMap.querySelector(sel);
if (el) return el;
}
const summarizeBtn = topicMap.querySelector(
'.summarize-topic, button.create-summary, button.topic-map__summarize-btn, button'
);
if (summarizeBtn) return summarizeBtn.parentElement;
const children = [...topicMap.children].filter(
el => el.tagName === 'DIV' || el.tagName === 'SECTION'
);
return children.length ? children[children.length - 1] : null;
}
function createButton() {
const btn = document.createElement('button');
btn.className = 'ld-export-md-btn';
btn.textContent = '⬇ 导出 MD';
// 获取参考样式(只在创建时获取一次)
const refBtn = document.querySelector('.topic-map button');
const refStyle = refBtn ? getComputedStyle(refBtn) : null;
Object.assign(btn.style, {
padding: '6px 14px',
borderRadius: refStyle ? refStyle.borderRadius : '6px',
border: '1px solid #404552',
background: '#2c3038',
color: refStyle ? refStyle.color : '#d4d7dc',
cursor: 'pointer',
fontSize: refStyle ? refStyle.fontSize : '13px',
fontWeight: '600',
marginRight: '8px',
verticalAlign: 'middle',
transition: 'background .2s',
flexShrink: '0',
});
btn.addEventListener('mouseover', () => btn.style.background = '#3a3f4b');
btn.addEventListener('mouseout', () => btn.style.background = '#2c3038');
return btn;
}
function injectButton(topicMap) {
if (topicMap.querySelector('.ld-export-md-btn')) return false;
const container = findButtonContainer(topicMap);
if (!container) return false;
const btn = createButton();
container.insertBefore(btn, container.firstChild);
return true;
}
// ── 性能优化核心:Intersection Observer + 事件委托 ──────────
const injectedMaps = new WeakSet();
// 使用事件委托,只绑定一次
document.body.addEventListener('click', (e) => {
if (e.target.classList.contains('ld-export-md-btn')) {
e.preventDefault();
e.stopPropagation();
createModal();
}
});
// Intersection Observer:只在元素可见时注入
const intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !injectedMaps.has(entry.target)) {
if (injectButton(entry.target)) {
injectedMaps.add(entry.target);
// 注入成功后停止观察该元素
intersectionObserver.unobserve(entry.target);
}
}
});
}, {
threshold: 0.1,
rootMargin: '50px' // 提前 50px 开始加载
});
// MutationObserver:只监听主内容区域,不监听整个 body
let mutationTimer = null;
const contentArea = document.querySelector('#main-outlet, #topic, .container.posts') || document.body;
const mutationObserver = new MutationObserver(() => {
if (mutationTimer) return;
mutationTimer = setTimeout(() => {
mutationTimer = null;
// 只查找新出现的 topic-map
const maps = contentArea.querySelectorAll('.topic-map');
maps.forEach(map => {
if (!injectedMaps.has(map)) {
intersectionObserver.observe(map);
}
});
}, 800); // 较长的防抖时间
});
mutationObserver.observe(contentArea, {
childList: true,
subtree: true
});
// 初始化:立即处理已存在的元素
document.querySelectorAll('.topic-map').forEach(map => {
intersectionObserver.observe(map);
});
// ── 路由跳转处理 ──────────────────────────────────────────────
function cleanup() {
intersectionObserver.disconnect();
mutationObserver.disconnect();
}
function reinit() {
cleanup();
// 重新初始化
const newContentArea = document.querySelector('#main-outlet, #topic, .container.posts') || document.body;
mutationObserver.observe(newContentArea, { childList: true, subtree: true });
document.querySelectorAll('.topic-map').forEach(map => {
if (!injectedMaps.has(map)) {
intersectionObserver.observe(map);
}
});
}
window.addEventListener('popstate', reinit);
const _pushState = history.pushState.bind(history);
history.pushState = function (...args) {
_pushState(...args);
setTimeout(reinit, 100); // 延迟等待 DOM 更新
};
})();
仅对前端页面修改,也不会对服务器造成压力 ↩︎
1 个帖子 - 1 位参与者