// ==UserScript==
// @name Discourse Saver (油猴版)
// @namespace https://github.com/discourse-saver
// @version 4.6.11
// @description 通用Discourse论坛内容保存工具 - 支持Obsidian/Notion/HTML,评论、用户名超链接、折叠模式
// @author 阿成
// @icon https://www.google.com/s2/favicons?sz=64&domain=obsidian.md
// @match https://linux.do/*
// @match https://meta.discourse.org/*
// @match https://users.rust-lang.org/*
// @match https://forums.docker.com/*
// @match https://community.openai.com/*
// @match https://discuss.python.org/*
// @match https://*.discourse.group/*
// @match https://forum.obsidian.md/*
// @match https://community.cloudflare.com/*
// @match https://forum.cursor.com/*
// @match https://community.render.com/*
// @match https://community.fly.io/*
// @match https://discourse.haskell.org/*
// @match https://discourse.julialang.org/*
// @match https://forum.rclone.org/*
// @match https://discourse.nixos.org/*
// @match https://discuss.kotlinlang.org/*
// @match https://forum.gitlab.com/*
// @match https://discuss.elastic.co/*
// @match https://discuss.hashicorp.com/*
// @match https://community.grafana.com/*
// @match https://discuss.codecademy.com/*
// @match https://community.letsencrypt.org/*
// @match https://discuss.atom.io/*
// @match https://forum.proxmox.com/*
// @match https://discuss.rubyonrails.org/*
// @match https://community.home-assistant.io/*
// @match https://forum.unity.com/*
// @match https://forums.unrealengine.com/*
// @match https://discourse.llvm.org/*
// @match https://discuss.ocaml.org/*
// @match https://elixirforum.com/*
// @match https://discuss.flarum.org/*
// @match https://community.paperspace.com/*
// @match https://forum.seafile.com/*
// @match https://forum.syncthing.net/*
// @match https://community.hivemq.com/*
// @match https://forum.owncloud.com/*
// @match https://community.bitwarden.com/*
// @match https://discuss.emberjs.com/*
// @include *://*discourse*/*
// @include *://*forum*/*
// @include *://*discuss*/*
// @include *://*community*/*
// @require https://cdn.jsdelivr.net/npm/turndown@7.1.2/dist/turndown.js
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_notification
// @grant GM_setClipboard
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-end
// @license MIT
// @downloadURL https://cdn.jsdelivr.net/gh/AchengBusiness/discourse-saver@main/discourse-saver.user.js
// @updateURL https://cdn.jsdelivr.net/gh/AchengBusiness/discourse-saver@main/discourse-saver.user.js
// ==/UserScript==
(function() {
'use strict';
// ============================================================
// 模块1: 配置管理 (ConfigModule)
// ============================================================
const ConfigModule = (function() {
const DEFAULT_CONFIG = {
// 保存目标
saveToObsidian: true,
saveToNotion: false,
exportHtml: false,
// Obsidian 设置
vaultName: '',
folderPath: 'Discourse收集箱',
useAdvancedUri: true,
// Notion 设置
notionToken: '',
notionDatabaseId: '',
notionPropTitle: '标题',
notionPropUrl: '链接',
notionPropAuthor: '作者',
notionPropCategory: '分类',
notionPropTags: '标签',
notionPropSavedDate: '保存日期',
notionPropCommentCount: '评论数',
// HTML 导出设置
htmlExportFolder: 'Discourse导出',
// 内容设置
addMetadata: true,
includeImages: true,
embedImages: false, // 将图片嵌入为 Base64(解决手机端图片无法显示问题)
// 评论设置
saveComments: false,
commentCount: 100,
saveAllComments: false, // 与 useFloorRange 互斥
foldComments: false,
// 楼层范围(与 saveAllComments 互斥)
useFloorRange: false,
floorFrom: 1,
floorTo: 100,
// 自定义站点(逗号分隔的域名列表,用于检测不到的自建 Discourse)
customSites: ''
};
function get(key) {
if (key) {
return GM_getValue(key, DEFAULT_CONFIG[key]);
}
// 获取全部配置
const config = {};
for (const k in DEFAULT_CONFIG) {
config[k] = GM_getValue(k, DEFAULT_CONFIG[k]);
}
return config;
}
function set(key, value) {
GM_setValue(key, value);
}
function setAll(config) {
for (const k in config) {
GM_setValue(k, config[k]);
}
}
function getDefault() {
return { ...DEFAULT_CONFIG };
}
return { get, set, setAll, getDefault };
})();
// ============================================================
// 模块2: 工具函数 (UtilModule)
// ============================================================
const UtilModule = (function() {
// 获取北京时间
function getBeijingTime() {
const now = new Date();
const utc = now.getTime() + now.getTimezoneOffset() * 60000;
const beijing = new Date(utc + 8 * 3600000);
const year = beijing.getFullYear();
const month = String(beijing.getMonth() + 1).padStart(2, '0');
const day = String(beijing.getDate()).padStart(2, '0');
const hours = String(beijing.getHours()).padStart(2, '0');
const minutes = String(beijing.getMinutes()).padStart(2, '0');
const seconds = String(beijing.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
// 清理文件名
function sanitizeFileName(name) {
return name
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, ' ')
.trim()
.substring(0, 100);
}
// 显示通知
function showNotification(message, type = 'info') {
// 移除旧通知
const old = document.querySelector('.ds-notification');
if (old) old.remove();
const colors = {
success: '#10b981',
error: '#ef4444',
info: '#3b82f6',
warning: '#f59e0b'
};
const div = document.createElement('div');
div.className = 'ds-notification';
div.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
background: ${colors[type] || colors.info};
color: white;
border-radius: 8px;
font-size: 14px;
z-index: 999999;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
animation: dsSlideIn 0.3s ease;
`;
div.textContent = message;
document.body.appendChild(div);
setTimeout(() => div.remove(), 3000);
}
// 图片嵌入限制常量
const IMAGE_LIMITS = {
MAX_SINGLE_SIZE: 15 * 1024 * 1024, // 单张图片最大 15MB
MAX_TOTAL_SIZE: 100 * 1024 * 1024, // 总大小最大 100MB
MAX_IMAGE_COUNT: 50 // 最多嵌入 50 张图片
};
// 下载图片并转换为 Base64(带大小检测)
function fetchImageAsBase64(url, maxSize = IMAGE_LIMITS.MAX_SINGLE_SIZE) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
timeout: 30000,
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const blob = response.response;
// 检查图片大小
if (blob.size > maxSize) {
reject(new Error(`图片过大 (${(blob.size/1024/1024).toFixed(1)}MB > ${(maxSize/1024/1024).toFixed(0)}MB限制)`));
return;
}
const reader = new FileReader();
reader.onloadend = () => resolve({
data: reader.result,
size: blob.size
});
reader.onerror = () => reject(new Error('Failed to read blob'));
reader.readAsDataURL(blob);
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: function() {
reject(new Error('Network error'));
},
ontimeout: function() {
reject(new Error('Timeout'));
}
});
});
}
// 批量将 Markdown 中的图片 URL 替换为 Base64
async function embedImagesInMarkdown(markdown, onProgress = null) {
// 参数验证
if (!markdown || typeof markdown !== 'string') {
console.warn('[Discourse Saver] embedImagesInMarkdown: 无效的 markdown 参数');
return markdown || '';
}
// 匹配 Markdown 图片语法: 
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let matches;
try {
matches = [...markdown.matchAll(imageRegex)];
} catch (regexError) {
console.error('[Discourse Saver] 正则匹配失败:', regexError);
return markdown;
}
if (matches.length === 0) {
return markdown;
}
console.log(`[Discourse Saver] 发现 ${matches.length} 张图片`);
// 收集所有图片 URL(去重)
const urlList = [];
const seenUrls = new Set();
for (const match of matches) {
const url = match[2];
// 跳过已经是 base64 的图片和无效 URL
if (url && !url.startsWith('data:') && url.startsWith('http') && !seenUrls.has(url)) {
seenUrls.add(url);
urlList.push(url);
}
}
if (urlList.length === 0) {
console.log('[Discourse Saver] 没有需要嵌入的图片');
return markdown;
}
// 应用图片数量限制
const effectiveUrls = urlList.slice(0, IMAGE_LIMITS.MAX_IMAGE_COUNT);
if (urlList.length > IMAGE_LIMITS.MAX_IMAGE_COUNT) {
console.warn(`[Discourse Saver] 图片数量 (${urlList.length}) 超过限制 (${IMAGE_LIMITS.MAX_IMAGE_COUNT}),只嵌入前 ${IMAGE_LIMITS.MAX_IMAGE_COUNT} 张`);
showNotification(`图片过多,只嵌入前 ${IMAGE_LIMITS.MAX_IMAGE_COUNT} 张`, 'warning');
}
console.log(`[Discourse Saver] 开始嵌入 ${effectiveUrls.length} 张图片...`);
// 用于存储结果和追踪总大小
const urlMap = new Map();
let totalSize = 0;
let completed = 0;
let skippedCount = 0;
const total = effectiveUrls.length;
const OVERALL_TIMEOUT = 180000; // 3分钟总超时(增加到3分钟以支持更多图片)
try {
await Promise.race([
Promise.all(effectiveUrls.map(async (url) => {
try {
// 检查是否已超过总大小限制
if (totalSize >= IMAGE_LIMITS.MAX_TOTAL_SIZE) {
console.warn(`[Discourse Saver] 总大小已达限制,跳过: ${url.substring(0, 50)}...`);
urlMap.set(url, null);
skippedCount++;
completed++;
if (onProgress) onProgress(completed, total);
return;
}
const result = await fetchImageAsBase64(url);
const imageSize = result.size;
// 检查添加此图片后是否超过总大小限制
if (totalSize + imageSize > IMAGE_LIMITS.MAX_TOTAL_SIZE) {
console.warn(`[Discourse Saver] 添加此图片将超过总大小限制,跳过 (${(imageSize/1024/1024).toFixed(1)}MB)`);
urlMap.set(url, null);
skippedCount++;
} else {
urlMap.set(url, result.data);
totalSize += imageSize;
console.log(`[Discourse Saver] 图片嵌入 ${completed+1}/${total}: ${(imageSize/1024).toFixed(0)}KB, 总计: ${(totalSize/1024/1024).toFixed(1)}MB`);
}
completed++;
if (onProgress) onProgress(completed, total);
} catch (error) {
console.warn(`[Discourse Saver] 图片下载失败: ${url}`, error.message);
urlMap.set(url, null);
skippedCount++;
completed++;
if (onProgress) onProgress(completed, total);
}
})),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('图片嵌入总超时')), OVERALL_TIMEOUT)
)
]);
} catch (timeoutError) {
console.warn('[Discourse Saver] 图片嵌入超时,部分图片可能未嵌入:', timeoutError.message);
}
// 替换 Markdown 中的图片 URL
let result = markdown;
try {
for (const [url, base64] of urlMap.entries()) {
if (base64 && typeof base64 === 'string') {
result = result.split(`](${url})`).join(`](${base64})`);
}
}
} catch (replaceError) {
console.error('[Discourse Saver] 图片 URL 替换失败:', replaceError);
return markdown;
}
const successCount = [...urlMap.values()].filter(v => v !== null).length;
console.log(`[Discourse Saver] 图片嵌入完成: ${successCount}/${total} 成功, ${skippedCount} 跳过, 总大小: ${(totalSize/1024/1024).toFixed(1)}MB`);
if (skippedCount > 0) {
showNotification(`已嵌入 ${successCount} 张图片,${skippedCount} 张因过大或超限跳过`, 'info');
}
return result;
}
return { getBeijingTime, sanitizeFileName, showNotification, fetchImageAsBase64, embedImagesInMarkdown };
})();
// ============================================================
// 模块3: 内容提取 (ExtractModule)
// ============================================================
const ExtractModule = (function() {
// 检测是否是 Discourse 论坛 - v4.6.5 增强版(支持自定义站点)
function isDiscourseForumPage() {
// 首先检查自定义站点列表
const config = ConfigModule.get();
const customSites = (config.customSites || '').split(',').map(s => s.trim().toLowerCase()).filter(s => s);
const currentHost = window.location.hostname.toLowerCase();
if (customSites.some(site => currentHost.includes(site) || site.includes(currentHost))) {
console.log('[Discourse Saver] 匹配自定义站点:', currentHost);
return true;
}
// 多种检测方式
const checks = [
// 检查 Discourse 特有的 meta 标签
() => document.querySelector('meta[name="discourse_theme_id"]') !== null,
() => document.querySelector('meta[name="discourse_current_homepage"]') !== null,
() => document.querySelector('meta[name="generator"][content*="Discourse"]') !== null,
// 检查 Discourse 特有的 DOM 结构
() => document.querySelector('#ember-basic-dropdown-wormhole') !== null,
() => document.querySelector('.ember-application') !== null,
() => document.querySelector('#main-outlet') !== null,
() => document.querySelector('.post-stream') !== null,
() => document.querySelector('.topic-list') !== null,
() => document.querySelector('.d-header') !== null,
() => document.querySelector('.discourse-root') !== null,
// 检查 Discourse 特有的 CSS 类
() => document.body.classList.contains('discourse-touch') ||
document.body.classList.contains('docked') ||
document.body.classList.contains('logged-in') ||
document.body.classList.contains('navigation-topics') ||
document.body.classList.contains('categories-list') ||
document.body.classList.contains('archetype-regular'),
// 检查 HTML 标签
() => document.documentElement.classList.contains('discourse-no-hierarchical-menu') ||
document.documentElement.classList.contains('discourse-hierarchical-menu'),
// 检查 Discourse 特有的脚本
() => typeof window.Discourse !== 'undefined',
() => typeof window.Ember !== 'undefined',
// 检查 Discourse 特有的预加载数据
() => document.getElementById('data-preloaded') !== null,
// 检查 Discourse 特有的 API 端点(通过已加载的脚本)
() => {
const scripts = document.querySelectorAll('script[src*="discourse"]');
return scripts.length > 0;
},
// 检查页面上是否有 Discourse 特有的元素
() => document.querySelector('.category-breadcrumb') !== null,
() => document.querySelector('.topic-post') !== null,
() => document.querySelector('.crawler-post') !== null
];
const result = checks.some(check => {
try {
return check();
} catch (e) {
return false;
}
});
if (result) {
console.log('[Discourse Saver] 检测到 Discourse 论坛');
}
return result;
}
// 检查是否在帖子页面 - 增强版
function isTopicPage() {
// URL 模式检查
const urlPattern = /\/t\/[^/]+\/\d+/;
if (!urlPattern.test(window.location.pathname)) {
return false;
}
// 多种选择器检测
const titleSelectors = [
'#topic-title h1',
'.topic-title h1',
'#topic-title .fancy-title',
'.fancy-title',
'h1.topic-title',
'.topic-header h1',
'h1[itemprop="headline"]',
'article header h1'
];
for (const selector of titleSelectors) {
if (document.querySelector(selector)) {
return true;
}
}
// 如果有帖子内容区域也算
const contentSelectors = [
'.topic-body .cooked',
'.topic-post .cooked',
'.post-stream .cooked',
'article .cooked',
'[itemprop="articleBody"]'
];
for (const selector of contentSelectors) {
if (document.querySelector(selector)) {
return true;
}
}
return false;
}
// 提取主帖内容 - 增强版
function extractContent() {
// 多种标题选择器
const titleSelectors = [
'#topic-title h1',
'.topic-title h1',
'#topic-title .fancy-title',
'.fancy-title',
'h1.topic-title',
'.topic-header h1',
'h1[itemprop="headline"]',
'article header h1'
];
let titleElement = null;
for (const selector of titleSelectors) {
titleElement = document.querySelector(selector);
if (titleElement) break;
}
// 多种内容选择器
const contentSelectors = [
'.topic-body .cooked',
'.topic-post:first-of-type .cooked',
'.post-stream .topic-post:first-child .cooked',
'#post_1 .cooked',
'article:first-of-type .cooked',
'[itemprop="articleBody"]'
];
let contentElement = null;
for (const selector of contentSelectors) {
contentElement = document.querySelector(selector);
if (contentElement) break;
}
// 多种作者选择器
const authorSelectors = [
'.topic-meta-data .creator a',
'.names .first a',
'.topic-post:first-of-type .username a',
'.topic-avatar a[data-user-card]',
'[itemprop="author"] a',
'.first-post .username'
];
let authorElement = null;
for (const selector of authorSelectors) {
authorElement = document.querySelector(selector);
if (authorElement) break;
}
// 如果没有标题,尝试从页面标题提取
if (!titleElement) {
const pageTitle = document.title;
// 通常格式是 "帖子标题 - 论坛名称"
const titlePart = pageTitle.split(' - ')[0] || pageTitle;
if (titlePart && contentElement) {
// 创建一个虚拟元素来获取标题
titleElement = { textContent: titlePart };
}
}
if (!titleElement || !contentElement) {
console.log('[Discourse Saver] 无法找到标题或内容元素');
console.log('[Discourse Saver] titleElement:', titleElement);
console.log('[Discourse Saver] contentElement:', contentElement);
return null;
}
const title = titleElement.textContent.trim();
const contentHTML = contentElement.innerHTML;
const url = window.location.href;
const author = authorElement ? authorElement.textContent.trim() : '未知作者';
const topicId = window.location.pathname.match(/\/t\/[^/]+\/(\d+)/)?.[1];
// 提取分类 - v4.6.0 终极增强版(过滤图标)
let category = '';
// 辅助函数:从元素中提取纯文本(过滤SVG图标)
function extractTextWithoutIcons(element) {
if (!element) return '';
// 克隆元素以避免修改原始 DOM
const clone = element.cloneNode(true);
// 删除所有 SVG 和图标元素
clone.querySelectorAll('svg, .d-icon, .svg-icon, [class*="icon"], use').forEach(el => el.remove());
// 获取纯文本
return clone.textContent.trim();
}
// 方法0(最优先): Linux.do 专用 - 查找第一个分类徽章
console.log('[Discourse Saver] 开始提取分类...');
const badgeCategoryContainers = document.querySelectorAll('.badge-category-parent-box, .badge-category-bg, .badge-category, .topic-category .badge-wrapper');
for (const container of badgeCategoryContainers) {
// 优先查找专门的名称元素
const nameEl = container.querySelector('.badge-category__name, .badge-category-name, .category-name');
if (nameEl) {
const text = nameEl.textContent.trim();
if (text && text.length > 0 && text.length < 100) {
category = text;
console.log(`[Discourse Saver] 方法0找到分类: "${category}" (从badge名称元素)`);
break;
}
}
// 如果没有名称元素,提取过滤图标后的文本
if (!category) {
const text = extractTextWithoutIcons(container);
if (text && text.length > 0 && text.length < 100) {
category = text;
console.log(`[Discourse Saver] 方法0找到分类: "${category}" (从badge容器过滤图标后)`);
break;
}
}
}
// 方法1: DOM 选择器 - 覆盖各种可能的情况
if (!category) {
const categorySelectors = [
// Discourse 标准选择器
'.topic-category .badge-category__name',
'.badge-category-bg .badge-category__name',
'.category-name',
'.badge-wrapper .badge-category-name',
'[itemprop="articleSection"]',
// 更多变体
'.topic-category .badge-category-name',
'.topic-header-extra .badge-category__name',
'.topic-header-extra .badge-category-name',
'#topic-title .badge-category__name',
'#topic-title .badge-category-name',
'.title-wrapper .badge-category__name',
'.title-wrapper .badge-category-name',
// Linux.do 更多选择器
'.extra-info-wrapper .badge-category__name',
'.extra-info-wrapper .badge-category-name',
'.extra-info .badge-category__name',
'.extra-info .badge-category-name',
// 分类链接内的文本
'a[href*="/c/"] .badge-category__name',
'a[href*="/c/"] .badge-category-name',
'a[href*="/c/"] span.category-name'
];
for (const selector of categorySelectors) {
try {
const categoryBadge = document.querySelector(selector);
if (categoryBadge) {
const text = categoryBadge.textContent.trim();
if (text && text.length > 0 && text.length < 100) {
category = text;
console.log(`[Discourse Saver] 方法1找到分类: "${category}" (选择器: ${selector})`);
break;
}
}
} catch (e) {
// 忽略选择器错误
}
}
}
// 方法2: 查找 topic-category 容器并过滤图标
if (!category) {
const topicCategoryContainers = [
'.topic-category',
'.extra-info-wrapper .topic-category',
'#topic-title .topic-category',
'.title-wrapper .topic-category'
];
for (const selector of topicCategoryContainers) {
const container = document.querySelector(selector);
if (container) {
// 优先找第一个链接
const firstLink = container.querySelector('a[href*="/c/"]');
if (firstLink) {
const text = extractTextWithoutIcons(firstLink);
if (text && text.length > 0 && text.length < 100) {
category = text;
console.log(`[Discourse Saver] 方法2找到分类: "${category}" (从topic-category链接)`);
break;
}
}
// 如果没有链接,从容器提取
if (!category) {
const text = extractTextWithoutIcons(container);
if (text && text.length > 0 && text.length < 100) {
// 可能包含多个分类/标签,取第一个
const firstPart = text.split(/[\s,,、]+/)[0];
if (firstPart && firstPart.length > 0) {
category = firstPart;
console.log(`[Discourse Saver] 方法2找到分类: "${category}" (从topic-category容器)`);
break;
}
}
}
}
}
}
// 方法3: 查找所有指向分类的链接(过滤图标版)
if (!category) {
const categoryLinks = document.querySelectorAll('a[href*="/c/"]');
for (const link of categoryLinks) {
// 检查链接是否在标题区域
const isInTitleArea = link.closest('#topic-title') ||
link.closest('.topic-category') ||
link.closest('.extra-info') ||
link.closest('.title-wrapper') ||
link.closest('.topic-header');
if (isInTitleArea) {
const text = extractTextWithoutIcons(link);
if (text && text.length > 0 && text.length < 100) {
category = text;
console.log(`[Discourse Saver] 方法3找到分类: "${category}" (从分类链接过滤图标后)`);
break;
}
}
}
}
// 方法4: 从 Discourse 预加载数据提取
if (!category) {
try {
// 尝试从页面的 preloaded data 获取
const preloadedData = document.getElementById('data-preloaded');
if (preloadedData) {
const data = preloadedData.dataset.preloaded;
if (data) {
const parsed = JSON.parse(data);
// 查找 topic 数据
for (const key in parsed) {
if (key.includes('topic')) {
try {
const topicData = JSON.parse(parsed[key]);
if (topicData && topicData.category_id) {
// 从 categories 数据中查找名称
const categoriesKey = Object.keys(parsed).find(k => k.includes('categories'));
if (categoriesKey) {
const categoriesData = JSON.parse(parsed[categoriesKey]);
const cat = categoriesData?.category_list?.categories?.find(
c => c.id === topicData.category_id
);
if (cat && cat.name) {
category = cat.name;
console.log(`[Discourse Saver] 方法4找到分类: "${category}" (从preloaded data categories)`);
break;
}
}
}
// 直接从 topic 数据获取 category
if (!category && topicData && topicData.category) {
if (typeof topicData.category === 'string') {
category = topicData.category;
console.log(`[Discourse Saver] 方法4找到分类: "${category}" (从topic.category字符串)`);
} else if (topicData.category.name) {
category = topicData.category.name;
console.log(`[Discourse Saver] 方法4找到分类: "${category}" (从topic.category.name)`);
}
}
} catch (e) {
// 忽略解析错误
}
}
}
}
}
} catch (e) {
console.log('[Discourse Saver] 方法4解析preloaded data失败:', e.message);
}
}
// 方法5: 从 Discourse 全局对象提取(多种方式)
if (!category) {
try {
// 方式1: Discourse.Topic.current
if (typeof window.Discourse !== 'undefined') {
const topic = window.Discourse?.Topic?.current ||
window.Discourse?.__container__?.lookup('controller:topic')?.model ||
window.Discourse?.__container__?.lookup('route:topic')?.modelFor('topic');
if (topic && topic.category) {
category = topic.category.name || topic.category;
console.log(`[Discourse Saver] 方法5找到分类: "${category}" (从Discourse.Topic)`);
}
}
// 方式2: Ember 路由
if (!category && typeof window.Ember !== 'undefined') {
const appInstance = window.Ember?.Namespace?.NAMESPACES?.find(n => n.toString() === 'Discourse');
if (appInstance) {
const router = appInstance.__container__?.lookup('router:main');
const topicController = appInstance.__container__?.lookup('controller:topic');
if (topicController?.model?.category) {
category = topicController.model.category.name;
console.log(`[Discourse Saver] 方法5找到分类: "${category}" (从Ember controller)`);
}
}
}
// 方式3: 页面上的隐藏数据
if (!category) {
const topicData = document.querySelector('[data-topic-id]');
if (topicData && topicData.dataset.categoryId) {
// 通过 category ID 查找名称
const categoryId = parseInt(topicData.dataset.categoryId);
const categoryLink = document.querySelector(`a[href*="/c/"][data-category-id="${categoryId}"]`);
if (categoryLink) {
category = extractTextWithoutIcons(categoryLink);
console.log(`[Discourse Saver] 方法5找到分类: "${category}" (从data-category-id)`);
}
}
}
} catch (e) {
console.log('[Discourse Saver] 方法5访问Discourse对象失败:', e.message);
}
}
// 方法5.5: 从页面 script 标签中的 JSON 数据提取
if (!category) {
try {
const scripts = document.querySelectorAll('script[type="application/json"], script:not([src])');
for (const script of scripts) {
const content = script.textContent;
if (content && content.includes('category') && content.includes('name')) {
try {
// 尝试解析 JSON
const data = JSON.parse(content);
if (data.category?.name) {
category = data.category.name;
console.log(`[Discourse Saver] 方法5.5找到分类: "${category}" (从script JSON)`);
break;
}
} catch (e) {
// 尝试从文本中提取
const match = content.match(/"category":\s*\{[^}]*"name":\s*"([^"]+)"/);
if (match) {
category = match[1];
console.log(`[Discourse Saver] 方法5.5找到分类: "${category}" (从script文本匹配)`);
break;
}
}
}
}
} catch (e) {
console.log('[Discourse Saver] 方法5.5解析script失败:', e.message);
}
}
// 方法6: 从 URL 路径提取(最后的 fallback)
if (!category) {
// 标准 /c/category/subcategory 路径
const categoryMatch = window.location.pathname.match(/\/c\/([^/]+)/);
if (categoryMatch) {
category = decodeURIComponent(categoryMatch[1]).replace(/-/g, ' ');
console.log(`[Discourse Saver] 方法6找到分类: "${category}" (从URL /c/ 路径)`);
}
}
// 方法7: 遍历所有带有特定样式的元素(过滤图标版)
if (!category) {
// 查找带有背景色样式的 span(分类通常有颜色)
const allSpans = document.querySelectorAll('span[style*="background"], span[style*="color"]');
for (const span of allSpans) {
const isInTitleArea = span.closest('#topic-title') ||
span.closest('.topic-category') ||
span.closest('.extra-info');
if (isInTitleArea) {
const text = span.textContent.trim();
if (text && text.length > 0 && text.length < 50 && !/^[\s\u200b]*$/.test(text)) {
// 排除一些明显不是分类的文本
if (!/^\d+$/.test(text) && !text.includes('http') && text !== '×') {
category = text;
console.log(`[Discourse Saver] 方法7找到分类: "${category}" (从带样式的span)`);
break;
}
}
}
}
}
if (category) {
console.log(`[Discourse Saver] 最终分类: "${category}"`);
} else {
console.log('[Discourse Saver] 所有方法都未能提取到分类');
// 输出调试信息
console.log('[Discourse Saver] 调试 - topic-category 元素:', document.querySelector('.topic-category'));
console.log('[Discourse Saver] 调试 - 分类链接:', document.querySelectorAll('a[href*="/c/"]'));
}
// 提取标签 - v4.5.10 超级增强版
const tags = [];
const tagSelectors = [
// Discourse 标准选择器
'.discourse-tags .discourse-tag',
'.list-tags .discourse-tag',
'.topic-header-extra .discourse-tag',
'.tag-drop .discourse-tag',
// 更多变体
'.topic-tags .discourse-tag',
'.tags-wrapper .discourse-tag',
'a.discourse-tag',
'.tag-list .tag',
'.topic-map .tag',
// Linux.do 特殊选择器
'.extra-info-wrapper .discourse-tag',
'.extra-info .discourse-tag',
'#topic-title .discourse-tag',
'.title-wrapper .discourse-tag',
// 链接形式的标签
'a[href*="/tag/"]',
'a[href*="/tags/"]',
// 带 data 属性的标签
'[data-tag-name]',
'.tag-badge'
];
for (const selector of tagSelectors) {
try {
const tagElements = document.querySelectorAll(selector);
tagElements.forEach(tag => {
let tagText = tag.textContent.trim();
// 如果有 data-tag-name 属性,优先使用
if (tag.dataset && tag.dataset.tagName) {
tagText = tag.dataset.tagName;
}
// 从 href 提取标签名
if (!tagText && tag.href) {
const tagMatch = tag.href.match(/\/tags?\/([^/?]+)/);
if (tagMatch) {
tagText = decodeURIComponent(tagMatch[1]);
}
}
if (tagText && !tags.includes(tagText) && tagText.length < 50) {
// 过滤一些明显不是标签的内容
if (!/^[\d\s]+$/.test(tagText) && !tagText.includes('http')) {
tags.push(tagText);
}
}
});
} catch (e) {
// 忽略选择器错误
}
}
if (tags.length > 0) {
console.log(`[Discourse Saver] 找到 ${tags.length} 个标签:`, tags);
} else {
console.log('[Discourse Saver] 未找到标签');
}
return { title, contentHTML, url, author, topicId, category, tags };
}
// 提取评论(DOM方式)- v4.5.10 增强版
function extractComments(maxCount = 100) {
const comments = [];
const baseUrl = window.location.origin;
// 多种评论容器选择器
const containerSelectors = [
'div.crawler-post',
'.topic-post',
'.post-stream .post',
'article.post',
'[itemtype*="Comment"]',
'.reply'
];
let commentElements = [];
for (const selector of containerSelectors) {
commentElements = document.querySelectorAll(selector);
if (commentElements.length > 0) {
console.log(`[Discourse Saver] 使用选择器 "${selector}" 找到 ${commentElements.length} 个评论元素`);
break;
}
}
const commentNodes = Array.from(commentElements).slice(1, maxCount + 1);
for (const el of commentNodes) {
// 增强的用户名选择器
const usernameSelectors = [
'.creator span[itemprop="name"]',
'.names .first a',
'.username a',
'.author-name',
'[itemprop="author"] [itemprop="name"]',
'.post-user a',
'.user-info .name'
];
let usernameEl = null;
for (const selector of usernameSelectors) {
usernameEl = el.querySelector(selector);
if (usernameEl) break;
}
const username = usernameEl ? usernameEl.textContent.trim() : '匿名用户';
// 提取用户主页链接
let userUrl = '';
const userLinkSelectors = [
'.creator a[href*="/u/"]',
'.names .first a[href*="/u/"]',
'.username a[href*="/u/"]',
'a[data-user-card]',
'.author-name a',
'.post-user a[href*="/u/"]'
];
for (const selector of userLinkSelectors) {
const userLinkEl = el.querySelector(selector);
if (userLinkEl) {
userUrl = userLinkEl.href;
break;
}
}
if (!userUrl && username && username !== '匿名用户') {
userUrl = `${baseUrl}/u/${username}`;
}
// 增强的内容选择器
const contentSelectors = [
'.post[itemprop="text"]',
'.cooked',
'.post-content',
'.post-body',
'[itemprop="text"]',
'.content'
];
let contentEl = null;
for (const selector of contentSelectors) {
contentEl = el.querySelector(selector);
if (contentEl) break;
}
const contentHTML = contentEl ? contentEl.innerHTML : '';
// 增强的楼层选择器
const positionSelectors = [
'span[itemprop="position"]',
'.post-number',
'.post-count',
'[data-post-number]'
];
let positionEl = null;
for (const selector of positionSelectors) {
positionEl = el.querySelector(selector);
if (positionEl) break;
}
let position = (comments.length + 2).toString();
if (positionEl) {
position = positionEl.textContent.trim() || positionEl.dataset?.postNumber || position;
}
// 尝试从元素属性获取
if (el.dataset && el.dataset.postNumber) {
position = el.dataset.postNumber;
}
// 增强的时间选择器
const timeSelectors = [
'time.post-time',
'.relative-date',
'time[datetime]',
'.post-date',
'[itemprop="datePublished"]'
];
let timeEl = null;
for (const selector of timeSelectors) {
timeEl = el.querySelector(selector);
if (timeEl) break;
}
const time = timeEl ? (timeEl.getAttribute('datetime') || timeEl.textContent) : '';
// 增强的点赞选择器
const likesSelectors = [
'meta[itemprop="userInteractionCount"]',
'.post-likes',
'.like-count',
'.likes',
'[data-likes]'
];
let likesEl = null;
for (const selector of likesSelectors) {
likesEl = el.querySelector(selector);
if (likesEl) break;
}
let likes = '0';
if (likesEl) {
likes = likesEl.getAttribute('content') ||
likesEl.dataset?.likes ||
likesEl.textContent.replace(/[^\d]/g, '') ||
'0';
}
if (contentHTML) {
comments.push({
username,
userUrl,
contentHTML,
position,
time,
likes
});
}
}
console.log(`[Discourse Saver] 提取到 ${comments.length} 条评论`);
return comments;
}
// 使用API获取评论
async function extractCommentsViaAPI(topicId, maxCount, saveAll = false, progressCallback = null) {
const comments = [];
const baseUrl = window.location.origin;
try {
if (progressCallback) progressCallback('正在获取帖子信息...');
const topicUrl = `${baseUrl}/t/${topicId}.json`;
const topicResponse = await fetch(topicUrl, { credentials: 'include' });
if (!topicResponse.ok) {
throw new Error(`获取帖子信息失败: ${topicResponse.status}`);
}
const topicData = await topicResponse.json();
const stream = topicData.post_stream?.stream || [];
const totalPosts = stream.length;
if (totalPosts === 0) {
return comments;
}
const commentIds = stream.slice(1);
const targetCount = saveAll ? commentIds.length : Math.min(maxCount, commentIds.length);
const idsToFetch = commentIds.slice(0, targetCount);
// 分批获取
const batchSize = 20;
for (let i = 0; i < idsToFetch.length; i += batchSize) {
const batch = idsToFetch.slice(i, i + batchSize);
const params = batch.map(id => `post_ids[]=${id}`).join('&');
const postsUrl = `${baseUrl}/t/${topicId}/posts.json?${params}`;
if (progressCallback) {
const progress = Math.min(i + batchSize, idsToFetch.length);
progressCallback(`正在加载评论 ${progress}/${targetCount}...`);
}
const postsResponse = await fetch(postsUrl, { credentials: 'include' });
if (!postsResponse.ok) continue;
const postsData = await postsResponse.json();
const posts = postsData.post_stream?.posts || [];
for (const post of posts) {
if (post.post_number === 1) continue;
const postUsername = post.username || post.display_username || '匿名用户';
const userUrl = postUsername !== '匿名用户' ? `${baseUrl}/u/${postUsername}` : '';
comments.push({
username: postUsername,
userUrl,
contentHTML: post.cooked || '',
position: String(post.post_number),
time: post.created_at || '',
likes: String(post.like_count || 0)
});
}
if (i + batchSize < idsToFetch.length) {
await new Promise(r => setTimeout(r, 100));
}
}
comments.sort((a, b) => parseInt(a.position) - parseInt(b.position));
return comments;
} catch (error) {
console.error('[Discourse Saver] API获取评论失败:', error);
throw error;
}
}
// 提取单条评论
function extractSingleComment(postNumber) {
const commentElements = document.querySelectorAll('.topic-post, article[data-post-id]');
const baseUrl = window.location.origin;
for (const el of commentElements) {
const posNum = el.getAttribute('data-post-number') ||
el.querySelector('[data-post-number]')?.getAttribute('data-post-number');
if (posNum === postNumber) {
const usernameEl = el.querySelector('.creator span[itemprop="name"]') ||
el.querySelector('.names .first a') ||
el.querySelector('.username a');
const username = usernameEl ? usernameEl.textContent.trim() : '匿名用户';
let userUrl = '';
const userLinkEl = el.querySelector('.creator a[href*="/u/"]') ||
el.querySelector('.names .first a[href*="/u/"]') ||
el.querySelector('.username a[href*="/u/"]') ||
el.querySelector('a[data-user-card]');
if (userLinkEl) {
userUrl = userLinkEl.href;
} else if (username && username !== '匿名用户') {
userUrl = `${baseUrl}/u/${username}`;
}
const contentEl = el.querySelector('.post[itemprop="text"]') ||
el.querySelector('.cooked');
const contentHTML = contentEl ? contentEl.innerHTML : '';
const timeEl = el.querySelector('time.post-time') ||
el.querySelector('.relative-date');
const time = timeEl ? (timeEl.getAttribute('datetime') || timeEl.textContent) : '';
const likesEl = el.querySelector('meta[itemprop="userInteractionCount"]') ||
el.querySelector('.post-likes');
const likes = likesEl ?
(likesEl.getAttribute('content') || likesEl.textContent.replace(/[^\d]/g, '')) : '0';
return {
username,
userUrl,
contentHTML,
position: postNumber,
time,
likes
};
}
}
return null;
}
return {
isDiscourseForumPage,
isTopicPage,
extractContent,
extractComments,
extractCommentsViaAPI,
extractSingleComment
};
})();
// ============================================================
// 模块4: Markdown转换 (ConvertModule)
// ============================================================
const ConvertModule = (function() {
let turndownService = null;
// 解析视频URL
function parseVideoUrl(href) {
// YouTube
const ytMatch = href.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/);
if (ytMatch) {
return { embedUrl: 'https://www.youtube.com/embed/' + ytMatch[1], isVideo: true, platform: 'youtube' };
}
// Bilibili(包含 b23.tv 短链 - 返回原链接让用户点击跳转)
const biliMatch = href.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+|av\d+)/i);
if (biliMatch) {
const vid = biliMatch[1];
if (vid.toLowerCase().startsWith('bv')) {
return { embedUrl: '//player.bilibili.com/player.html?bvid=' + vid, isVideo: true, platform: 'bilibili' };
} else {
return { embedUrl: '//player.bilibili.com/player.html?aid=' + vid.replace(/^av/i, ''), isVideo: true, platform: 'bilibili' };
}
}
// b23.tv 短链(Bilibili)- 无法直接解析,作为视频链接显示
if (/b23\.tv/i.test(href)) {
return { embedUrl: '', isVideo: true, platform: 'bilibili-short', originalUrl: href };
}
// Vimeo
const vimeoMatch = href.match(/vimeo\.com\/(\d+)/);
if (vimeoMatch) {
return { embedUrl: 'https://player.vimeo.com/video/' + vimeoMatch[1], isVideo: true, platform: 'vimeo' };
}
// 优酷
const youkuMatch = href.match(/v\.youku\.com\/v_show\/id_([a-zA-Z0-9=]+)/);
if (youkuMatch) {
return { embedUrl: 'https://player.youku.com/embed/' + youkuMatch[1], isVideo: true, platform: 'youku' };
}
// 腾讯视频
const qqMatch = href.match(/v\.qq\.com\/x\/(?:cover\/[^\/]+\/|page\/|play\/)([a-zA-Z0-9]+)/);
if (qqMatch) {
return { embedUrl: 'https://v.qq.com/txp/iframe/player.html?vid=' + qqMatch[1], isVideo: true, platform: 'qq' };
}
return { embedUrl: '', isVideo: false, platform: '' };
}
// 解析网盘链接
function parseCloudUrl(href) {
// 百度网盘
if (/pan\.baidu\.com|yun\.baidu\.com/i.test(href)) {
return { isCloud: true, platform: 'baidu', name: '百度网盘', icon: '📦' };
}
// 夸克网盘
if (/pan\.quark\.cn/i.test(href)) {
return { isCloud: true, platform: 'quark', name: '夸克网盘', icon: '📦' };
}
// 123云盘
if (/123pan\.com|123云盘/i.test(href)) {
return { isCloud: true, platform: '123pan', name: '123云盘', icon: '📦' };
}
// 蓝奏云
if (/lanzou[a-z]*\.(com|cn)|lanzoui\.com|lanzoux\.com/i.test(href)) {
return { isCloud: true, platform: 'lanzou', name: '蓝奏云', icon: '📦' };
}
// 阿里云盘
if (/aliyundrive\.com|alipan\.com/i.test(href)) {
return { isCloud: true, platform: 'aliyun', name: '阿里云盘', icon: '📦' };
}
// 天翼云盘
if (/cloud\.189\.cn/i.test(href)) {
return { isCloud: true, platform: 'tianyi', name: '天翼云盘', icon: '📦' };
}
return { isCloud: false, platform: '', name: '', icon: '' };
}
// 生成视频嵌入
function generateVideoEmbed(videoInfo, originalUrl) {
if (videoInfo.embedUrl) {
return '\n\n
\n\n';
}
// 无法嵌入的视频链接(如 b23.tv 短链),使用视频图标显示
if (videoInfo.isVideo) {
return '\n\n> 🎬 **视频链接**: [点击观看](' + originalUrl + ')\n\n';
}
return '\n\n' + originalUrl + '\n\n';
}
// 生成网盘链接块
function generateCloudBlock(cloudInfo, originalUrl, linkText) {
return '\n\n> ' + cloudInfo.icon + ' **' + cloudInfo.name + '**: [' + (linkText || '点击下载') + '](' + originalUrl + ')\n\n';
}
// 初始化Turndown
function initTurndown() {
if (turndownService) return turndownService;
turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
emDelimiter: '*'
});
// 保留颜色样式
turndownService.addRule('coloredText', {
filter: (node) => {
if (node.nodeName !== 'SPAN') return false;
const style = node.getAttribute('style') || '';
return style.includes('color');
},
replacement: (content, node) => {
const style = node.getAttribute('style') || '';
const colorMatch = style.match(/color:\s*([^;]+)/i);
if (colorMatch && content.trim()) {
const color = colorMatch[1].trim();
return `${content}`;
}
return content;
}
});
// 处理代码块
turndownService.addRule('codeBlocks', {
filter: (node) => {
return node.nodeName === 'PRE' && node.querySelector('code');
},
replacement: (content, node) => {
const codeNode = node.querySelector('code');
if (!codeNode) return content;
const clonedCode = codeNode.cloneNode(true);
const brTags = clonedCode.querySelectorAll('br');
brTags.forEach(br => br.replaceWith('\n'));
const code = clonedCode.textContent;
const langFromClass = codeNode.className.match(/lang-(\w+)/);
const langFromData = node.getAttribute('data-code-wrap');
const lang = langFromClass ? langFromClass[1] : (langFromData || '');
return '\n\n```' + lang + '\n' + code + '\n```\n\n';
}
});
// 处理lightbox图片
turndownService.addRule('lightboxImages', {
filter: (node) => {
return node.nodeName === 'A' &&
node.classList.contains('lightbox') &&
node.querySelector('img');
},
replacement: (content, node) => {
const img = node.querySelector('img');
if (!img) return '';
const src = node.href || img.src;
const fullSrc = src.startsWith('http') ? src : window.location.origin + src;
const alt = (img.getAttribute('data-base62-sha1') ||
img.alt?.replace(/[_\d]+$/, '').trim() ||
'image').replace(/[\r\n]+/g, ' ').trim();
return '\n\n\n\n';
}
});
// 处理普通图片
turndownService.addRule('images', {
filter: (node) => {
if (node.nodeName !== 'IMG') return false;
if (node.classList.contains('emoji')) return false;
if (node.parentNode?.classList?.contains('lightbox')) return false;
return true;
},
replacement: (content, node) => {
const src = node.src;
if (!src) return '';
const fullSrc = src.startsWith('http') ? src : window.location.origin + src;
const alt = (node.alt?.replace(/[_\d]+$/, '').trim() || 'image').replace(/[\r\n]+/g, ' ').trim();
return '\n\n\n\n';
}
});
// 移除emoji
turndownService.addRule('emojiImages', {
filter: (node) => {
if (node.nodeName !== 'IMG') return false;
const className = node.className || '';
const src = node.src || '';
const alt = node.alt || '';
if (className.includes('emoji') ||
src.includes('/emoji/') ||
src.includes('twemoji') ||
/^:[^:]+:$/.test(alt)) return true;
return false;
},
replacement: () => ''
});
// 视频链接转iframe
turndownService.addRule('onlineVideoEmbed', {
filter: (node) => {
if (node.nodeName !== 'A') return false;
const href = node.href || '';
const videoInfo = parseVideoUrl(href);
return videoInfo.isVideo;
},
replacement: (content, node) => {
const href = node.href || '';
const videoInfo = parseVideoUrl(href);
if (videoInfo.isVideo) {
return generateVideoEmbed(videoInfo, href);
}
return '[' + content + '](' + href + ')';
}
});
// 网盘链接特殊处理
turndownService.addRule('cloudStorageLink', {
filter: (node) => {
if (node.nodeName !== 'A') return false;
const href = node.href || '';
const cloudInfo = parseCloudUrl(href);
return cloudInfo.isCloud;
},
replacement: (content, node) => {
const href = node.href || '';
const cloudInfo = parseCloudUrl(href);
if (cloudInfo.isCloud) {
return generateCloudBlock(cloudInfo, href, content);
}
return '[' + content + '](' + href + ')';
}
});
// 文档链接
turndownService.addRule('documentEmbed', {
filter: (node) => {
if (node.nodeName !== 'A') return false;
if (node.classList?.contains('lightbox')) return false;
const href = (node.href || '').toLowerCase();
return /\.(pdf|docx?|xlsx?|pptx?|svg|csv|txt)(\?|$)/i.test(href);
},
replacement: (content, node) => {
const href = node.href || '';
const hrefLower = href.toLowerCase();
let fileName = content.trim().replace(/[\r\n]+/g, ' ').trim();
const imgMatch = fileName.match(/^!\[([^\]]*)\]\([^)]+\)$/);
if (imgMatch) {
fileName = imgMatch[1] || '';
}
fileName = fileName || href.split('/').pop().split('?')[0] || '文档';
if (/\.svg(\?|$)/i.test(hrefLower)) {
return '\n\n\n\n';
}
if (/\.pdf(\?|$)/i.test(hrefLower)) {
return '\n\n📄 **' + fileName + '**\n📥 [下载 PDF](' + href + ')\n\n';
}
return '\n\n📎 **' + fileName + '**\n📥 [下载文件](' + href + ')\n\n';
}
});
return turndownService;
}
// 清理Markdown
function cleanupMarkdown(markdown) {
// 移除空锚点链接
markdown = markdown.replace(/\[\s*\]\(#[^)]*\)/g, '');
// 移除emoji图片语法
markdown = markdown.replace(/!\[:[a-z_]+:\]\([^)]+\)/gi, '');
// 移除图片尺寸信息行
markdown = markdown.replace(/^\s*\d+×\d+\s+\d+(?:\.\d+)?\s*(?:KB|MB|GB)\s*$/gim, '');
// 移除转义下划线
markdown = markdown.replace(/\\_/g, '_');
// 清理嵌套图片链接
markdown = markdown.replace(/\[!\[([^\]]*)\]\([^)]+\)\]\(([^)]+)\)/g, '');
markdown = markdown.replace(/!\[!\[([^\]]*)\]\([^)]+\)\]\(([^)]+)\)/g, '');
markdown = markdown.replace(/!!\[/g, '![');
// 移除GIF
markdown = markdown.replace(/!\[[^\]]*\]\([^)]*\.gif[^)]*\)/gi, '');
// 移除多余空行
markdown = markdown.replace(/\n{3,}/g, '\n\n');
return markdown;
}
// 转换为带评论的Markdown
function convertToMarkdownWithComments(contentHTML, metadata, comments, config) {
const td = initTurndown();
let mainContent = td.turndown(contentHTML);
mainContent = cleanupMarkdown(mainContent);
let markdown = '';
// 添加元数据
if (config.addMetadata) {
const timeStr = UtilModule.getBeijingTime();
const allTags = ['discourse'];
if (metadata.tags && metadata.tags.length > 0) {
metadata.tags.forEach(tag => {
const cleanTag = String(tag).trim();
if (cleanTag && !allTags.includes(cleanTag)) {
allTags.push(cleanTag);
}
});
}
// 生成 YAML 列表格式的 tags
const tagsYaml = allTags
.map(t => t.replace(/[,\[\]#]/g, '').trim())
.filter(t => t)
.map(t => ` - ${t}`)
.join('\n');
markdown += `---
来源: ${metadata.url}
标题: "${metadata.title.replace(/"/g, '\\"')}"
作者: ${metadata.author}
分类: ${metadata.category || '未分类'}
tags:
${tagsYaml}
保存时间: ${timeStr}
评论数: ${comments.length}
---
`;
}
// 添加标题和正文
markdown += `# ${metadata.title}\n\n`;
markdown += mainContent;
// 添加评论区
if (config.saveComments && comments.length > 0) {
markdown += '\n\n---\n\n';
markdown += `## 评论区(共${comments.length}条)\n\n`;
for (const comment of comments) {
let commentContent = td.turndown(comment.contentHTML);
commentContent = cleanupMarkdown(commentContent);
commentContent = commentContent.trim();
// 用户名超链接
const usernameDisplay = comment.userUrl
? `[${comment.username}](${comment.userUrl})`
: comment.username;
const usernameDisplayHtml = comment.userUrl
? `${comment.username}`
: `${comment.username}`;
if (config.foldComments) {
// 折叠模式:转换Markdown为HTML
let htmlContent = commentContent.trim();
htmlContent = htmlContent.replace(/\*\*(.+?)\*\*/g, '$1');
htmlContent = htmlContent.replace(/~~(.+?)~~/g, '$1');
htmlContent = htmlContent.replace(/`([^`]+)`/g, '$1');
// 保护图片,转换链接
const imgPlaceholders = [];
htmlContent = htmlContent.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match) => {
imgPlaceholders.push(match);
return `__IMG_PLACEHOLDER_${imgPlaceholders.length - 1}__`;
});
htmlContent = htmlContent.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
imgPlaceholders.forEach((img, i) => {
htmlContent = htmlContent.replace(`__IMG_PLACEHOLDER_${i}__`, img);
});
markdown += `\n${comment.position}楼 - ${usernameDisplayHtml}
\n\n`;
markdown += htmlContent;
markdown += '\n\n \n\n';
} else {
markdown += `### ${comment.position}楼 - ${usernameDisplay}\n\n`;
markdown += commentContent;
markdown += '\n\n';
}
}
}
return markdown;
}
return {
initTurndown,
cleanupMarkdown,
convertToMarkdownWithComments
};
})();
// ============================================================
// 模块5: 保存功能 (SaveModule)
// ============================================================
const SaveModule = (function() {
const NOTION_API_VERSION = '2022-06-28';
// 通用:提取内容和评论
async function extractData(config, targetPostNumber = null) {
const extracted = ExtractModule.extractContent();
if (!extracted) {
throw new Error('无法提取帖子内容');
}
const { title, contentHTML, url, author, topicId, category, tags } = extracted;
let comments = [];
let isSingleCommentMode = targetPostNumber && targetPostNumber !== '1';
if (isSingleCommentMode) {
UtilModule.showNotification(`正在提取第${targetPostNumber}楼评论...`, 'info');
const singleComment = ExtractModule.extractSingleComment(targetPostNumber);
if (singleComment) {
comments = [singleComment];
} else {
throw new Error(`未找到第${targetPostNumber}楼评论`);
}
} else if (config.saveComments) {
let effectiveCommentCount = config.commentCount;
let effectiveSaveAll = config.saveAllComments;
if (config.useFloorRange) {
effectiveCommentCount = config.floorTo || 100;
}
const useAPI = effectiveSaveAll || effectiveCommentCount > 30;
if (useAPI && topicId) {
UtilModule.showNotification('正在通过API加载评论...', 'info');
try {
comments = await ExtractModule.extractCommentsViaAPI(
topicId,
effectiveCommentCount,
effectiveSaveAll,
(msg) => UtilModule.showNotification(msg, 'info')
);
} catch (apiError) {
console.warn('[Discourse Saver] API获取失败,回退到DOM方式:', apiError);
comments = ExtractModule.extractComments(effectiveCommentCount);
}
} else {
UtilModule.showNotification('正在提取评论...', 'info');
comments = ExtractModule.extractComments(effectiveCommentCount);
}
}
// 楼层范围过滤
if (config.useFloorRange && comments.length > 0) {
const floorFrom = config.floorFrom || 1;
const floorTo = config.floorTo || 100;
comments = comments.filter(c => {
const pos = parseInt(c.position);
return pos >= floorFrom && pos <= floorTo;
});
}
return {
title, contentHTML, url, author, topicId, category, tags,
comments, isSingleCommentMode
};
}
// 下载 Markdown 文件(备选方案:当剪贴板模式失败时使用)
function downloadMarkdownFile(markdown, fileName, folderPath) {
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}.md`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// 提示用户
const vaultPath = folderPath || 'Discourse收集箱';
UtilModule.showNotification(`文件已下载,请手动放入 Obsidian: ${vaultPath}/`, 'info');
console.log(`[Discourse Saver] 备选方案:已下载为文件: ${fileName}.md`);
console.log(`[Discourse Saver] 请将文件移动到 Obsidian vault 的 "${vaultPath}" 文件夹`);
}
// 存储最后一次大文件保存的信息(用于备选下载)
let lastLargeFileSave = null;
// 保存到 Obsidian
async function sendToObsidian(markdown, fileName, config) {
const vaultName = config.vaultName;
const folderPath = config.folderPath || 'Discourse收集箱';
const encode = (str) => encodeURIComponent(str);
// 检查必要配置
if (!vaultName) {
UtilModule.showNotification('提示:未配置 Vault 名称,请在设置中填写', 'warning');
console.warn('[Discourse Saver] 未配置 Vault 名称,Obsidian 可能无法正确打开');
}
// 检查 Advanced URI 配置
if (config.useAdvancedUri) {
console.log('[Discourse Saver] 使用 Advanced URI 模式,请确保已安装 Advanced URI 插件');
}
// 清理文件名中的特殊字符(Obsidian 不支持的字符)
const safeFileName = fileName.replace(/[#\[\]|]/g, '').trim();
if (safeFileName !== fileName) {
console.log(`[Discourse Saver] 文件名已清理: "${fileName}" -> "${safeFileName}"`);
}
// 计算 URL 编码后的内容长度
const encodedLength = encode(markdown).length;
// URL 长度限制约 2MB,但浏览器实际限制更低,设置 50KB 阈值使用剪贴板模式
const URL_LENGTH_THRESHOLD = 50000;
const useClipboard = encodedLength > URL_LENGTH_THRESHOLD;
console.log(`[Discourse Saver] Obsidian 保存: vault=${vaultName || '(未设置)'}, folder=${folderPath}, file=${safeFileName}, size=${Math.round(encodedLength/1024)}KB, clipboard=${useClipboard}`);
let obsidianUrl;
if (config.useAdvancedUri) {
const parts = [];
if (vaultName) parts.push(`vault=${encode(vaultName)}`);
parts.push(`filepath=${encode(`${folderPath}/${safeFileName}.md`)}`);
if (useClipboard) {
// 内容过大,使用剪贴板模式
GM_setClipboard(markdown, 'text');
parts.push(`clipboard=true`);
parts.push(`mode=overwrite`);
console.log(`[Discourse Saver] 内容过大 (${Math.round(encodedLength/1024)}KB),使用剪贴板模式`);
// 保存信息用于备选下载
lastLargeFileSave = { markdown, safeFileName, folderPath };
// 显示提示
UtilModule.showNotification('内容已复制到剪贴板,正在打开 Obsidian...', 'info');
} else {
parts.push(`data=${encode(markdown)}`);
parts.push(`mode=overwrite`);
}
obsidianUrl = `obsidian://advanced-uri?${parts.join('&')}`;
} else {
// 普通 URI 模式
const parts = [];
if (vaultName) parts.push(`vault=${encode(vaultName)}`);
parts.push(`file=${encode(`${folderPath}/${safeFileName}`)}`);
if (useClipboard) {
// 普通模式也尝试使用剪贴板
GM_setClipboard(markdown, 'text');
parts.push(`content=${encode('')}`);
parts.push(`overwrite=true`);
// 保存信息用于备选下载
lastLargeFileSave = { markdown, safeFileName, folderPath };
UtilModule.showNotification('内容已复制到剪贴板,Obsidian 打开后请手动粘贴', 'info');
} else {
parts.push(`content=${encode(markdown)}`);
parts.push(`overwrite=true`);
}
obsidianUrl = `obsidian://new?${parts.join('&')}`;
}
// 打开 Obsidian URI
location.href = obsidianUrl;
return true;
}
// 备选方案:下载上次大文件(供油猴菜单调用)
function downloadLastLargeFile() {
if (lastLargeFileSave) {
downloadMarkdownFile(
lastLargeFileSave.markdown,
lastLargeFileSave.fileName,
lastLargeFileSave.folderPath
);
} else {
UtilModule.showNotification('没有待下载的大文件', 'warning');
}
}
// 保存为 HTML 文件下载
function downloadAsHtml(markdown, metadata, fileName, config) {
const htmlContent = generateHtmlContent(markdown, metadata);
const folder = config.htmlExportFolder || 'Discourse导出';
const fullFileName = `${folder}/${fileName}.html`;
// 创建 Blob 并下载
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}.html`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
return true;
}
// 生成 HTML 内容
function generateHtmlContent(markdown, metadata) {
// 简单的 Markdown 转 HTML(基础转换)
let htmlBody = markdown
.replace(/^### (.+)$/gm, '$1
')
.replace(/^## (.+)$/gm, '$1
')
.replace(/^# (.+)$/gm, '$1
')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '
')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
.replace(/^---$/gm, '
')
.replace(/\n\n/g, '')
.replace(/\n/g, '
');
// 处理代码块
htmlBody = htmlBody.replace(/```(\w*)\n([\s\S]*?)```/g, (match, lang, code) => {
return `
${code.replace(//g, '>')}
`;
});
return `
${metadata.title}
${htmlBody}
`;
}
// 获取 Notion 数据库属性
async function getDatabaseProperties(token, databaseId) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.notion.com/v1/databases/${databaseId}`,
headers: {
'Authorization': `Bearer ${token}`,
'Notion-Version': NOTION_API_VERSION
},
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const data = JSON.parse(response.responseText);
resolve(data.properties || {});
} else {
const error = JSON.parse(response.responseText);
reject(new Error(error.message || '获取数据库属性失败'));
}
},
onerror: function() {
reject(new Error('网络请求失败'));
}
});
});
}
// 查找匹配的属性名(支持模糊匹配)
function findMatchingProperty(dbProps, targetName, expectedType = null) {
if (!targetName) return null;
const normalizedTarget = targetName.toLowerCase().trim();
for (const [propName, propInfo] of Object.entries(dbProps)) {
const normalizedProp = propName.toLowerCase().trim();
// 精确匹配或模糊匹配
if (normalizedProp === normalizedTarget ||
propName === targetName ||
normalizedProp.includes(normalizedTarget) ||
normalizedTarget.includes(normalizedProp)) {
// 如果指定了类型,检查类型是否匹配
if (expectedType && propInfo.type !== expectedType) {
console.warn(`[Discourse Saver] 属性 "${propName}" 类型不匹配: 期望 ${expectedType}, 实际 ${propInfo.type}`);
continue;
}
return { name: propName, type: propInfo.type };
}
}
return null;
}
// 根据 URL 查找已存在的 Notion 页面(去重)
async function findExistingPageByUrl(token, databaseId, url, urlPropName) {
return new Promise((resolve, reject) => {
const filter = {
property: urlPropName,
url: { equals: url }
};
GM_xmlhttpRequest({
method: 'POST',
url: `https://api.notion.com/v1/databases/${databaseId}/query`,
headers: {
'Authorization': `Bearer ${token}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json'
},
data: JSON.stringify({ filter, page_size: 1 }),
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const data = JSON.parse(response.responseText);
if (data.results && data.results.length > 0) {
console.log(`[Discourse Saver] 找到已存在的页面: ${data.results[0].id}`);
resolve(data.results[0]);
} else {
resolve(null);
}
} else {
console.warn('[Discourse Saver] 查询已存在页面失败:', response.responseText);
resolve(null); // 查询失败时继续创建新页面
}
},
onerror: function() {
resolve(null); // 网络错误时继续创建新页面
}
});
});
}
// 归档(删除)已存在的 Notion 页面
async function archiveNotionPage(token, pageId) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'PATCH',
url: `https://api.notion.com/v1/pages/${pageId}`,
headers: {
'Authorization': `Bearer ${token}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json'
},
data: JSON.stringify({ archived: true }),
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
console.log('[Discourse Saver] 已归档旧页面:', pageId);
resolve(true);
} else {
console.warn('[Discourse Saver] 归档页面失败:', response.responseText);
resolve(false);
}
},
onerror: function() {
resolve(false);
}
});
});
}
// 保存到 Notion(使用 GM_xmlhttpRequest 解决 CORS)
async function saveToNotion(markdown, metadata, config) {
const token = config.notionToken;
const databaseId = config.notionDatabaseId.replace(/-/g, '');
if (!token || !databaseId) {
throw new Error('请先配置 Notion Token 和 Database ID');
}
// 先获取数据库属性
console.log('[Discourse Saver] 正在获取数据库属性...');
let dbProps;
try {
dbProps = await getDatabaseProperties(token, databaseId);
console.log('[Discourse Saver] 数据库属性:', Object.keys(dbProps));
} catch (e) {
throw new Error('获取数据库属性失败: ' + e.message);
}
// 构建 Notion 页面属性(只使用数据库中存在的属性)
const properties = {};
// 标题(必需)- 查找 title 类型的属性
const titleProp = findMatchingProperty(dbProps, config.notionPropTitle || '标题', 'title') ||
findMatchingProperty(dbProps, 'Name', 'title') ||
findMatchingProperty(dbProps, '名称', 'title');
if (!titleProp) {
// 如果没找到,使用数据库中第一个 title 类型的属性
for (const [propName, propInfo] of Object.entries(dbProps)) {
if (propInfo.type === 'title') {
console.log(`[Discourse Saver] 使用 "${propName}" 作为标题属性`);
properties[propName] = {
title: [{ text: { content: metadata.title.substring(0, 2000) } }]
};
break;
}
}
if (Object.keys(properties).length === 0) {
throw new Error('数据库中没有找到标题属性(title 类型)');
}
} else {
console.log(`[Discourse Saver] 标题属性: "${titleProp.name}"`);
properties[titleProp.name] = {
title: [{ text: { content: metadata.title.substring(0, 2000) } }]
};
}
// URL - 查找 url 类型的属性
const urlProp = findMatchingProperty(dbProps, config.notionPropUrl || '链接', 'url');
if (urlProp) {
console.log(`[Discourse Saver] URL属性: "${urlProp.name}"`);
properties[urlProp.name] = { url: metadata.url };
}
// 作者 - 查找 rich_text 类型的属性
const authorProp = findMatchingProperty(dbProps, config.notionPropAuthor || '作者', 'rich_text');
if (authorProp) {
console.log(`[Discourse Saver] 作者属性: "${authorProp.name}"`);
properties[authorProp.name] = {
rich_text: [{ text: { content: metadata.author || '未知' } }]
};
}
// 分类 - 支持多种类型(select、multi_select、rich_text)
console.log(`[Discourse Saver] metadata.category = "${metadata.category || '(空)'}"`);
// 先尝试 select 类型
let categoryProp = findMatchingProperty(dbProps, config.notionPropCategory || '分类', 'select');
// 再尝试 multi_select 类型
if (!categoryProp) {
categoryProp = findMatchingProperty(dbProps, config.notionPropCategory || '分类', 'multi_select');
}
// 再尝试 rich_text 类型
if (!categoryProp) {
categoryProp = findMatchingProperty(dbProps, config.notionPropCategory || '分类', 'rich_text');
}
// 最后不限类型查找
if (!categoryProp) {
categoryProp = findMatchingProperty(dbProps, config.notionPropCategory || '分类', null);
}
if (categoryProp && metadata.category && metadata.category.trim()) {
console.log(`[Discourse Saver] 分类属性: "${categoryProp.name}" (类型: ${categoryProp.type})`);
const categoryValue = metadata.category.trim();
// 根据属性类型设置值
switch (categoryProp.type) {
case 'select':
properties[categoryProp.name] = {
select: { name: categoryValue }
};
break;
case 'multi_select':
properties[categoryProp.name] = {
multi_select: [{ name: categoryValue }]
};
break;
case 'rich_text':
properties[categoryProp.name] = {
rich_text: [{ text: { content: categoryValue } }]
};
break;
default:
// 尝试作为 rich_text 处理
properties[categoryProp.name] = {
rich_text: [{ text: { content: categoryValue } }]
};
}
console.log(`[Discourse Saver] 分类值已设置: "${categoryValue}" (类型: ${categoryProp.type})`);
} else if (!categoryProp) {
console.log('[Discourse Saver] 未找到分类属性');
// 尝试查找任何包含"分类"的属性
for (const [propName, propInfo] of Object.entries(dbProps)) {
if (propName.includes('分类') || propName.toLowerCase().includes('category')) {
console.log(`[Discourse Saver] 发现可能的分类属性: "${propName}" (类型: ${propInfo.type})`);
}
}
} else {
console.log('[Discourse Saver] 分类为空,跳过设置');
}
// 标签 - 查找 multi_select 类型的属性
if (metadata.tags && metadata.tags.length > 0) {
const tagsProp = findMatchingProperty(dbProps, config.notionPropTags || '标签', 'multi_select');
if (tagsProp) {
console.log(`[Discourse Saver] 标签属性: "${tagsProp.name}"`);
properties[tagsProp.name] = {
multi_select: metadata.tags.slice(0, 10).map(tag => ({ name: tag.substring(0, 100) }))
};
}
}
// 保存日期 - 查找 date 类型的属性
const dateProp = findMatchingProperty(dbProps, config.notionPropSavedDate || '保存日期', 'date');
if (dateProp) {
console.log(`[Discourse Saver] 日期属性: "${dateProp.name}"`);
properties[dateProp.name] = {
date: { start: new Date().toISOString().split('T')[0] }
};
}
// 评论数 - 查找 number 类型的属性
const commentCountProp = findMatchingProperty(dbProps, config.notionPropCommentCount || '评论数', 'number');
if (commentCountProp) {
console.log(`[Discourse Saver] 评论数属性: "${commentCountProp.name}"`);
properties[commentCountProp.name] = {
number: metadata.commentCount || 0
};
}
console.log('[Discourse Saver] 最终属性:', Object.keys(properties));
// 将 Markdown 转换为 Notion blocks
const children = markdownToNotionBlocks(markdown);
// 去重检查:根据 URL 查找已存在的页面
let existingPage = null;
if (urlProp) {
console.log('[Discourse Saver] 检查是否已存在相同 URL 的页面...');
existingPage = await findExistingPageByUrl(token, databaseId, metadata.url, urlProp.name);
}
if (existingPage) {
// 归档已存在的页面,然后创建新页面
console.log('[Discourse Saver] 找到已存在的页面,正在归档后重新创建...');
await archiveNotionPage(token, existingPage.id);
}
// 创建新页面(先添加前100个块)
console.log(`[Discourse Saver] 总块数: ${children.length}`);
const pageData = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://api.notion.com/v1/pages',
headers: {
'Authorization': `Bearer ${token}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json'
},
data: JSON.stringify({
parent: { database_id: databaseId },
properties: properties,
children: children.slice(0, 100)
}),
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const data = JSON.parse(response.responseText);
console.log('[Discourse Saver] Notion 页面创建成功:', data.id);
resolve(data);
} else {
const error = JSON.parse(response.responseText);
console.error('[Discourse Saver] Notion 错误:', error);
console.error('[Discourse Saver] 请求数据:', { properties, childrenCount: children.length });
reject(new Error(error.message || 'Notion API 错误'));
}
},
onerror: function(error) {
reject(new Error('网络请求失败'));
}
});
});
// 如果有超过100个块,分批追加剩余的块
if (children.length > 100) {
const pageId = pageData.id;
const remainingChildren = children.slice(100);
console.log(`[Discourse Saver] 需要追加 ${remainingChildren.length} 个块`);
// 每批最多100个块
for (let i = 0; i < remainingChildren.length; i += 100) {
const batch = remainingChildren.slice(i, i + 100);
console.log(`[Discourse Saver] 追加第 ${Math.floor(i/100) + 1} 批,${batch.length} 个块`);
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'PATCH',
url: `https://api.notion.com/v1/blocks/${pageId}/children`,
headers: {
'Authorization': `Bearer ${token}`,
'Notion-Version': NOTION_API_VERSION,
'Content-Type': 'application/json'
},
data: JSON.stringify({ children: batch }),
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
resolve();
} else {
console.warn('[Discourse Saver] 追加块失败:', response.responseText);
resolve(); // 继续处理,不中断
}
},
onerror: function() {
console.warn('[Discourse Saver] 追加块网络错误');
resolve(); // 继续处理
}
});
});
// 避免 API 限流,稍微延迟
if (i + 100 < remainingChildren.length) {
await new Promise(r => setTimeout(r, 300));
}
}
console.log('[Discourse Saver] 所有块追加完成');
}
return pageData;
}
// Markdown 转 Notion Blocks(v4.6.0 增强版 - 支持更多内容类型)
function markdownToNotionBlocks(markdown) {
const blocks = [];
const lines = markdown.split('\n');
let i = 0;
// 辅助函数:检测URL类型
function getUrlType(url) {
// 视频链接(YouTube、Bilibili、Vimeo、优酷、腾讯视频)
if (/youtube\.com|youtu\.be|vimeo\.com|bilibili\.com|b23\.tv|v\.youku\.com|v\.qq\.com/i.test(url)) {
return 'video';
}
// 网盘链接
if (/pan\.baidu\.com|yun\.baidu\.com|pan\.quark\.cn|123pan\.com|lanzou[a-z]*\.(com|cn)|lanzoui\.com|lanzoux\.com|aliyundrive\.com|alipan\.com|cloud\.189\.cn/i.test(url)) {
return 'cloud';
}
// 音频链接
if (/\.mp3|\.wav|\.ogg|\.m4a|\.aac|soundcloud\.com|spotify\.com|music\./i.test(url)) {
return 'audio';
}
// 图片链接
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?|$)/i.test(url)) {
return 'image';
}
// PDF 链接
if (/\.pdf(\?|$)/i.test(url)) {
return 'pdf';
}
return 'link';
}
// 辅助函数:获取网盘名称
function getCloudName(url) {
if (/pan\.baidu\.com|yun\.baidu\.com/i.test(url)) return '百度网盘';
if (/pan\.quark\.cn/i.test(url)) return '夸克网盘';
if (/123pan\.com/i.test(url)) return '123云盘';
if (/lanzou[a-z]*\.(com|cn)|lanzoui\.com|lanzoux\.com/i.test(url)) return '蓝奏云';
if (/aliyundrive\.com|alipan\.com/i.test(url)) return '阿里云盘';
if (/cloud\.189\.cn/i.test(url)) return '天翼云盘';
return '网盘';
}
// 辅助函数:验证并补全 URL
function normalizeUrl(url) {
if (!url) return null;
let normalized = url.trim();
// 如果是相对路径,补全为完整 URL
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
if (normalized.startsWith('/')) {
normalized = window.location.origin + normalized;
} else if (normalized.startsWith('#')) {
normalized = window.location.href.split('#')[0] + normalized;
} else if (!normalized.includes(':')) {
// 不是特殊协议(如 mailto:, tel:),添加 https://
normalized = 'https://' + normalized;
}
}
// 验证 URL 是否有效
try {
new URL(normalized);
return normalized;
} catch {
return null;
}
}
// 辅助函数:解析富文本(支持加粗、斜体、链接、代码)
function parseRichText(text) {
const richText = [];
let remaining = text;
// 简单处理:如果有复杂格式,按段处理
// 检测链接 [text](url)
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match;
while ((match = linkRegex.exec(text)) !== null) {
// 添加链接前的文本
if (match.index > lastIndex) {
const before = text.substring(lastIndex, match.index);
if (before) {
richText.push(...parseInlineFormatting(before));
}
}
// 处理链接 URL
const linkUrl = normalizeUrl(match[2]);
if (linkUrl) {
richText.push({
text: { content: match[1], link: { url: linkUrl } }
});
} else {
// URL 无效,只显示文本不添加链接
richText.push({
text: { content: match[1] }
});
}
lastIndex = match.index + match[0].length;
}
// 添加剩余文本
if (lastIndex < text.length) {
richText.push(...parseInlineFormatting(text.substring(lastIndex)));
}
return richText.length > 0 ? richText : [{ text: { content: text.substring(0, 2000) } }];
}
// 辅助函数:解析内联格式(加粗、斜体、代码)
function parseInlineFormatting(text) {
const parts = [];
// 简化处理:检测 **加粗** *斜体* `代码`
let remaining = text;
// 加粗
remaining = remaining.replace(/\*\*([^*]+)\*\*/g, (_, content) => {
parts.push({ text: { content }, annotations: { bold: true } });
return '\x00';
});
// 斜体
remaining = remaining.replace(/\*([^*]+)\*/g, (_, content) => {
parts.push({ text: { content }, annotations: { italic: true } });
return '\x00';
});
// 行内代码
remaining = remaining.replace(/`([^`]+)`/g, (_, content) => {
parts.push({ text: { content }, annotations: { code: true } });
return '\x00';
});
// 处理剩余的普通文本
const normalParts = remaining.split('\x00').filter(p => p);
normalParts.forEach(p => {
parts.push({ text: { content: p } });
});
return parts.length > 0 ? parts : [{ text: { content: text.substring(0, 2000) } }];
}
while (i < lines.length) {
const line = lines[i];
// 跳过 YAML frontmatter
if (line === '---' && i === 0) {
i++;
while (i < lines.length && lines[i] !== '---') i++;
i++;
continue;
}
// 标题
if (line.startsWith('# ')) {
blocks.push({
type: 'heading_1',
heading_1: { rich_text: parseRichText(line.substring(2)) }
});
} else if (line.startsWith('## ')) {
blocks.push({
type: 'heading_2',
heading_2: { rich_text: parseRichText(line.substring(3)) }
});
} else if (line.startsWith('### ')) {
blocks.push({
type: 'heading_3',
heading_3: { rich_text: parseRichText(line.substring(4)) }
});
}
// 代码块
else if (line.startsWith('```')) {
const lang = line.substring(3).trim() || 'plain text';
let code = '';
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
code += lines[i] + '\n';
i++;
}
blocks.push({
type: 'code',
code: {
language: lang,
rich_text: [{ text: { content: code.trimEnd().substring(0, 2000) } }]
}
});
}
// Obsidian callout / 折叠块
else if (line.startsWith('> [!')) {
const calloutMatch = line.match(/^> \[!([^\]]+)\]([+-])?\s*(.*)$/);
if (calloutMatch) {
const calloutType = calloutMatch[1];
const isCollapsed = calloutMatch[2] === '-';
const title = calloutMatch[3] || calloutType;
// 收集 callout 内容
let content = '';
i++;
while (i < lines.length && lines[i].startsWith('> ')) {
content += lines[i].substring(2) + '\n';
i++;
}
i--; // 回退一行
// 使用 callout 块
blocks.push({
type: 'callout',
callout: {
rich_text: [{ text: { content: title + '\n' + content.trim() } }],
icon: { type: 'emoji', emoji: calloutType === 'note' ? '📝' : calloutType === 'warning' ? '⚠️' : '💡' }
}
});
}
}
// 引用块
else if (line.startsWith('> ')) {
let quoteContent = line.substring(2);
i++;
while (i < lines.length && lines[i].startsWith('> ')) {
quoteContent += '\n' + lines[i].substring(2);
i++;
}
i--; // 回退一行
blocks.push({
type: 'quote',
quote: { rich_text: parseRichText(quoteContent) }
});
}
// 列表项(无序)
else if (line.match(/^[-*]\s+/)) {
const content = line.replace(/^[-*]\s+/, '');
blocks.push({
type: 'bulleted_list_item',
bulleted_list_item: { rich_text: parseRichText(content) }
});
}
// 列表项(有序)
else if (line.match(/^\d+\.\s+/)) {
const content = line.replace(/^\d+\.\s+/, '');
blocks.push({
type: 'numbered_list_item',
numbered_list_item: { rich_text: parseRichText(content) }
});
}
// 任务列表
else if (line.match(/^[-*]\s+\[[ x]\]\s+/)) {
const isChecked = line.includes('[x]');
const content = line.replace(/^[-*]\s+\[[ x]\]\s+/, '');
blocks.push({
type: 'to_do',
to_do: {
rich_text: parseRichText(content),
checked: isChecked
}
});
}
// 分割线
else if (line === '---' || line === '***' || line === '___') {
blocks.push({ type: 'divider', divider: {} });
}
// 图片(支持带链接的图片)
else if (line.match(/^!\[.*\]\(.+\)$/)) {
const match = line.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (match) {
const rawUrl = match[2];
// 检查是否是 base64 数据(太大不支持)
if (rawUrl.startsWith('data:')) {
blocks.push({
type: 'paragraph',
paragraph: { rich_text: [{ text: { content: '[内嵌图片 - Notion 不支持 Base64]' } }] }
});
} else {
const imageUrl = normalizeUrl(rawUrl);
if (imageUrl) {
blocks.push({
type: 'image',
image: { type: 'external', external: { url: imageUrl } }
});
} else {
blocks.push({
type: 'paragraph',
paragraph: { rich_text: [{ text: { content: '[图片链接无效]' } }] }
});
}
}
}
}
// 独立链接行(可能是视频/音频/书签)
else if (line.match(/^https?:\/\/[^\s]+$/)) {
const url = line.trim();
const urlType = getUrlType(url);
switch (urlType) {
case 'video':
blocks.push({
type: 'video',
video: { type: 'external', external: { url } }
});
break;
case 'cloud':
// 网盘链接:使用 callout 块突出显示
blocks.push({
type: 'callout',
callout: {
icon: { emoji: '📦' },
rich_text: [{
text: { content: getCloudName(url) + ': ', link: null },
annotations: { bold: true }
}, {
text: { content: '点击下载', link: { url } }
}]
}
});
break;
case 'audio':
// Notion 不直接支持音频块,使用书签
blocks.push({
type: 'bookmark',
bookmark: { url }
});
break;
case 'image':
blocks.push({
type: 'image',
image: { type: 'external', external: { url } }
});
break;
case 'pdf':
blocks.push({
type: 'pdf',
pdf: { type: 'external', external: { url } }
});
break;
default:
// 普通链接,使用书签预览
blocks.push({
type: 'bookmark',
bookmark: { url }
});
}
}
// Markdown 链接行 [text](url)
else if (line.match(/^\[.+\]\(.+\)$/)) {
const match = line.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (match) {
const text = match[1];
const url = normalizeUrl(match[2]);
if (!url) {
// URL 无效,只显示文本
blocks.push({
type: 'paragraph',
paragraph: { rich_text: [{ text: { content: text } }] }
});
} else {
const urlType = getUrlType(url);
// 视频链接使用 embed
if (urlType === 'video') {
blocks.push({
type: 'video',
video: { type: 'external', external: { url } }
});
} else if (urlType === 'cloud') {
// 网盘链接:使用 callout 块
blocks.push({
type: 'callout',
callout: {
icon: { emoji: '📦' },
rich_text: [{
text: { content: getCloudName(url) + ': ', link: null },
annotations: { bold: true }
}, {
text: { content: text || '点击下载', link: { url } }
}]
}
});
} else if (urlType === 'image') {
blocks.push({
type: 'image',
image: { type: 'external', external: { url } }
});
} else {
// 普通链接
blocks.push({
type: 'paragraph',
paragraph: {
rich_text: [{
text: { content: text, link: { url } }
}]
}
});
}
}
}
}
// 普通段落(带格式解析)
else if (line.trim()) {
blocks.push({
type: 'paragraph',
paragraph: { rich_text: parseRichText(line) }
});
}
i++;
}
return blocks;
}
// 测试 Notion 连接(支持两种调用方式)
async function testNotionConnection(tokenOrConfig, dbId = null) {
let token, databaseId;
// 支持两种调用方式:testNotionConnection(config) 或 testNotionConnection(token, dbId)
if (typeof tokenOrConfig === 'object') {
token = tokenOrConfig.notionToken;
databaseId = tokenOrConfig.notionDatabaseId;
} else {
token = tokenOrConfig;
databaseId = dbId;
}
databaseId = databaseId.replace(/-/g, '');
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.notion.com/v1/databases/${databaseId}`,
headers: {
'Authorization': `Bearer ${token}`,
'Notion-Version': NOTION_API_VERSION
},
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const data = JSON.parse(response.responseText);
resolve({ success: true, title: data.title?.[0]?.plain_text || 'Database' });
} else {
const error = JSON.parse(response.responseText);
reject(new Error(error.message || 'Connection failed'));
}
},
onerror: function() {
reject(new Error('网络请求失败'));
}
});
});
}
// 准备保存数据(公共函数)
async function prepareData(targetPostNumber = null) {
const config = ConfigModule.get();
const data = await extractData(config, targetPostNumber);
const { title, contentHTML, url, author, topicId, category, tags, comments, isSingleCommentMode } = data;
const effectiveConfig = isSingleCommentMode
? { ...config, saveComments: true, foldComments: false }
: config;
let markdown = ConvertModule.convertToMarkdownWithComments(
contentHTML,
{ title, url, author, topicId, category, tags },
comments,
effectiveConfig
);
// 如果启用了图片嵌入,将图片转换为 Base64
if (config.embedImages) {
try {
UtilModule.showNotification('正在嵌入图片(可能需要一些时间)...', 'info');
const originalMarkdown = markdown; // 保存原始内容以防出错
markdown = await UtilModule.embedImagesInMarkdown(markdown, (completed, total) => {
UtilModule.showNotification(`正在嵌入图片 ${completed}/${total}...`, 'info');
});
// 检查结果是否有效
if (!markdown || markdown.length === 0) {
console.warn('[Discourse Saver] 图片嵌入返回空结果,使用原始内容');
markdown = originalMarkdown;
}
} catch (embedError) {
console.error('[Discourse Saver] 图片嵌入失败,使用原始内容:', embedError);
UtilModule.showNotification('图片嵌入失败,将使用原始图片链接', 'warning');
// markdown 保持原值,不影响后续保存
}
}
let fileName = UtilModule.sanitizeFileName(title);
let displayTitle = title; // Notion 等显示用的标题
if (isSingleCommentMode) {
fileName += `-${targetPostNumber}楼`;
displayTitle = `${title} #${targetPostNumber}楼`; // Notion 标题也加上楼层信息
}
const metadata = { title: displayTitle, url, author, category, tags, commentCount: comments.length };
return { markdown, fileName, metadata, comments, config, isSingleCommentMode, targetPostNumber };
}
// 独立导出:仅保存到 Obsidian
async function saveToObsidianOnly(targetPostNumber = null) {
try {
UtilModule.showNotification('正在准备保存到 Obsidian...', 'info');
const { markdown, fileName, comments, config } = await prepareData(targetPostNumber);
await sendToObsidian(markdown, fileName, config);
const commentInfo = comments.length > 0 ? `(含${comments.length}条评论)` : '';
UtilModule.showNotification(`已保存到 Obsidian${commentInfo}`, 'success');
return { success: true, target: 'Obsidian' };
} catch (error) {
console.error('[Discourse Saver] Obsidian 保存失败:', error);
UtilModule.showNotification('Obsidian 保存失败: ' + error.message, 'error');
return { success: false, target: 'Obsidian', error: error.message };
}
}
// 独立导出:仅保存到 Notion
async function saveToNotionOnly(targetPostNumber = null) {
try {
const config = ConfigModule.get();
if (!config.notionToken || !config.notionDatabaseId) {
throw new Error('请先配置 Notion Token 和 Database ID');
}
UtilModule.showNotification('正在准备保存到 Notion...', 'info');
const { markdown, metadata, comments } = await prepareData(targetPostNumber);
await saveToNotion(markdown, metadata, config);
const commentInfo = comments.length > 0 ? `(含${comments.length}条评论)` : '';
UtilModule.showNotification(`已保存到 Notion${commentInfo}`, 'success');
return { success: true, target: 'Notion' };
} catch (error) {
console.error('[Discourse Saver] Notion 保存失败:', error);
UtilModule.showNotification('Notion 保存失败: ' + error.message, 'error');
return { success: false, target: 'Notion', error: error.message };
}
}
// 独立导出:仅导出为 HTML
async function exportHtmlOnly(targetPostNumber = null) {
try {
UtilModule.showNotification('正在准备导出 HTML...', 'info');
const { markdown, fileName, metadata, comments, config } = await prepareData(targetPostNumber);
downloadAsHtml(markdown, metadata, fileName, config);
const commentInfo = comments.length > 0 ? `(含${comments.length}条评论)` : '';
UtilModule.showNotification(`已导出 HTML${commentInfo}`, 'success');
return { success: true, target: 'HTML' };
} catch (error) {
console.error('[Discourse Saver] HTML 导出失败:', error);
UtilModule.showNotification('HTML 导出失败: ' + error.message, 'error');
return { success: false, target: 'HTML', error: error.message };
}
}
// 主保存函数(并行处理所有选中的目标,Obsidian 最后执行)
async function save(targetPostNumber = null) {
try {
const config = ConfigModule.get();
console.log('[Discourse Saver] 配置:', config);
// 检查是否至少选择了一个保存目标
if (!config.saveToObsidian && !config.saveToNotion && !config.exportHtml) {
UtilModule.showNotification('请在设置中至少选择一个保存目标', 'warning');
return;
}
// 提取数据(只提取一次)
UtilModule.showNotification('正在提取内容...', 'info');
const { markdown, fileName, metadata, comments } = await prepareData(targetPostNumber);
// 构建任务列表(Obsidian 单独处理,因为会跳转页面)
const tasks = [];
const taskNames = [];
let shouldSaveToObsidian = false;
// Notion 和 HTML 先执行(不会跳转页面)
if (config.saveToNotion && config.notionToken && config.notionDatabaseId) {
tasks.push(
saveToNotion(markdown, metadata, config)
.then(() => ({ success: true, target: 'Notion' }))
.catch(e => ({ success: false, target: 'Notion', error: e.message }))
);
taskNames.push('Notion');
}
if (config.exportHtml) {
tasks.push(
Promise.resolve()
.then(() => {
downloadAsHtml(markdown, metadata, fileName, config);
return { success: true, target: 'HTML' };
})
.catch(e => ({ success: false, target: 'HTML', error: e.message }))
);
taskNames.push('HTML');
}
// 记录是否需要保存到 Obsidian(最后执行)
if (config.saveToObsidian) {
shouldSaveToObsidian = true;
taskNames.push('Obsidian');
}
if (taskNames.length === 0) {
UtilModule.showNotification('没有可执行的保存任务', 'warning');
return;
}
// 显示正在保存
UtilModule.showNotification(`正在保存到 ${taskNames.join('、')}...`, 'info');
// 先并行执行 Notion 和 HTML 任务
let results = [];
if (tasks.length > 0) {
results = await Promise.allSettled(tasks);
}
// 统计结果
const succeeded = [];
const failed = [];
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.success) {
succeeded.push(result.value.target);
} else {
// 注意:taskNames 不包含 Obsidian(如果有的话),所以 index 对应的是非 Obsidian 任务
const nonObsidianTaskNames = taskNames.filter(t => t !== 'Obsidian');
const target = result.status === 'fulfilled' ? result.value.target : nonObsidianTaskNames[index];
const error = result.status === 'fulfilled' ? result.value.error : result.reason?.message;
failed.push({ target, error });
console.error(`[Discourse Saver] ${target} 保存失败:`, error);
}
});
// 显示 Notion/HTML 结果(如果有)
const commentInfo = comments.length > 0 ? `(含${comments.length}条评论)` : '';
if (succeeded.length > 0 && !shouldSaveToObsidian) {
// 没有 Obsidian 任务,直接显示最终结果
if (failed.length === 0) {
UtilModule.showNotification(`已保存到 ${succeeded.join('、')}${commentInfo}`, 'success');
} else {
UtilModule.showNotification(
`已保存到 ${succeeded.join('、')}${commentInfo},${failed.map(f => f.target).join('、')} 失败`,
'warning'
);
}
} else if (failed.length > 0 && !shouldSaveToObsidian) {
UtilModule.showNotification(`${failed.map(f => f.target).join('、')} 保存失败`, 'error');
}
// 执行 Obsidian 保存(会跳转页面,所以放在最后)
if (shouldSaveToObsidian) {
// 先显示其他任务的结果
if (succeeded.length > 0) {
const msg = `${succeeded.join('、')} 已完成${commentInfo},正在打开 Obsidian...`;
UtilModule.showNotification(msg, 'info');
} else if (failed.length > 0) {
const msg = `${failed.map(f => f.target).join('、')} 失败,正在打开 Obsidian...`;
UtilModule.showNotification(msg, 'warning');
} else {
UtilModule.showNotification('正在打开 Obsidian...', 'info');
}
// 等待一段时间让用户看到通知
await new Promise(resolve => setTimeout(resolve, 1500));
try {
await sendToObsidian(markdown, fileName, config);
succeeded.push('Obsidian');
} catch (e) {
failed.push({ target: 'Obsidian', error: e.message });
console.error('[Discourse Saver] Obsidian 保存失败:', e);
UtilModule.showNotification('Obsidian 打开失败: ' + e.message, 'error');
}
}
return { succeeded, failed };
} catch (error) {
console.error('[Discourse Saver] 保存失败:', error);
UtilModule.showNotification('保存失败: ' + error.message, 'error');
return { succeeded: [], failed: [{ target: 'all', error: error.message }] };
}
}
// 导出所有函数
return {
save, // 根据配置保存(并行)
saveToObsidianOnly, // 仅保存到 Obsidian
saveToNotionOnly, // 仅保存到 Notion
exportHtmlOnly, // 仅导出 HTML
testNotionConnection, // 测试 Notion 连接
downloadLastLargeFile // 备选:下载大文件
};
})();
// ============================================================
// 模块6: 用户界面 (UIModule)
// ============================================================
const UIModule = (function() {
let linkClickCount = 0;
let linkClickTimer = null;
let lastLinkPostNumber = null;
// 注入样式
function injectStyles() {
GM_addStyle(`
@keyframes dsSlideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.ds-settings-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 999998;
display: flex;
align-items: center;
justify-content: center;
}
.ds-settings-panel {
background: white;
border-radius: 12px;
padding: 24px;
width: 480px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.ds-settings-panel h2 {
margin: 0 0 20px 0;
font-size: 20px;
color: #1f2937;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 12px;
}
.ds-form-group {
margin-bottom: 16px;
}
.ds-form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #374151;
font-size: 14px;
}
.ds-form-group input[type="text"],
.ds-form-group input[type="number"] {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
}
.ds-form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.ds-checkbox-group {
display: flex;
align-items: center;
gap: 8px;
}
.ds-checkbox-group input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.ds-checkbox-group label {
margin: 0;
cursor: pointer;
}
.ds-btn-group {
display: flex;
gap: 12px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
.ds-btn {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.ds-btn-primary {
background: #3b82f6;
color: white;
}
.ds-btn-primary:hover {
background: #2563eb;
}
.ds-btn-secondary {
background: #f3f4f6;
color: #374151;
}
.ds-btn-secondary:hover {
background: #e5e7eb;
}
.ds-section-title {
font-size: 14px;
font-weight: 600;
color: #6b7280;
margin: 20px 0 12px 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ds-hint {
font-size: 12px;
color: #9ca3af;
margin-top: 4px;
}
`);
}
// 判断是否为链接按钮
function isLinkButton(element) {
if (!element) return { isLink: false, postNumber: null };
if (!ExtractModule.isTopicPage()) {
return { isLink: false, postNumber: null };
}
const postContainer = element.closest('.topic-post, article[data-post-id]');
if (!postContainer) {
return { isLink: false, postNumber: null };
}
const controlsArea = element.closest('.post-controls, .post-menu-area, .actions, nav.post-controls');
if (!controlsArea) {
return { isLink: false, postNumber: null };
}
const className = element.className || '';
const dataShareUrl = element.getAttribute('data-share-url');
const title = element.title || '';
const ariaLabel = element.getAttribute('aria-label') || '';
const hasCopyLinkClass = className.includes('post-action-menu__copy-link') ||
className.includes('copy-link');
const hasShareUrl = dataShareUrl !== null && dataShareUrl !== '';
const hasShareClass = className.includes('share');
const hasShareTitle = title.includes('将此帖子的链接复制到剪贴板') ||
title.includes('复制到剪贴板') ||
title.includes('链接') ||
title.toLowerCase().includes('copy a link') ||
title.toLowerCase().includes('copy') ||
title.toLowerCase().includes('share');
const hasShareAria = ariaLabel.includes('链接') ||
ariaLabel.includes('复制') ||
ariaLabel.includes('分享') ||
ariaLabel.toLowerCase().includes('share') ||
ariaLabel.toLowerCase().includes('copy');
const isLinkLike = hasCopyLinkClass || hasShareUrl || hasShareClass || hasShareTitle || hasShareAria;
if (!isLinkLike) {
return { isLink: false, postNumber: null };
}
const topicPost = element.closest('.topic-post');
const postNumber = topicPost?.getAttribute('data-post-number') ||
postContainer.getAttribute('data-post-number') ||
postContainer.querySelector('[data-post-number]')?.getAttribute('data-post-number') ||
'1';
return { isLink: true, postNumber: postNumber };
}
// 触发原生复制链接
function triggerOriginalCopyLink(postNumber) {
let linkUrl = window.location.href;
linkUrl = linkUrl.replace(/#.*$/, '').replace(/\?.*$/, '');
if (postNumber !== '1') {
const match = linkUrl.match(/^(.*\/t\/[^/]+\/\d+)(\/\d+)?$/);
if (match) {
linkUrl = match[1] + '/' + postNumber;
} else {
linkUrl = linkUrl + '/' + postNumber;
}
}
GM_setClipboard(linkUrl, 'text');
if (postNumber === '1') {
UtilModule.showNotification('已复制帖子链接', 'success');
} else {
UtilModule.showNotification(`已复制${postNumber}楼链接`, 'success');
}
}
// 劫持链接按钮
function hijackLinkButton() {
document.addEventListener('click', (e) => {
let target = e.target.closest('button');
if (!target) {
target = e.target.closest('a');
}
if (target?.hasAttribute('data-ds-bypass')) {
return;
}
const linkResult = isLinkButton(target);
if (target && linkResult.isLink) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const isSameButton = lastLinkPostNumber === linkResult.postNumber;
if (isSameButton) {
linkClickCount++;
} else {
if (linkClickTimer) {
clearTimeout(linkClickTimer);
linkClickTimer = null;
}
linkClickCount = 1;
}
lastLinkPostNumber = linkResult.postNumber;
if (linkClickTimer) {
clearTimeout(linkClickTimer);
}
if (linkClickCount === 2 && isSameButton) {
// 双击:复制链接
linkClickCount = 0;
lastLinkPostNumber = null;
triggerOriginalCopyLink(linkResult.postNumber);
} else {
// 等待判断
const postNumber = linkResult.postNumber;
linkClickTimer = setTimeout(() => {
if (linkClickCount === 1) {
// 单击:保存
if (postNumber === '1') {
console.log('[Discourse Saver] 单击主帖链接按钮');
SaveModule.save(null);
} else {
console.log('[Discourse Saver] 单击评论链接按钮,楼层:', postNumber);
SaveModule.save(postNumber);
}
}
linkClickCount = 0;
lastLinkPostNumber = null;
}, 300);
}
return false;
}
}, true);
console.log('[Discourse Saver] 链接按钮劫持已激活');
}
// 显示设置面板
function showSettingsPanel() {
const config = ConfigModule.get();
const overlay = document.createElement('div');
overlay.className = 'ds-settings-overlay';
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
// 互斥逻辑:saveAllComments 和 useFloorRange
const allCommentsCheckbox = overlay.querySelector('#ds-all-comments');
const floorRangeCheckbox = overlay.querySelector('#ds-floor-range');
allCommentsCheckbox.addEventListener('change', () => {
if (allCommentsCheckbox.checked) {
floorRangeCheckbox.checked = false;
}
});
floorRangeCheckbox.addEventListener('change', () => {
if (floorRangeCheckbox.checked) {
allCommentsCheckbox.checked = false;
}
});
// 测试Notion连接
overlay.querySelector('#ds-test-notion').addEventListener('click', async () => {
const token = overlay.querySelector('#ds-notion-token').value.trim();
const dbId = overlay.querySelector('#ds-notion-db').value.trim();
const statusEl = overlay.querySelector('#ds-notion-status');
if (!token || !dbId) {
statusEl.textContent = '请填写Token和Database ID';
statusEl.style.color = '#ef4444';
return;
}
statusEl.textContent = '测试中...';
statusEl.style.color = '#6b7280';
try {
const result = await SaveModule.testNotionConnection(token, dbId);
if (result.success) {
statusEl.textContent = '连接成功!';
statusEl.style.color = '#22c55e';
} else {
statusEl.textContent = '连接失败: ' + result.error;
statusEl.style.color = '#ef4444';
}
} catch (e) {
statusEl.textContent = '测试出错: ' + e.message;
statusEl.style.color = '#ef4444';
}
});
// 取消按钮
overlay.querySelector('#ds-cancel').addEventListener('click', () => {
overlay.remove();
});
// 保存按钮
overlay.querySelector('#ds-save').addEventListener('click', () => {
const newConfig = {
// 自定义站点
customSites: overlay.querySelector('#ds-custom-sites').value.trim(),
// 保存目标
saveToObsidian: overlay.querySelector('#ds-save-obsidian').checked,
saveToNotion: overlay.querySelector('#ds-save-notion').checked,
exportHtml: overlay.querySelector('#ds-export-html').checked,
// Obsidian设置
vaultName: overlay.querySelector('#ds-vault').value.trim(),
folderPath: overlay.querySelector('#ds-folder').value.trim() || 'Discourse收集箱',
addMetadata: overlay.querySelector('#ds-metadata').checked,
useAdvancedUri: overlay.querySelector('#ds-advanced-uri').checked,
embedImages: overlay.querySelector('#ds-embed-images').checked,
// Notion设置
notionToken: overlay.querySelector('#ds-notion-token').value.trim(),
notionDatabaseId: overlay.querySelector('#ds-notion-db').value.trim(),
// HTML设置
htmlExportFolder: overlay.querySelector('#ds-html-folder').value.trim() || 'Discourse导出',
// 评论设置
saveComments: overlay.querySelector('#ds-comments').checked,
foldComments: overlay.querySelector('#ds-fold').checked,
saveAllComments: overlay.querySelector('#ds-all-comments').checked,
commentCount: parseInt(overlay.querySelector('#ds-comment-count').value) || 100,
// 楼层范围
useFloorRange: overlay.querySelector('#ds-floor-range').checked,
floorFrom: parseInt(overlay.querySelector('#ds-floor-from').value) || 1,
floorTo: parseInt(overlay.querySelector('#ds-floor-to').value) || 100
};
ConfigModule.setAll(newConfig);
overlay.remove();
UtilModule.showNotification('设置已保存', 'success');
});
// 点击遮罩关闭
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.remove();
}
});
}
// 初始化
function init() {
// 检测是否是 Discourse 论坛
const isDiscourse = ExtractModule.isDiscourseForumPage();
const isTopicPageNow = ExtractModule.isTopicPage();
console.log('[Discourse Saver] 检测结果:');
console.log('[Discourse Saver] - 是否 Discourse 论坛:', isDiscourse);
console.log('[Discourse Saver] - 是否帖子页面:', isTopicPageNow);
console.log('[Discourse Saver] - 当前 URL:', window.location.href);
injectStyles();
hijackLinkButton();
// 注册油猴菜单
GM_registerMenuCommand('⚙️ 设置', showSettingsPanel);
GM_registerMenuCommand('📥 保存当前帖子(全部目标)', () => SaveModule.save(null));
GM_registerMenuCommand('📝 仅保存到 Obsidian', () => SaveModule.saveToObsidianOnly(null));
GM_registerMenuCommand('📑 仅保存到 Notion', () => SaveModule.saveToNotionOnly(null));
GM_registerMenuCommand('📄 仅导出为 HTML', () => SaveModule.exportHtmlOnly(null));
GM_registerMenuCommand('💾 下载大文件(备选)', () => SaveModule.downloadLastLargeFile());
GM_registerMenuCommand('🔍 调试信息', () => {
const info = {
isDiscourse: ExtractModule.isDiscourseForumPage(),
isTopicPage: ExtractModule.isTopicPage(),
url: window.location.href,
title: document.title,
hasTopicTitle: !!document.querySelector('#topic-title h1, .fancy-title'),
hasCooked: !!document.querySelector('.cooked'),
hasPostStream: !!document.querySelector('.post-stream, .topic-post')
};
console.log('[Discourse Saver] 调试信息:', info);
alert('调试信息已输出到控制台 (F12)\n\n' +
'是否 Discourse: ' + info.isDiscourse + '\n' +
'是否帖子页面: ' + info.isTopicPage + '\n' +
'找到标题: ' + info.hasTopicTitle + '\n' +
'找到内容: ' + info.hasCooked + '\n' +
'找到帖子流: ' + info.hasPostStream);
});
console.log('[Discourse Saver] 油猴脚本已加载 (V4.5.6)');
}
return { init, showSettingsPanel };
})();
// ============================================================
// 启动脚本
// ============================================================
UIModule.init();
})();