Discourse 论坛系统帖子保存器

2026-04-11 11:391阅读0评论SEO问题
  • 内容介绍
  • 文章标签
  • 相关推荐
问题描述:

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}](${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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } function escapeAttr(str) { return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } // ============================================================ // 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">&times;</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}](${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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } function escapeAttr(str) { return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); } // ============================================================ // 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">&times;</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

标签:纯水