Discourse 论坛系统帖子保存器
- 内容介绍
- 文章标签
- 相关推荐
Logo是脚本完成后使用GLM5画的,我不得不吹一下,颜值在线,很符合我的审美
image809×788 61 KB
logo200×200 2.33 KB
image1920×1020 217 KB
image1920×1020 240 KB
image1920×1020 134 KB
建议直接使用仅主楼,全部楼层有BUG
// ==UserScript==
// @name Discourse 论坛系统帖子保存器
// @namespace https://greasyfork.org/
// @version 2.0
// @description 一键保存 Discourse 论坛帖子的标题和正文(支持仅主楼 / 全部楼层),可复制或下载为 Markdown
// @author 蓝颜
// @match *://*/*
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMDAgMjAwIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJtYWluR3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPjxzdG9wIG9mZnNldD0iMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMxOTc2ZDIiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNTY1YzAiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYWNjZW50R3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPjxzdG9wIG9mZnNldD0iMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiM0M2EwNDciLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMzODhlM2MiLz48L2xpbmVhckdyYWRpZW50PjxmaWx0ZXIgaWQ9InNoYWRvdyIgeD0iLTIwJSIgeT0iLTIwJSIgd2lkdGg9IjE0MCUiIGhlaWdodD0iMTQwJSI+PGZlRHJvcFNoYWRvdyBkeD0iMCIgZHk9IjQiIHN0ZERldmlhdGlvbj0iNiIgZmxvb2QtY29sb3I9IiMxOTc2ZDIiIGZsb29kLW9wYWNpdHk9IjAuMyIvPjwvZmlsdGVyPjwvZGVmcz48Y2lyY2xlIGN4PSIxMDAiIGN5PSIxMDAiIHI9IjkwIiBmaWxsPSJ1cmwoI21haW5HcmFkaWVudCkiIGZpbHRlcj0idXJsKCNzaGFkb3cpIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTAwLCAxMDApIj48cmVjdCB4PSItNDUiIHk9Ii01NSIgd2lkdGg9IjkwIiBoZWlnaHQ9IjExMCIgcng9IjgiIHJ5PSI4IiBmaWxsPSIjZmZmZmZmIiBvcGFjaXR5PSIwLjk1Ii8+PHBhdGggZD0iTSA0NSwtNTUgTCA0NSwtMzUgTCAyNSwtMzUgWiIgZmlsbD0iI2UzZjJmZCIvPjxwYXRoIGQ9Ik0gMjUsLTU1IEwgNDUsLTU1IEwgNDUsLTM1IEwgMjUsLTM1IFoiIGZpbGw9IiNiYmRlZmIiIG9wYWNpdHk9IjAuNiIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsIC0yMCkiPjx0ZXh0IHg9IjAiIHk9IjAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIzMiIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9InVybCgjbWFpbkdyYWRpZW50KSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSI+TTwvdGV4dD48cGF0aCBkPSJNIC04LDIwIEwgMCwzMiBMIDgsMjAiIHN0cm9rZT0idXJsKCNhY2NlbnRHcmFkaWVudCkiIHN0cm9rZS13aWR0aD0iMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PGxpbmUgeDE9IjAiIHkxPSIxMiIgeDI9IjAiIHkyPSIzMiIgc3Ryb2tlPSJ1cmwoI2FjY2VudEdyYWRpZW50KSIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L2c+PGcgb3BhY2l0eT0iMC40Ij48cmVjdCB4PSItMzAiIHk9IjI1IiB3aWR0aD0iNjAiIGhlaWdodD0iMyIgcng9IjEuNSIgZmlsbD0iIzE5NzZkMiIvPjxyZWN0IHg9Ii0zMCIgeT0iMzMiIHdpZHRoPSI0NSIgaGVpZ2h0PSIzIiByeD0iMS41IiBmaWxsPSIjMTk3NmQyIi8+PHJlY3QgeD0iLTMwIiB5PSI0MSIgd2lkdGg9IjUwIiBoZWlnaHQ9IjMiIHJ4PSIxLjUiIGZpbGw9IiMxOTc2ZDIiLz48L2c+PC9nPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDE0NSwgNTUpIj48Y2lyY2xlIGN4PSIwIiBjeT0iMCIgcj0iMTgiIGZpbGw9InVybCgjYWNjZW50R3JhZGllbnQpIiBvcGFjaXR5PSIwLjkiLz48cGF0aCBkPSJNIC04LC01IEwgOCwtNSBNIC04LDAgTCA4LDAgTSAtOCw1IEwgNCw1IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9IjAuOSIvPjwvZz48L3N2Zz4=
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// Discourse 检测 —— 非 Discourse 站点不加载
// ============================================================
function isDiscourse() {
return !!(
document.querySelector('meta[name="discourse_theme_id"]') ||
document.querySelector('meta[name="discourse_current_homepage"]') ||
document.querySelector('#discourse-main') ||
document.querySelector('.ember-application') && document.querySelector('.topic-post')
);
}
// 延迟检测(Discourse 是 Ember SPA,DOM 可能晚加载)
function waitForDiscourse(callback, retries = 15) {
if (isDiscourse()) return callback();
if (retries <= 0) return; // 非 Discourse,静默退出
setTimeout(() => waitForDiscourse(callback, retries - 1), 500);
}
waitForDiscourse(init);
// ============================================================
// 主初始化
// ============================================================
function init() {
injectStyles();
createFloatingButton();
registerMenuCommands();
}
// ============================================================
// Discourse DOM 选择器
// ============================================================
const SEL = {
// 标题选择器列表(按优先级依次尝试)
titleSelectors: [
'#topic-title .fancy-title',
'.title-wrapper .fancy-title',
'a.fancy-title',
'#topic-title h1 a',
'.title-wrapper h1',
'#topic-title h1',
'.topic-header .title h1',
'.fancy-title',
'h1[data-topic-id]',
'#topic-title',
],
// 分类 & 标签
category: '.topic-category .category-name, .badge-category .category-name, .topic-category .badge-category-bg + .badge-category .category-name',
tags: '.discourse-tags .discourse-tag, .list-tags .discourse-tag',
// 单个帖子容器
postContainer: '.topic-post',
// 帖子正文
postBody: '.cooked',
// 作者
postAuthor: '.topic-meta-data .names .username a, .topic-meta-data .names .first a',
// 发帖时间
postDate: '.topic-meta-data .post-date .relative-date',
// 楼层编号
postNumber: '.topic-meta-data .post-number, article[data-post-number]',
};
// ============================================================
// 数据提取
// ============================================================
/** 获取帖子标题(多策略:DOM 选择器 → document.title → Discourse JSON API) */
function getTitle() {
// 策略 1:依次尝试 DOM 选择器
for (const sel of SEL.titleSelectors) {
const el = document.querySelector(sel);
if (el && el.textContent.trim()) {
return el.textContent.trim();
}
}
// 策略 2:从 document.title 提取(Discourse 格式:"帖子标题 - 站点名")
const docTitle = document.title || '';
if (docTitle.includes(' - ')) {
const extracted = docTitle.substring(0, docTitle.lastIndexOf(' - ')).trim();
if (extracted) return extracted;
}
// 策略 3:从 URL 中提取 topic slug
const urlMatch = location.pathname.match(/\/t\/([^/]+)/);
if (urlMatch) {
return decodeURIComponent(urlMatch[1]).replace(/-/g, ' ');
}
return '';
}
/** 异步获取标题(通过 Discourse JSON API,作为终极后备) */
async function getTitleAsync() {
const title = getTitle();
if (title) return title;
// 从 URL 提取 topic ID,调用 Discourse JSON API
const idMatch = location.pathname.match(/\/t\/[^/]+\/(\d+)/);
if (idMatch) {
try {
const resp = await fetch(`/t/${idMatch[1]}.json`);
if (resp.ok) {
const data = await resp.json();
if (data.title) return data.title;
}
} catch (e) { /* 静默失败 */ }
}
return '(未识别到标题)';
}
/** 获取分类 */
function getCategory() {
const el = document.querySelector(SEL.category);
return el ? el.textContent.trim() : '';
}
/** 获取标签 */
function getTags() {
const els = document.querySelectorAll(SEL.tags);
return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
}
/** 提取干净正文(保留结构化换行) */
function extractText(element) {
if (!element) return '';
const clone = element.cloneNode(true);
// 移除无关元素
clone.querySelectorAll(
'script, style, .lightbox-wrapper .meta, .onebox-metadata, .poll-info'
).forEach(el => el.remove());
// 处理图片:转为 Markdown 图片语法
clone.querySelectorAll('img').forEach(img => {
const alt = img.alt || '图片';
const src = img.getAttribute('data-orig-src') || img.src || '';
const md = src ? `` : `![${alt}]`;
img.replaceWith(document.createTextNode(md));
});
// 处理链接:转为 Markdown 链接语法
clone.querySelectorAll('a').forEach(a => {
const href = a.href || '';
const text = a.textContent.trim();
if (href && text && text !== href) {
a.replaceWith(document.createTextNode(`[${text}](${href})`));
} else if (href) {
a.replaceWith(document.createTextNode(href));
}
});
// 处理代码块
clone.querySelectorAll('pre code').forEach(code => {
const lang = code.className.replace('lang-', '').replace('language-', '') || '';
const text = code.textContent;
const replacement = `\n\`\`\`${lang}\n${text}\n\`\`\`\n`;
code.closest('pre').replaceWith(document.createTextNode(replacement));
});
// 处理行内代码
clone.querySelectorAll('code').forEach(code => {
code.replaceWith(document.createTextNode(`\`${code.textContent}\``));
});
// 处理引用块
clone.querySelectorAll('blockquote').forEach(bq => {
const lines = bq.textContent.trim().split('\n').map(l => `> ${l.trim()}`).join('\n');
bq.replaceWith(document.createTextNode(`\n${lines}\n`));
});
// 处理列表
clone.querySelectorAll('li').forEach(li => {
const parent = li.parentElement;
const isOrdered = parent && parent.tagName === 'OL';
const idx = isOrdered ? Array.from(parent.children).indexOf(li) + 1 : 0;
const prefix = isOrdered ? `${idx}. ` : '- ';
li.innerHTML = `\n${prefix}${li.innerHTML}`;
});
// 块级元素换行
let html = clone.innerHTML;
html = html.replace(/<br\s*\/?>/gi, '\n');
html = html.replace(/<\/(p|div|h[1-6]|tr|ul|ol)>/gi, '\n');
html = html.replace(/<(p|div|h[1-6]|tr|ul|ol)[^>]*>/gi, '\n');
html = html.replace(/<hr\s*\/?>/gi, '\n---\n');
const temp = document.createElement('div');
temp.innerHTML = html;
let text = temp.textContent || temp.innerText || '';
text = text.replace(/\n{3,}/g, '\n\n').trim();
return text;
}
/** 提取单个楼层数据 */
function parsePost(postEl) {
const authorEl = postEl.querySelector(SEL.postAuthor);
const dateEl = postEl.querySelector(SEL.postDate);
const bodyEl = postEl.querySelector(SEL.postBody);
const article = postEl.querySelector('article[data-post-number]');
return {
number: article ? article.getAttribute('data-post-number') : '?',
author: authorEl ? authorEl.textContent.trim() : '匿名',
date: dateEl ? (dateEl.getAttribute('title') || dateEl.textContent.trim()) : '',
content: extractText(bodyEl),
};
}
/** 获取主楼数据 */
function getFirstPost() {
const postEl = document.querySelector(SEL.postContainer);
return postEl ? parsePost(postEl) : null;
}
/** 获取所有楼层数据 */
function getAllPosts() {
const postEls = document.querySelectorAll(SEL.postContainer);
return Array.from(postEls).map(parsePost);
}
// ============================================================
// 格式化输出
// ============================================================
async function formatOutput(mode) {
const title = await getTitleAsync();
const category = getCategory();
const tags = getTags();
const url = location.href;
const now = new Date().toLocaleString('zh-CN');
const lines = [];
// Markdown 标题
lines.push(`# ${title || '(未识别)'}`);
lines.push('');
// 元信息表格
lines.push('| 属性 | 值 |');
lines.push('| --- | --- |');
if (category) lines.push(`| 分类 | ${category} |`);
if (tags.length) lines.push(`| 标签 | ${tags.map(t => '`' + t + '`').join(' ')} |`);
lines.push(`| 来源 | ${url} |`);
lines.push(`| 保存时间 | ${now} |`);
lines.push('');
lines.push('---');
if (mode === 'first') {
// 仅主楼
const post = getFirstPost();
if (post) {
lines.push('');
if (post.author || post.date) {
lines.push(`**作者:** ${post.author} | **时间:** ${post.date}`);
lines.push('');
}
lines.push(post.content || '*(正文为空)*');
} else {
lines.push('');
lines.push('*(未找到帖子内容)*');
}
} else {
// 全部楼层
const posts = getAllPosts();
if (posts.length === 0) {
lines.push('');
lines.push('*(未找到帖子内容)*');
} else {
posts.forEach((post) => {
lines.push('');
lines.push(`## #${post.number} ${post.author}`);
lines.push('');
if (post.date) lines.push(`> ${post.date}`);
lines.push('');
lines.push(post.content || '*(内容为空)*');
lines.push('');
lines.push('---');
});
}
}
return lines.join('\n');
}
// ============================================================
// 工具函数
// ============================================================
function downloadAsFile(text, filename) {
const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
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);
}
function safeFilename(title) {
return (title || 'discourse-post').replace(/[\\/:*?"<>|]/g, '_').substring(0, 80);
}
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = 'gm-save-toast';
toast.textContent = message;
toast.style.borderLeft = type === 'success' ? '4px solid #4caf50' : '4px solid #f44336';
document.body.appendChild(toast);
requestAnimationFrame(() => {
toast.style.opacity = '1';
toast.style.transform = 'translateY(0)';
});
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(-20px)';
setTimeout(() => toast.remove(), 300);
}, 2500);
}
function escapeHtml(str) {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function escapeAttr(str) {
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
}
// ============================================================
// UI 样式
// ============================================================
function injectStyles() {
GM_addStyle(`
/* 悬浮按钮 */
#gm-save-btn {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 99999;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #1976d2, #1565c0);
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 14px rgba(25, 118, 210, 0.4);
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
opacity: 0.8;
}
#gm-save-btn:hover {
opacity: 1;
transform: scale(1.1);
box-shadow: 0 6px 22px rgba(25, 118, 210, 0.55);
}
/* 弹窗遮罩 */
#gm-save-overlay {
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
#gm-save-overlay.show { opacity: 1; }
/* 弹窗主体 */
#gm-save-modal {
background: #fff;
border-radius: 12px;
width: 720px;
max-width: 92vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 80px rgba(0,0,0,0.25);
transform: scale(0.92);
transition: transform 0.2s ease;
}
#gm-save-overlay.show #gm-save-modal { transform: scale(1); }
/* 头部 */
.gm-modal-header {
padding: 16px 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
}
.gm-modal-header h3 { margin: 0; font-size: 16px; color: #222; }
.gm-modal-close {
background: none; border: none; font-size: 22px;
cursor: pointer; color: #999; padding: 0 4px;
}
.gm-modal-close:hover { color: #333; }
/* 选项区 */
.gm-options {
padding: 12px 20px;
background: #f8f9fa;
border-bottom: 1px solid #e8e8e8;
display: flex;
gap: 20px;
align-items: center;
font-size: 14px;
color: #555;
}
.gm-options label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-weight: normal !important;
margin: 0 !important;
}
.gm-options input[type="radio"] { margin: 0; cursor: pointer; }
/* 预览区 */
.gm-modal-body {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
}
.gm-preview {
width: 100%;
box-sizing: border-box;
border: 1px solid #ddd;
border-radius: 8px;
padding: 12px 16px;
font-size: 13px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
line-height: 1.6;
resize: vertical;
min-height: 300px;
outline: none;
background: #fafafa;
white-space: pre-wrap;
word-break: break-word;
}
.gm-preview:focus { border-color: #1976d2; background: #fff; }
/* 底部按钮 */
.gm-modal-footer {
padding: 12px 20px;
border-top: 1px solid #e8e8e8;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.gm-btn {
padding: 8px 20px; border-radius: 6px; border: none;
cursor: pointer; font-size: 14px; font-weight: 500;
transition: background 0.2s, box-shadow 0.2s;
}
.gm-btn-primary { background: #1976d2; color: #fff; }
.gm-btn-primary:hover { background: #1565c0; box-shadow: 0 2px 8px rgba(25,118,210,0.3); }
.gm-btn-success { background: #43a047; color: #fff; }
.gm-btn-success:hover { background: #388e3c; box-shadow: 0 2px 8px rgba(67,160,71,0.3); }
.gm-btn-secondary { background: #e0e0e0; color: #333; }
.gm-btn-secondary:hover { background: #c0c0c0; }
/* 帖子数统计 */
.gm-post-count {
font-size: 12px; color: #999; margin-left: auto;
}
/* Toast */
.gm-save-toast {
position: fixed; top: 20px; right: 20px; z-index: 100001;
background: #fff; padding: 12px 20px; border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
font-size: 14px; color: #333;
opacity: 0; transform: translateY(-20px);
transition: all 0.3s ease;
}
`);
}
// ============================================================
// 悬浮按钮
// ============================================================
function createFloatingButton() {
const btn = document.createElement('button');
btn.id = 'gm-save-btn';
// 使用自定义 Logo SVG 图标
btn.innerHTML = `<svg viewBox="0 0 200 200" width="28" height="28" style="display:block">
<defs>
<linearGradient id="gm-logo-grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1976d2"/>
<stop offset="100%" style="stop-color:#1565c0"/>
</linearGradient>
<linearGradient id="gm-logo-grad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#43a047"/>
<stop offset="100%" style="stop-color:#388e3c"/>
</linearGradient>
</defs>
<circle cx="100" cy="100" r="90" fill="url(#gm-logo-grad1)"/>
<g transform="translate(100, 100)">
<rect x="-45" y="-55" width="90" height="110" rx="8" ry="8" fill="#ffffff" opacity="0.95"/>
<path d="M 45,-55 L 45,-35 L 25,-35 Z" fill="#e3f2fd"/>
<path d="M 25,-55 L 45,-55 L 45,-35 L 25,-35 Z" fill="#bbdefb" opacity="0.6"/>
<g transform="translate(0, -20)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="url(#gm-logo-grad1)" text-anchor="middle" dominant-baseline="middle">M</text>
<path d="M -8,20 L 0,32 L 8,20" stroke="url(#gm-logo-grad2)" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="0" y1="12" x2="0" y2="32" stroke="url(#gm-logo-grad2)" stroke-width="3" stroke-linecap="round"/>
</g>
<g opacity="0.4">
<rect x="-30" y="25" width="60" height="3" rx="1.5" fill="#1976d2"/>
<rect x="-30" y="33" width="45" height="3" rx="1.5" fill="#1976d2"/>
<rect x="-30" y="41" width="50" height="3" rx="1.5" fill="#1976d2"/>
</g>
</g>
<g transform="translate(145, 55)">
<circle cx="0" cy="0" r="18" fill="url(#gm-logo-grad2)" opacity="0.9"/>
<path d="M -8,-5 L 8,-5 M -8,0 L 8,0 M -8,5 L 4,5" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
</g>
</svg>`;
btn.title = '保存 Discourse 帖子';
document.body.appendChild(btn);
btn.addEventListener('click', () => openModal());
}
// ============================================================
// 弹窗
// ============================================================
async function openModal() {
const old = document.getElementById('gm-save-overlay');
if (old) old.remove();
const totalPosts = document.querySelectorAll(SEL.postContainer).length;
const initialText = await formatOutput('first');
const overlay = document.createElement('div');
overlay.id = 'gm-save-overlay';
overlay.innerHTML = `
<div id="gm-save-modal">
<div class="gm-modal-header">
<h3>保存帖子内容</h3>
<button class="gm-modal-close" id="gm-modal-close">×</button>
</div>
<div class="gm-options">
<span>范围:</span>
<label><input type="radio" name="gm-scope" value="first" checked /> 仅主楼</label>
<label><input type="radio" name="gm-scope" value="all" /> 全部楼层</label>
<span class="gm-post-count">当前页共 ${totalPosts} 楼</span>
</div>
<div class="gm-modal-body">
<textarea class="gm-preview" id="gm-preview">${escapeHtml(initialText)}</textarea>
</div>
<div class="gm-modal-footer">
<button class="gm-btn gm-btn-secondary" id="gm-btn-cancel">取消</button>
<button class="gm-btn gm-btn-primary" id="gm-btn-copy">复制到剪贴板</button>
<button class="gm-btn gm-btn-success" id="gm-btn-download">下载 Markdown</button>
</div>
</div>
`;
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('show'));
// --- 事件绑定 ---
const closeModal = () => {
overlay.classList.remove('show');
setTimeout(() => overlay.remove(), 200);
};
overlay.querySelector('#gm-modal-close').onclick = closeModal;
overlay.querySelector('#gm-btn-cancel').onclick = closeModal;
overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', escHandler); }
});
// 范围切换
overlay.querySelectorAll('input[name="gm-scope"]').forEach(radio => {
radio.addEventListener('change', async () => {
const mode = overlay.querySelector('input[name="gm-scope"]:checked').value;
document.getElementById('gm-preview').value = await formatOutput(mode);
});
});
// 复制
overlay.querySelector('#gm-btn-copy').onclick = () => {
const text = document.getElementById('gm-preview').value;
GM_setClipboard(text, 'text');
showToast('已复制到剪贴板');
closeModal();
};
// 下载:从预览区内容中提取第一行的标题作为文件名
overlay.querySelector('#gm-btn-download').onclick = async () => {
const text = document.getElementById('gm-preview').value;
const title = await getTitleAsync();
downloadAsFile(text, `${safeFilename(title)}.md`);
showToast('Markdown 文件已下载');
closeModal();
};
}
// ============================================================
// 油猴菜单命令
// ============================================================
function registerMenuCommands() {
GM_registerMenuCommand('保存帖子(弹窗)', () => openModal());
GM_registerMenuCommand('快速复制 - 仅主楼', async () => {
const text = await formatOutput('first');
GM_setClipboard(text, 'text');
showToast('主楼内容已复制');
});
GM_registerMenuCommand('快速复制 - 全部楼层', async () => {
const text = await formatOutput('all');
GM_setClipboard(text, 'text');
showToast('全部楼层已复制');
});
GM_registerMenuCommand('快速下载 - 仅主楼', async () => {
const text = await formatOutput('first');
const title = await getTitleAsync();
downloadAsFile(text, `${safeFilename(title)}.md`);
showToast('Markdown 文件已下载');
});
GM_registerMenuCommand('快速下载 - 全部楼层', async () => {
const text = await formatOutput('all');
const title = await getTitleAsync();
downloadAsFile(text, `${safeFilename(title)}.md`);
showToast('Markdown 文件已下载');
});
}
})();
网友解答:
--【壹】--:
image1909×989 110 KB
--【贰】--:
cool!
--【叁】--:
有链接吗
--【肆】--:
感谢大佬。
--【伍】--:
忘了放脚本了不好意思
--【陆】--:
不错,虽然我一般用singlefile直接下载原html,但这个占的空间更小
--【柒】--:
image1920×1029 113 KB
Logo是脚本完成后使用GLM5画的,我不得不吹一下,颜值在线,很符合我的审美
image809×788 61 KB
logo200×200 2.33 KB
image1920×1020 217 KB
image1920×1020 240 KB
image1920×1020 134 KB
建议直接使用仅主楼,全部楼层有BUG
// ==UserScript==
// @name Discourse 论坛系统帖子保存器
// @namespace https://greasyfork.org/
// @version 2.0
// @description 一键保存 Discourse 论坛帖子的标题和正文(支持仅主楼 / 全部楼层),可复制或下载为 Markdown
// @author 蓝颜
// @match *://*/*
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMDAgMjAwIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJtYWluR3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPjxzdG9wIG9mZnNldD0iMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMxOTc2ZDIiLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMxNTY1YzAiLz48L2xpbmVhckdyYWRpZW50PjxsaW5lYXJHcmFkaWVudCBpZD0iYWNjZW50R3JhZGllbnQiIHgxPSIwJSIgeTE9IjAlIiB4Mj0iMTAwJSIgeTI9IjEwMCUiPjxzdG9wIG9mZnNldD0iMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiM0M2EwNDciLz48c3RvcCBvZmZzZXQ9IjEwMCUiIHN0eWxlPSJzdG9wLWNvbG9yOiMzODhlM2MiLz48L2xpbmVhckdyYWRpZW50PjxmaWx0ZXIgaWQ9InNoYWRvdyIgeD0iLTIwJSIgeT0iLTIwJSIgd2lkdGg9IjE0MCUiIGhlaWdodD0iMTQwJSI+PGZlRHJvcFNoYWRvdyBkeD0iMCIgZHk9IjQiIHN0ZERldmlhdGlvbj0iNiIgZmxvb2QtY29sb3I9IiMxOTc2ZDIiIGZsb29kLW9wYWNpdHk9IjAuMyIvPjwvZmlsdGVyPjwvZGVmcz48Y2lyY2xlIGN4PSIxMDAiIGN5PSIxMDAiIHI9IjkwIiBmaWxsPSJ1cmwoI21haW5HcmFkaWVudCkiIGZpbHRlcj0idXJsKCNzaGFkb3cpIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTAwLCAxMDApIj48cmVjdCB4PSItNDUiIHk9Ii01NSIgd2lkdGg9IjkwIiBoZWlnaHQ9IjExMCIgcng9IjgiIHJ5PSI4IiBmaWxsPSIjZmZmZmZmIiBvcGFjaXR5PSIwLjk1Ii8+PHBhdGggZD0iTSA0NSwtNTUgTCA0NSwtMzUgTCAyNSwtMzUgWiIgZmlsbD0iI2UzZjJmZCIvPjxwYXRoIGQ9Ik0gMjUsLTU1IEwgNDUsLTU1IEwgNDUsLTM1IEwgMjUsLTM1IFoiIGZpbGw9IiNiYmRlZmIiIG9wYWNpdHk9IjAuNiIvPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsIC0yMCkiPjx0ZXh0IHg9IjAiIHk9IjAiIGZvbnQtZmFtaWx5PSJBcmlhbCwgc2Fucy1zZXJpZiIgZm9udC1zaXplPSIzMiIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9InVybCgjbWFpbkdyYWRpZW50KSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSI+TTwvdGV4dD48cGF0aCBkPSJNIC04LDIwIEwgMCwzMiBMIDgsMjAiIHN0cm9rZT0idXJsKCNhY2NlbnRHcmFkaWVudCkiIHN0cm9rZS13aWR0aD0iMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PGxpbmUgeDE9IjAiIHkxPSIxMiIgeDI9IjAiIHkyPSIzMiIgc3Ryb2tlPSJ1cmwoI2FjY2VudEdyYWRpZW50KSIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L2c+PGcgb3BhY2l0eT0iMC40Ij48cmVjdCB4PSItMzAiIHk9IjI1IiB3aWR0aD0iNjAiIGhlaWdodD0iMyIgcng9IjEuNSIgZmlsbD0iIzE5NzZkMiIvPjxyZWN0IHg9Ii0zMCIgeT0iMzMiIHdpZHRoPSI0NSIgaGVpZ2h0PSIzIiByeD0iMS41IiBmaWxsPSIjMTk3NmQyIi8+PHJlY3QgeD0iLTMwIiB5PSI0MSIgd2lkdGg9IjUwIiBoZWlnaHQ9IjMiIHJ4PSIxLjUiIGZpbGw9IiMxOTc2ZDIiLz48L2c+PC9nPjxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDE0NSwgNTUpIj48Y2lyY2xlIGN4PSIwIiBjeT0iMCIgcj0iMTgiIGZpbGw9InVybCgjYWNjZW50R3JhZGllbnQpIiBvcGFjaXR5PSIwLjkiLz48cGF0aCBkPSJNIC04LC01IEwgOCwtNSBNIC04LDAgTCA4LDAgTSAtOCw1IEwgNCw1IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9IjAuOSIvPjwvZz48L3N2Zz4=
// ==/UserScript==
(function () {
'use strict';
// ============================================================
// Discourse 检测 —— 非 Discourse 站点不加载
// ============================================================
function isDiscourse() {
return !!(
document.querySelector('meta[name="discourse_theme_id"]') ||
document.querySelector('meta[name="discourse_current_homepage"]') ||
document.querySelector('#discourse-main') ||
document.querySelector('.ember-application') && document.querySelector('.topic-post')
);
}
// 延迟检测(Discourse 是 Ember SPA,DOM 可能晚加载)
function waitForDiscourse(callback, retries = 15) {
if (isDiscourse()) return callback();
if (retries <= 0) return; // 非 Discourse,静默退出
setTimeout(() => waitForDiscourse(callback, retries - 1), 500);
}
waitForDiscourse(init);
// ============================================================
// 主初始化
// ============================================================
function init() {
injectStyles();
createFloatingButton();
registerMenuCommands();
}
// ============================================================
// Discourse DOM 选择器
// ============================================================
const SEL = {
// 标题选择器列表(按优先级依次尝试)
titleSelectors: [
'#topic-title .fancy-title',
'.title-wrapper .fancy-title',
'a.fancy-title',
'#topic-title h1 a',
'.title-wrapper h1',
'#topic-title h1',
'.topic-header .title h1',
'.fancy-title',
'h1[data-topic-id]',
'#topic-title',
],
// 分类 & 标签
category: '.topic-category .category-name, .badge-category .category-name, .topic-category .badge-category-bg + .badge-category .category-name',
tags: '.discourse-tags .discourse-tag, .list-tags .discourse-tag',
// 单个帖子容器
postContainer: '.topic-post',
// 帖子正文
postBody: '.cooked',
// 作者
postAuthor: '.topic-meta-data .names .username a, .topic-meta-data .names .first a',
// 发帖时间
postDate: '.topic-meta-data .post-date .relative-date',
// 楼层编号
postNumber: '.topic-meta-data .post-number, article[data-post-number]',
};
// ============================================================
// 数据提取
// ============================================================
/** 获取帖子标题(多策略:DOM 选择器 → document.title → Discourse JSON API) */
function getTitle() {
// 策略 1:依次尝试 DOM 选择器
for (const sel of SEL.titleSelectors) {
const el = document.querySelector(sel);
if (el && el.textContent.trim()) {
return el.textContent.trim();
}
}
// 策略 2:从 document.title 提取(Discourse 格式:"帖子标题 - 站点名")
const docTitle = document.title || '';
if (docTitle.includes(' - ')) {
const extracted = docTitle.substring(0, docTitle.lastIndexOf(' - ')).trim();
if (extracted) return extracted;
}
// 策略 3:从 URL 中提取 topic slug
const urlMatch = location.pathname.match(/\/t\/([^/]+)/);
if (urlMatch) {
return decodeURIComponent(urlMatch[1]).replace(/-/g, ' ');
}
return '';
}
/** 异步获取标题(通过 Discourse JSON API,作为终极后备) */
async function getTitleAsync() {
const title = getTitle();
if (title) return title;
// 从 URL 提取 topic ID,调用 Discourse JSON API
const idMatch = location.pathname.match(/\/t\/[^/]+\/(\d+)/);
if (idMatch) {
try {
const resp = await fetch(`/t/${idMatch[1]}.json`);
if (resp.ok) {
const data = await resp.json();
if (data.title) return data.title;
}
} catch (e) { /* 静默失败 */ }
}
return '(未识别到标题)';
}
/** 获取分类 */
function getCategory() {
const el = document.querySelector(SEL.category);
return el ? el.textContent.trim() : '';
}
/** 获取标签 */
function getTags() {
const els = document.querySelectorAll(SEL.tags);
return Array.from(els).map(e => e.textContent.trim()).filter(Boolean);
}
/** 提取干净正文(保留结构化换行) */
function extractText(element) {
if (!element) return '';
const clone = element.cloneNode(true);
// 移除无关元素
clone.querySelectorAll(
'script, style, .lightbox-wrapper .meta, .onebox-metadata, .poll-info'
).forEach(el => el.remove());
// 处理图片:转为 Markdown 图片语法
clone.querySelectorAll('img').forEach(img => {
const alt = img.alt || '图片';
const src = img.getAttribute('data-orig-src') || img.src || '';
const md = src ? `` : `![${alt}]`;
img.replaceWith(document.createTextNode(md));
});
// 处理链接:转为 Markdown 链接语法
clone.querySelectorAll('a').forEach(a => {
const href = a.href || '';
const text = a.textContent.trim();
if (href && text && text !== href) {
a.replaceWith(document.createTextNode(`[${text}](${href})`));
} else if (href) {
a.replaceWith(document.createTextNode(href));
}
});
// 处理代码块
clone.querySelectorAll('pre code').forEach(code => {
const lang = code.className.replace('lang-', '').replace('language-', '') || '';
const text = code.textContent;
const replacement = `\n\`\`\`${lang}\n${text}\n\`\`\`\n`;
code.closest('pre').replaceWith(document.createTextNode(replacement));
});
// 处理行内代码
clone.querySelectorAll('code').forEach(code => {
code.replaceWith(document.createTextNode(`\`${code.textContent}\``));
});
// 处理引用块
clone.querySelectorAll('blockquote').forEach(bq => {
const lines = bq.textContent.trim().split('\n').map(l => `> ${l.trim()}`).join('\n');
bq.replaceWith(document.createTextNode(`\n${lines}\n`));
});
// 处理列表
clone.querySelectorAll('li').forEach(li => {
const parent = li.parentElement;
const isOrdered = parent && parent.tagName === 'OL';
const idx = isOrdered ? Array.from(parent.children).indexOf(li) + 1 : 0;
const prefix = isOrdered ? `${idx}. ` : '- ';
li.innerHTML = `\n${prefix}${li.innerHTML}`;
});
// 块级元素换行
let html = clone.innerHTML;
html = html.replace(/<br\s*\/?>/gi, '\n');
html = html.replace(/<\/(p|div|h[1-6]|tr|ul|ol)>/gi, '\n');
html = html.replace(/<(p|div|h[1-6]|tr|ul|ol)[^>]*>/gi, '\n');
html = html.replace(/<hr\s*\/?>/gi, '\n---\n');
const temp = document.createElement('div');
temp.innerHTML = html;
let text = temp.textContent || temp.innerText || '';
text = text.replace(/\n{3,}/g, '\n\n').trim();
return text;
}
/** 提取单个楼层数据 */
function parsePost(postEl) {
const authorEl = postEl.querySelector(SEL.postAuthor);
const dateEl = postEl.querySelector(SEL.postDate);
const bodyEl = postEl.querySelector(SEL.postBody);
const article = postEl.querySelector('article[data-post-number]');
return {
number: article ? article.getAttribute('data-post-number') : '?',
author: authorEl ? authorEl.textContent.trim() : '匿名',
date: dateEl ? (dateEl.getAttribute('title') || dateEl.textContent.trim()) : '',
content: extractText(bodyEl),
};
}
/** 获取主楼数据 */
function getFirstPost() {
const postEl = document.querySelector(SEL.postContainer);
return postEl ? parsePost(postEl) : null;
}
/** 获取所有楼层数据 */
function getAllPosts() {
const postEls = document.querySelectorAll(SEL.postContainer);
return Array.from(postEls).map(parsePost);
}
// ============================================================
// 格式化输出
// ============================================================
async function formatOutput(mode) {
const title = await getTitleAsync();
const category = getCategory();
const tags = getTags();
const url = location.href;
const now = new Date().toLocaleString('zh-CN');
const lines = [];
// Markdown 标题
lines.push(`# ${title || '(未识别)'}`);
lines.push('');
// 元信息表格
lines.push('| 属性 | 值 |');
lines.push('| --- | --- |');
if (category) lines.push(`| 分类 | ${category} |`);
if (tags.length) lines.push(`| 标签 | ${tags.map(t => '`' + t + '`').join(' ')} |`);
lines.push(`| 来源 | ${url} |`);
lines.push(`| 保存时间 | ${now} |`);
lines.push('');
lines.push('---');
if (mode === 'first') {
// 仅主楼
const post = getFirstPost();
if (post) {
lines.push('');
if (post.author || post.date) {
lines.push(`**作者:** ${post.author} | **时间:** ${post.date}`);
lines.push('');
}
lines.push(post.content || '*(正文为空)*');
} else {
lines.push('');
lines.push('*(未找到帖子内容)*');
}
} else {
// 全部楼层
const posts = getAllPosts();
if (posts.length === 0) {
lines.push('');
lines.push('*(未找到帖子内容)*');
} else {
posts.forEach((post) => {
lines.push('');
lines.push(`## #${post.number} ${post.author}`);
lines.push('');
if (post.date) lines.push(`> ${post.date}`);
lines.push('');
lines.push(post.content || '*(内容为空)*');
lines.push('');
lines.push('---');
});
}
}
return lines.join('\n');
}
// ============================================================
// 工具函数
// ============================================================
function downloadAsFile(text, filename) {
const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' });
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);
}
function safeFilename(title) {
return (title || 'discourse-post').replace(/[\\/:*?"<>|]/g, '_').substring(0, 80);
}
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = 'gm-save-toast';
toast.textContent = message;
toast.style.borderLeft = type === 'success' ? '4px solid #4caf50' : '4px solid #f44336';
document.body.appendChild(toast);
requestAnimationFrame(() => {
toast.style.opacity = '1';
toast.style.transform = 'translateY(0)';
});
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateY(-20px)';
setTimeout(() => toast.remove(), 300);
}, 2500);
}
function escapeHtml(str) {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function escapeAttr(str) {
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
}
// ============================================================
// UI 样式
// ============================================================
function injectStyles() {
GM_addStyle(`
/* 悬浮按钮 */
#gm-save-btn {
position: fixed;
bottom: 30px;
right: 30px;
z-index: 99999;
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #1976d2, #1565c0);
color: #fff;
border: none;
cursor: pointer;
box-shadow: 0 4px 14px rgba(25, 118, 210, 0.4);
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
opacity: 0.8;
}
#gm-save-btn:hover {
opacity: 1;
transform: scale(1.1);
box-shadow: 0 6px 22px rgba(25, 118, 210, 0.55);
}
/* 弹窗遮罩 */
#gm-save-overlay {
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
#gm-save-overlay.show { opacity: 1; }
/* 弹窗主体 */
#gm-save-modal {
background: #fff;
border-radius: 12px;
width: 720px;
max-width: 92vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 80px rgba(0,0,0,0.25);
transform: scale(0.92);
transition: transform 0.2s ease;
}
#gm-save-overlay.show #gm-save-modal { transform: scale(1); }
/* 头部 */
.gm-modal-header {
padding: 16px 20px;
border-bottom: 1px solid #e8e8e8;
display: flex;
justify-content: space-between;
align-items: center;
}
.gm-modal-header h3 { margin: 0; font-size: 16px; color: #222; }
.gm-modal-close {
background: none; border: none; font-size: 22px;
cursor: pointer; color: #999; padding: 0 4px;
}
.gm-modal-close:hover { color: #333; }
/* 选项区 */
.gm-options {
padding: 12px 20px;
background: #f8f9fa;
border-bottom: 1px solid #e8e8e8;
display: flex;
gap: 20px;
align-items: center;
font-size: 14px;
color: #555;
}
.gm-options label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
font-weight: normal !important;
margin: 0 !important;
}
.gm-options input[type="radio"] { margin: 0; cursor: pointer; }
/* 预览区 */
.gm-modal-body {
padding: 16px 20px;
overflow-y: auto;
flex: 1;
}
.gm-preview {
width: 100%;
box-sizing: border-box;
border: 1px solid #ddd;
border-radius: 8px;
padding: 12px 16px;
font-size: 13px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
line-height: 1.6;
resize: vertical;
min-height: 300px;
outline: none;
background: #fafafa;
white-space: pre-wrap;
word-break: break-word;
}
.gm-preview:focus { border-color: #1976d2; background: #fff; }
/* 底部按钮 */
.gm-modal-footer {
padding: 12px 20px;
border-top: 1px solid #e8e8e8;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.gm-btn {
padding: 8px 20px; border-radius: 6px; border: none;
cursor: pointer; font-size: 14px; font-weight: 500;
transition: background 0.2s, box-shadow 0.2s;
}
.gm-btn-primary { background: #1976d2; color: #fff; }
.gm-btn-primary:hover { background: #1565c0; box-shadow: 0 2px 8px rgba(25,118,210,0.3); }
.gm-btn-success { background: #43a047; color: #fff; }
.gm-btn-success:hover { background: #388e3c; box-shadow: 0 2px 8px rgba(67,160,71,0.3); }
.gm-btn-secondary { background: #e0e0e0; color: #333; }
.gm-btn-secondary:hover { background: #c0c0c0; }
/* 帖子数统计 */
.gm-post-count {
font-size: 12px; color: #999; margin-left: auto;
}
/* Toast */
.gm-save-toast {
position: fixed; top: 20px; right: 20px; z-index: 100001;
background: #fff; padding: 12px 20px; border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
font-size: 14px; color: #333;
opacity: 0; transform: translateY(-20px);
transition: all 0.3s ease;
}
`);
}
// ============================================================
// 悬浮按钮
// ============================================================
function createFloatingButton() {
const btn = document.createElement('button');
btn.id = 'gm-save-btn';
// 使用自定义 Logo SVG 图标
btn.innerHTML = `<svg viewBox="0 0 200 200" width="28" height="28" style="display:block">
<defs>
<linearGradient id="gm-logo-grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1976d2"/>
<stop offset="100%" style="stop-color:#1565c0"/>
</linearGradient>
<linearGradient id="gm-logo-grad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#43a047"/>
<stop offset="100%" style="stop-color:#388e3c"/>
</linearGradient>
</defs>
<circle cx="100" cy="100" r="90" fill="url(#gm-logo-grad1)"/>
<g transform="translate(100, 100)">
<rect x="-45" y="-55" width="90" height="110" rx="8" ry="8" fill="#ffffff" opacity="0.95"/>
<path d="M 45,-55 L 45,-35 L 25,-35 Z" fill="#e3f2fd"/>
<path d="M 25,-55 L 45,-55 L 45,-35 L 25,-35 Z" fill="#bbdefb" opacity="0.6"/>
<g transform="translate(0, -20)">
<text x="0" y="0" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="url(#gm-logo-grad1)" text-anchor="middle" dominant-baseline="middle">M</text>
<path d="M -8,20 L 0,32 L 8,20" stroke="url(#gm-logo-grad2)" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="0" y1="12" x2="0" y2="32" stroke="url(#gm-logo-grad2)" stroke-width="3" stroke-linecap="round"/>
</g>
<g opacity="0.4">
<rect x="-30" y="25" width="60" height="3" rx="1.5" fill="#1976d2"/>
<rect x="-30" y="33" width="45" height="3" rx="1.5" fill="#1976d2"/>
<rect x="-30" y="41" width="50" height="3" rx="1.5" fill="#1976d2"/>
</g>
</g>
<g transform="translate(145, 55)">
<circle cx="0" cy="0" r="18" fill="url(#gm-logo-grad2)" opacity="0.9"/>
<path d="M -8,-5 L 8,-5 M -8,0 L 8,0 M -8,5 L 4,5" stroke="#ffffff" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
</g>
</svg>`;
btn.title = '保存 Discourse 帖子';
document.body.appendChild(btn);
btn.addEventListener('click', () => openModal());
}
// ============================================================
// 弹窗
// ============================================================
async function openModal() {
const old = document.getElementById('gm-save-overlay');
if (old) old.remove();
const totalPosts = document.querySelectorAll(SEL.postContainer).length;
const initialText = await formatOutput('first');
const overlay = document.createElement('div');
overlay.id = 'gm-save-overlay';
overlay.innerHTML = `
<div id="gm-save-modal">
<div class="gm-modal-header">
<h3>保存帖子内容</h3>
<button class="gm-modal-close" id="gm-modal-close">×</button>
</div>
<div class="gm-options">
<span>范围:</span>
<label><input type="radio" name="gm-scope" value="first" checked /> 仅主楼</label>
<label><input type="radio" name="gm-scope" value="all" /> 全部楼层</label>
<span class="gm-post-count">当前页共 ${totalPosts} 楼</span>
</div>
<div class="gm-modal-body">
<textarea class="gm-preview" id="gm-preview">${escapeHtml(initialText)}</textarea>
</div>
<div class="gm-modal-footer">
<button class="gm-btn gm-btn-secondary" id="gm-btn-cancel">取消</button>
<button class="gm-btn gm-btn-primary" id="gm-btn-copy">复制到剪贴板</button>
<button class="gm-btn gm-btn-success" id="gm-btn-download">下载 Markdown</button>
</div>
</div>
`;
document.body.appendChild(overlay);
requestAnimationFrame(() => overlay.classList.add('show'));
// --- 事件绑定 ---
const closeModal = () => {
overlay.classList.remove('show');
setTimeout(() => overlay.remove(), 200);
};
overlay.querySelector('#gm-modal-close').onclick = closeModal;
overlay.querySelector('#gm-btn-cancel').onclick = closeModal;
overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', escHandler); }
});
// 范围切换
overlay.querySelectorAll('input[name="gm-scope"]').forEach(radio => {
radio.addEventListener('change', async () => {
const mode = overlay.querySelector('input[name="gm-scope"]:checked').value;
document.getElementById('gm-preview').value = await formatOutput(mode);
});
});
// 复制
overlay.querySelector('#gm-btn-copy').onclick = () => {
const text = document.getElementById('gm-preview').value;
GM_setClipboard(text, 'text');
showToast('已复制到剪贴板');
closeModal();
};
// 下载:从预览区内容中提取第一行的标题作为文件名
overlay.querySelector('#gm-btn-download').onclick = async () => {
const text = document.getElementById('gm-preview').value;
const title = await getTitleAsync();
downloadAsFile(text, `${safeFilename(title)}.md`);
showToast('Markdown 文件已下载');
closeModal();
};
}
// ============================================================
// 油猴菜单命令
// ============================================================
function registerMenuCommands() {
GM_registerMenuCommand('保存帖子(弹窗)', () => openModal());
GM_registerMenuCommand('快速复制 - 仅主楼', async () => {
const text = await formatOutput('first');
GM_setClipboard(text, 'text');
showToast('主楼内容已复制');
});
GM_registerMenuCommand('快速复制 - 全部楼层', async () => {
const text = await formatOutput('all');
GM_setClipboard(text, 'text');
showToast('全部楼层已复制');
});
GM_registerMenuCommand('快速下载 - 仅主楼', async () => {
const text = await formatOutput('first');
const title = await getTitleAsync();
downloadAsFile(text, `${safeFilename(title)}.md`);
showToast('Markdown 文件已下载');
});
GM_registerMenuCommand('快速下载 - 全部楼层', async () => {
const text = await formatOutput('all');
const title = await getTitleAsync();
downloadAsFile(text, `${safeFilename(title)}.md`);
showToast('Markdown 文件已下载');
});
}
})();
网友解答:
--【壹】--:
image1909×989 110 KB
--【贰】--:
cool!
--【叁】--:
有链接吗
--【肆】--:
感谢大佬。
--【伍】--:
忘了放脚本了不好意思
--【陆】--:
不错,虽然我一般用singlefile直接下载原html,但这个占的空间更小
--【柒】--:
image1920×1029 113 KB

