// ==UserScript==
// @name Discourse Saver (油猴版 · Raw 特别版)
// @namespace https://github.com/discourse-saver
// @version 5.6.0-raw
// @description 通用Discourse论坛内容保存工具 Raw特别版 - 直接使用Discourse原始Markdown,表格/代码块零损耗,支持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
// @require https://cdn.jsdelivr.net/npm/marked@9.1.0/marked.min.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://raw.githubusercontent.com/acheng-byte/discourse-saver/main/raw-edition/discourse-saver.user.js
// @updateURL https://raw.githubusercontent.com/acheng-byte/discourse-saver/main/raw-edition/discourse-saver.user.js
// ==/UserScript==
(function() {
'use strict';
// ============================================================
// 模块1: 配置管理 (ConfigModule)
// ============================================================
const ConfigModule = (function() {
const DEFAULT_CONFIG = {
// 保存目标
saveToObsidian: true,
saveToNotion: false,
saveToFeishu: false,
exportHtml: false,
// Obsidian 设置
vaultName: '',
folderPath: 'Discourse收集箱',
useAdvancedUri: true,
// Notion 设置
notionToken: '',
notionDatabaseId: '',
notionPropTitle: '标题',
notionPropUrl: '链接',
notionPropAuthor: '作者',
notionPropCategory: '分类',
notionPropTags: '标签',
notionPropSavedDate: '保存日期',
notionPropCommentCount: '评论数',
// 飞书设置
feishuApiDomain: 'feishu',
feishuAppId: '',
feishuAppSecret: '',
feishuAppToken: '',
feishuTableId: '',
feishuUploadContent: true,
feishuUploadAttachment: false,
feishuUploadHtml: false,
// HTML 导出设置
htmlExportFolder: 'Discourse导出',
// 内容设置
addMetadata: true,
includeImages: true,
embedImages: false, // 将图片嵌入为 Base64(解决手机端图片无法显示问题)
// 下载图片/视频到本地 Vault(通过 Obsidian Local REST API 插件)
downloadImages: false,
downloadVideos: true,
restApiKey: '',
restApiPort: 27124,
mediaFolderName: 'media',
// 评论设置
saveComments: false,
commentCount: 100,
saveAllComments: false, // 与 useFloorRange 互斥
foldComments: false,
// 楼层范围(与 saveAllComments 互斥)
useFloorRange: false,
floorFrom: 1,
floorTo: 100,
// 自定义站点(逗号分隔的域名列表,用于检测不到的自建 Discourse)
customSites: ''
};
function get(key) {
if (key) {
const value = GM_getValue(key, DEFAULT_CONFIG[key]);
console.log(`[Discourse Saver] Config.get('${key}') = '${value}'`);
return value;
}
// 获取全部配置
const config = {};
for (const k in DEFAULT_CONFIG) {
config[k] = GM_getValue(k, DEFAULT_CONFIG[k]);
}
console.log('[Discourse Saver] Config loaded:', JSON.stringify({
vaultName: config.vaultName,
folderPath: config.folderPath,
saveToObsidian: config.saveToObsidian,
useAdvancedUri: config.useAdvancedUri
}));
return config;
}
function set(key, value) {
console.log(`[Discourse Saver] Config.set('${key}', '${value}')`);
GM_setValue(key, value);
}
function setAll(config) {
console.log('[Discourse Saver] Config.setAll:', JSON.stringify({
vaultName: config.vaultName,
folderPath: config.folderPath
}));
for (const k in config) {
GM_setValue(k, config[k]);
}
// 立即验证保存是否成功
const savedVault = GM_getValue('vaultName', '');
console.log(`[Discourse Saver] 验证保存: vaultName = '${savedVault}'`);
}
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;
}
// 验证 URL 是否有效
function isValidUrl(url) {
if (!url || typeof url !== 'string') return false;
try {
// 去除空白字符
url = url.trim();
if (!url) return false;
// 检查是否包含无效字符
if (/[\s<>"{}|\\^`\[\]]/.test(url)) return false;
// 尝试构造 URL 对象
new URL(url);
return true;
} catch (e) {
return false;
}
}
// 构建安全的 URL
function buildSafeUrl(url, baseUrl = window.location.origin) {
if (!url || typeof url !== 'string') return '';
try {
url = url.trim();
if (!url) return '';
// 如果已经是完整 URL
if (url.startsWith('http://') || url.startsWith('https://')) {
return isValidUrl(url) ? url : '';
}
// 相对 URL
if (url.startsWith('/')) {
const fullUrl = baseUrl + url;
return isValidUrl(fullUrl) ? fullUrl : '';
}
// 不是有效的 URL 格式
return '';
} catch (e) {
console.warn('[Discourse Saver] URL 构建失败:', url, e.message);
return '';
}
}
// 清理 HTML 内容,防止崩溃
function sanitizeHtml(html) {
if (!html || typeof html !== 'string') return '';
try {
// 移除可能导致解析问题的字符
html = html.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
// 修复未闭合的标签
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
return tempDiv.innerHTML;
} catch (e) {
console.warn('[Discourse Saver] HTML 清理失败:', e.message);
// 返回纯文本版本
return html.replace(/<[^>]*>/g, '');
}
}
// V5.1: 收集 Markdown 中的媒体文件 URL(图片+视频)
function collectMediaUrls(markdown, downloadVideos) {
const mediaUrls = [];
const seenUrls = new Set();
// 匹配图片 
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
let match;
while ((match = imageRegex.exec(markdown)) !== null) {
const url = match[2];
if (!seenUrls.has(url) && url.startsWith('http')) {
seenUrls.add(url);
mediaUrls.push({ url, type: 'image', alt: match[1] });
}
}
// 匹配视频链接
if (downloadVideos) {
const videoRegex = /\[([^\]]*)\]\((https?:\/\/[^)]+\.(?:mp4|webm|mov|avi|mkv|m4v)[^)]*)\)/gi;
while ((match = videoRegex.exec(markdown)) !== null) {
const url = match[2];
if (!seenUrls.has(url)) {
seenUrls.add(url);
mediaUrls.push({ url, type: 'video', alt: match[1] });
}
}
// 独立的视频 URL 行
const videoLineRegex = /^(https?:\/\/\S+\.(?:mp4|webm|mov|avi|mkv|m4v)\S*)$/gim;
while ((match = videoLineRegex.exec(markdown)) !== null) {
const url = match[1];
if (!seenUrls.has(url)) {
seenUrls.add(url);
mediaUrls.push({ url, type: 'video', alt: '' });
}
}
}
return mediaUrls;
}
// V5.1: 通过 Obsidian Local REST API 下载媒体文件到 Vault 并替换路径
async function downloadAndReplaceMedia(markdown, config, siteFolderPath) {
if (!config.downloadImages) {
return markdown;
}
const mediaUrls = collectMediaUrls(markdown, config.downloadVideos);
if (mediaUrls.length === 0) {
console.log('[Discourse Saver] 没有找到需要下载的媒体文件');
return markdown;
}
console.log(`[Discourse Saver] 找到 ${mediaUrls.length} 个媒体文件,开始通过 REST API 写入...`);
showNotification(`正在下载 ${mediaUrls.length} 个媒体文件到 Vault...`, 'info');
// 媒体文件夹路径:{siteFolderPath}/{mediaFolderName}
const mediaFolderName = config.mediaFolderName || 'media';
const vaultPath = siteFolderPath ? `${siteFolderPath}/${mediaFolderName}` : mediaFolderName;
// 优先使用 HTTP (27123) 避免自签名证书问题
const configPort = config.restApiPort || 27124;
const httpPort = configPort === 27124 ? 27123 : configPort;
const apiBase = `http://127.0.0.1:${httpPort}`;
const results = [];
for (let i = 0; i < mediaUrls.length; i++) {
const media = mediaUrls[i];
try {
// 1. 通过 GM_xmlhttpRequest 获取图片/视频的二进制数据
const binaryData = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: media.url,
responseType: 'arraybuffer',
timeout: 30000,
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
resolve(response.response);
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: function(error) {
reject(new Error('网络错误'));
},
ontimeout: function() {
reject(new Error('下载超时'));
}
});
});
// 2. 从 URL 提取文件名
let fileName;
try {
const urlObj = new URL(media.url);
fileName = urlObj.pathname.split('/').pop() || `media_${i}`;
} catch(e) {
fileName = `media_${i}`;
}
fileName = fileName.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_');
if (!fileName.includes('.')) {
fileName += media.type === 'video' ? '.mp4' : '.jpg';
}
// 去重
const existingNames = results.map(r => r.localName);
let finalName = fileName;
let counter = 1;
while (existingNames.includes(finalName)) {
const dotIdx = fileName.lastIndexOf('.');
if (dotIdx > 0) {
finalName = fileName.substring(0, dotIdx) + `_${counter}` + fileName.substring(dotIdx);
} else {
finalName = fileName + `_${counter}`;
}
counter++;
}
// 3. 通过 REST API 写入 Vault
const filePath = `${vaultPath}/${finalName}`;
const putResult = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'PUT',
url: `${apiBase}/vault/${encodeURIComponent(filePath)}`,
headers: {
'Authorization': `Bearer ${config.restApiKey}`,
'Content-Type': 'application/octet-stream'
},
data: binaryData,
responseType: 'text',
timeout: 30000,
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
resolve(true);
} else {
reject(new Error(`REST API ${response.status}: ${response.responseText}`));
}
},
onerror: function() {
reject(new Error('REST API 连接失败'));
},
ontimeout: function() {
reject(new Error('REST API 超时'));
}
});
});
console.log(`[Discourse Saver] 写入 Vault [${i + 1}/${mediaUrls.length}]: ${filePath}`);
showNotification(`下载媒体文件 ${i + 1}/${mediaUrls.length}...`, 'info');
results.push({
originalUrl: media.url,
localName: finalName,
relativePath: `${mediaFolderName}/${finalName}`,
success: true
});
} catch (dlError) {
console.warn(`[Discourse Saver] 写入媒体失败: ${media.url}`, dlError);
results.push({
originalUrl: media.url,
localName: null,
relativePath: null,
success: false,
error: dlError.message
});
}
}
// 替换 Markdown 中的 URL 为相对路径
let processedMarkdown = markdown;
let successCount = 0;
for (const result of results) {
if (result.success && result.relativePath) {
const escapedUrl = result.originalUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
processedMarkdown = processedMarkdown.replace(
new RegExp(escapedUrl, 'g'),
result.relativePath
);
successCount++;
}
}
console.log(`[Discourse Saver] 媒体路径替换完成: ${successCount}/${mediaUrls.length} 成功`);
if (successCount > 0) {
showNotification(`已下载 ${successCount} 个媒体文件到 Vault`, 'success');
}
return processedMarkdown;
}
// V5.1: 测试 Obsidian Local REST API 连接
async function testRestApiConnection(apiKey, apiPort) {
const httpPort = (apiPort || 27124) === 27124 ? 27123 : apiPort;
const apiBase = `http://127.0.0.1:${httpPort}`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `${apiBase}/`,
headers: {
'Authorization': `Bearer ${apiKey}`
},
timeout: 5000,
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
resolve({ success: true, message: '连接成功' });
} else if (response.status === 401 || response.status === 403) {
resolve({ success: false, message: 'API Key 错误' });
} else {
resolve({ success: false, message: `HTTP ${response.status}` });
}
},
onerror: function() {
resolve({ success: false, message: '无法连接,请确认 Obsidian 已启动且已安装 Local REST API 插件' });
},
ontimeout: function() {
resolve({ success: false, message: '连接超时' });
}
});
});
}
return { getBeijingTime, sanitizeFileName, showNotification, fetchImageAsBase64, embedImagesInMarkdown, isValidUrl, buildSafeUrl, sanitizeHtml, collectMediaUrls, downloadAndReplaceMedia, testRestApiConnection };
})();
// ============================================================
// 模块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;
let fetchErrors = 0;
try {
if (progressCallback) progressCallback('正在获取帖子信息...');
console.log(`[Discourse Saver] API提取评论: topicId=${topicId}, maxCount=${maxCount}, saveAll=${saveAll}`);
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;
console.log(`[Discourse Saver] 帖子流长度: ${totalPosts}`);
if (totalPosts === 0) {
console.log('[Discourse Saver] 帖子流为空,无评论');
return comments;
}
const commentIds = stream.slice(1); // 排除主帖
const targetCount = saveAll ? commentIds.length : Math.min(maxCount, commentIds.length);
const idsToFetch = commentIds.slice(0, targetCount);
console.log(`[Discourse Saver] 需要获取 ${idsToFetch.length} 条评论 (目标: ${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}...`);
}
try {
const postsResponse = await fetch(postsUrl, { credentials: 'include' });
if (!postsResponse.ok) {
console.warn(`[Discourse Saver] 批次 ${Math.floor(i/batchSize)+1} 请求失败: ${postsResponse.status}`);
fetchErrors++;
continue;
}
const postsData = await postsResponse.json();
const posts = postsData.post_stream?.posts || [];
console.log(`[Discourse Saver] 批次 ${Math.floor(i/batchSize)+1}: 获取 ${posts.length} 个帖子`);
for (const post of posts) {
if (post.post_number === 1) continue;
try {
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)
});
} catch (postError) {
console.warn(`[Discourse Saver] 解析帖子 ${post.post_number} 失败:`, postError.message);
}
}
} catch (batchError) {
console.warn(`[Discourse Saver] 批次 ${Math.floor(i/batchSize)+1} 处理失败:`, batchError.message);
fetchErrors++;
}
if (i + batchSize < idsToFetch.length) {
await new Promise(r => setTimeout(r, 100));
}
}
comments.sort((a, b) => parseInt(a.position) - parseInt(b.position));
console.log(`[Discourse Saver] API评论提取完成: 成功 ${comments.length} 条, 失败批次 ${fetchErrors}`);
// 即使有部分失败,也返回已获取的评论
return comments;
} catch (error) {
console.error('[Discourse Saver] API获取评论失败:', error);
// 如果已经获取了一些评论,返回它们而不是抛出错误
if (comments.length > 0) {
console.log(`[Discourse Saver] 部分成功,返回 ${comments.length} 条评论`);
return comments;
}
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: '*'
});
// HTML 表格转 Markdown 表格
turndownService.addRule('tables', {
filter: 'table',
replacement: (content, node) => {
try {
const rows = node.querySelectorAll('tr');
if (rows.length === 0) return content;
const tableData = [];
let maxCols = 0;
// 收集所有行的数据
rows.forEach(row => {
const cells = row.querySelectorAll('th, td');
const rowData = [];
cells.forEach(cell => {
// 获取单元格文本,处理内部链接
let cellText = '';
const links = cell.querySelectorAll('a');
if (links.length > 0) {
// 处理带链接的单元格
const parts = [];
cell.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) {
parts.push(child.textContent.trim());
} else if (child.nodeName === 'A') {
const linkText = child.textContent.trim();
const linkHref = child.href || '';
if (linkHref) {
parts.push(`[${linkText}](${linkHref})`);
} else {
parts.push(linkText);
}
} else {
parts.push(child.textContent.trim());
}
});
cellText = parts.join(' ').trim();
} else {
cellText = cell.textContent.trim();
}
// 清理单元格内容(移除换行、多余空格)
cellText = cellText.replace(/\s+/g, ' ').trim();
rowData.push(cellText);
});
if (rowData.length > maxCols) maxCols = rowData.length;
tableData.push(rowData);
});
if (tableData.length === 0 || maxCols === 0) return content;
// 构建 Markdown 表格
let markdown = '\n\n';
// 第一行作为表头
const headerRow = tableData[0];
while (headerRow.length < maxCols) headerRow.push('');
markdown += '| ' + headerRow.join(' | ') + ' |\n';
// 分隔行
markdown += '| ' + headerRow.map(() => '---').join(' | ') + ' |\n';
// 数据行
for (let i = 1; i < tableData.length; i++) {
const row = tableData[i];
while (row.length < maxCols) row.push('');
markdown += '| ' + row.join(' | ') + ' |\n';
}
markdown += '\n';
console.log(`[Discourse Saver] 转换表格: ${tableData.length} 行, ${maxCols} 列`);
return markdown;
} catch (e) {
console.warn('[Discourse Saver] 表格转换失败:', e.message);
return content;
}
}
});
// 保留颜色样式
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;
// 清理 alt:去掉 |WxH 尺寸标注、末尾数字下划线
const rawAlt = img.getAttribute('data-base62-sha1') || img.alt || '';
const alt = rawAlt.replace(/\|[^\]|]+/g, '').replace(/[_\d]+$/, '').replace(/[\r\n]+/g, ' ').trim() || 'image';
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;
// 清理 alt:去掉 |WxH 尺寸标注、末尾数字下划线
const rawAlt = node.alt || '';
const alt = rawAlt.replace(/\|[^\]|]+/g, '').replace(/[_\d]+$/, '').replace(/[\r\n]+/g, ' ').trim() || 'image';
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, '');
// 清理 Discourse 图片 alt 中的尺寸标注  → 
markdown = markdown.replace(/!\[([^\]]*?)\|[^\]]+\]/g, '![$1]');
// 处理 [spoiler]...[/spoiler] BBCode:保留内容,去掉标签
markdown = markdown.replace(/\[spoiler\]([\s\S]*?)\[\/spoiler\]/gi, (_, content) => {
return '\n\n' + content.trim() + '\n\n';
});
// 处理单独的未闭合 [spoiler] / [/spoiler] 标签
markdown = markdown.replace(/\[\/?spoiler\]/gi, '');
// 移除多余空行
markdown = markdown.replace(/\n{3,}/g, '\n\n');
return markdown;
}
// 转换为带评论的Markdown
function convertToMarkdownWithComments(contentHTML, metadata, comments, config) {
const td = initTurndown();
// 安全地转换主内容
let mainContent = '';
try {
const cleanedMainHtml = UtilModule.sanitizeHtml(contentHTML || '');
mainContent = td.turndown(cleanedMainHtml);
mainContent = cleanupMarkdown(mainContent);
} catch (mainContentError) {
console.error('[Discourse Saver] 主内容转换失败:', mainContentError.message);
// 备选方案:使用纯文本
mainContent = (contentHTML || '').replace(/<[^>]*>/g, '').trim() || '*[主内容转换失败]*';
}
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`;
console.log(`[Discourse Saver] 开始处理 ${comments.length} 条评论`);
let processedCount = 0;
let errorCount = 0;
for (const comment of comments) {
try {
// 清理 HTML 内容,防止解析崩溃
const cleanedHtml = UtilModule.sanitizeHtml(comment.contentHTML || '');
// 安全地转换为 Markdown
let commentContent = '';
try {
commentContent = td.turndown(cleanedHtml);
commentContent = cleanupMarkdown(commentContent);
commentContent = commentContent.trim();
} catch (turndownError) {
console.warn(`[Discourse Saver] Turndown 转换失败 (第${comment.position}楼):`, turndownError.message);
// 备选方案:使用纯文本
commentContent = cleanedHtml.replace(/<[^>]*>/g, '').trim() || '*[内容转换失败]*';
}
// 构建安全的用户 URL
const safeUserUrl = UtilModule.buildSafeUrl(comment.userUrl);
const safeUsername = (comment.username || '用户').replace(/[[\]]/g, '');
const usernameDisplay = safeUserUrl
? `[${safeUsername}](${safeUserUrl})`
: safeUsername;
const usernameDisplayHtml = safeUserUrl
? `${safeUsername}`
: `${safeUsername}`;
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';
}
processedCount++;
} catch (e) {
console.error(`[Discourse Saver] 处理第 ${comment.position || '?'} 楼评论失败:`, e.message);
console.error('[Discourse Saver] 失败评论数据:', {
position: comment.position,
username: comment.username,
contentLength: (comment.contentHTML || '').length,
userUrl: comment.userUrl
});
errorCount++;
// 尝试保存基本信息
const fallbackUsername = (comment.username || '用户').replace(/[[\]<>]/g, '');
const fallbackPosition = comment.position || '?';
markdown += `### ${fallbackPosition}楼 - ${fallbackUsername}\n\n`;
// 尝试保存纯文本内容
try {
const plainText = (comment.contentHTML || '').replace(/<[^>]*>/g, '').trim();
if (plainText) {
markdown += plainText.substring(0, 2000) + '\n\n';
} else {
markdown += `*[评论内容处理失败]*\n\n`;
}
} catch (fallbackError) {
markdown += `*[评论内容处理失败]*\n\n`;
}
}
}
console.log(`[Discourse Saver] 评论处理完成: 成功 ${processedCount} 条, 失败 ${errorCount} 条`);
}
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;
console.log('[Discourse Saver] 评论配置:', {
saveComments: config.saveComments,
saveAllComments: config.saveAllComments,
commentCount: config.commentCount,
useFloorRange: config.useFloorRange
});
if (config.useFloorRange) {
effectiveCommentCount = config.floorTo || 100;
}
const useAPI = effectiveSaveAll || effectiveCommentCount > 30;
console.log(`[Discourse Saver] 使用API: ${useAPI}, topicId: ${topicId}, saveAll: ${effectiveSaveAll}`);
if (useAPI && topicId) {
UtilModule.showNotification('正在通过API加载评论...', 'info');
try {
comments = await ExtractModule.extractCommentsViaAPI(
topicId,
effectiveCommentCount,
effectiveSaveAll,
(msg) => UtilModule.showNotification(msg, 'info')
);
console.log(`[Discourse Saver] API获取评论成功: ${comments.length} 条`);
} catch (apiError) {
console.warn('[Discourse Saver] API获取失败,回退到DOM方式:', apiError);
comments = ExtractModule.extractComments(effectiveCommentCount);
console.log(`[Discourse Saver] DOM获取评论: ${comments.length} 条`);
}
} else {
UtilModule.showNotification('正在提取评论...', 'info');
comments = ExtractModule.extractComments(effectiveCommentCount);
console.log(`[Discourse Saver] DOM提取评论: ${comments.length} 条`);
}
} else {
console.log('[Discourse Saver] 评论保存未启用 (saveComments=false)');
}
// 楼层范围过滤
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(使用 v4.3.8 验证过的方式:始终用剪贴板模式)
async function sendToObsidian(markdown, fileName, config) {
// 读取配置
const vaultName = GM_getValue('vaultName', '') || config.vaultName || '';
const folderPath = GM_getValue('folderPath', 'Discourse收集箱') || config.folderPath || 'Discourse收集箱';
const encode = (str) => encodeURIComponent(str);
console.log('[Discourse Saver] ========== Obsidian 保存 ==========');
console.log('[Discourse Saver] Vault:', vaultName || '(默认)');
console.log('[Discourse Saver] 文件夹:', folderPath);
// 清理文件名(与 v4.3.8 完全一致)
const sanitizedTitle = fileName
.replace(/[《》<>:"/\\|?*]/g, '')
.replace(/\s+/g, '-')
.replace(/^[\s-]+|[\s-]+$/g, '')
.substring(0, 80) || 'Discourse-' + Date.now();
// 构建文件路径(不含 .md 后缀,Advanced URI 会自动加)
const filePath = folderPath ? `${folderPath}/${sanitizedTitle}` : sanitizedTitle;
// 构建 vault 参数
const vaultParam = vaultName && vaultName.trim() !== ''
? 'vault=' + encode(vaultName.trim()) + '&'
: '';
console.log('[Discourse Saver] 文件路径:', filePath + '.md');
console.log('[Discourse Saver] 内容大小:', Math.round(markdown.length / 1024) + 'KB');
// ===== 核心:始终使用剪贴板模式(v4.3.8 验证过的方式)=====
try {
// 步骤1:写入剪贴板
// 优先使用原生 Clipboard API(v4.3.8 方式)
if (navigator.clipboard && navigator.clipboard.writeText) {
console.log('[Discourse Saver] 使用原生 Clipboard API');
await navigator.clipboard.writeText(markdown);
} else {
// 回退到 GM_setClipboard
console.log('[Discourse Saver] 回退到 GM_setClipboard');
GM_setClipboard(markdown, 'text');
}
// 步骤2:构建 Advanced URI(始终用 clipboard=true,不传 data)
let advancedUri = 'obsidian://advanced-uri?' + vaultParam;
advancedUri += 'filepath=' + encode(filePath + '.md') + '&';
advancedUri += 'clipboard=true&';
advancedUri += 'mode=overwrite';
console.log('[Discourse Saver] URI:', advancedUri);
// 步骤3:打开 Obsidian
window.location.href = advancedUri;
return true;
} catch (clipboardError) {
console.error('[Discourse Saver] 剪贴板失败:', clipboardError);
// 备用方案:使用普通 URI(带 content 参数,有长度限制)
console.log('[Discourse Saver] 尝试备用方案...');
const encodedContent = encode(markdown);
if (encodedContent.length > 100000) {
UtilModule.showNotification('内容过大,剪贴板不可用,请手动复制', 'error');
// 提供下载选项
downloadMarkdownFile(markdown, sanitizedTitle, folderPath);
return false;
}
try {
let basicUri = 'obsidian://new?' + vaultParam;
basicUri += 'file=' + encode(filePath) + '&';
basicUri += 'overwrite=true&';
basicUri += 'content=' + encodedContent;
window.location.href = basicUri;
return true;
} catch (e) {
console.error('[Discourse Saver] 备用方案失败:', e);
UtilModule.showNotification('保存失败,已下载文件', 'warning');
downloadMarkdownFile(markdown, sanitizedTitle, folderPath);
return false;
}
}
}
// 备选方案:下载上次大文件(供油猴菜单调用)
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 内容(V5.6.0:5主题切换、PDF导出、代码复制、图片 Lightbox)
function generateHtmlContent(markdown, metadata) {
// 去掉 YAML frontmatter(---...---)
const cleanMarkdown = markdown.replace(/^---\n[\s\S]*?\n---\n?/, '').trimStart();
// 使用 marked.js 转换(未加载则降级为基础转换)
let htmlContent;
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true });
htmlContent = marked.parse(cleanMarkdown);
} else {
let h = cleanMarkdown
.replace(/&/g, '&').replace(//g, '>')
.replace(/^### (.+)$/gm, '$1
')
.replace(/^## (.+)$/gm, '$1
')
.replace(/^# (.+)$/gm, '$1
')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\n\n/g, '').replace(/\n/g, '
');
htmlContent = '
' + h + '
';
}
function esc(t) {
if (!t) return '';
return String(t).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
const exportTime = new Date().toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit',
day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
});
const safeTitle = esc(metadata.title || '');
const tagsHtml = metadata.tags && metadata.tags.length > 0 ? metadata.tags.map(t => esc(t)).join(', ') : '无';
return `
${safeTitle}
${htmlContent}
`;
}
// 获取 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) {
try {
if (response.status >= 200 && response.status < 300) {
const data = JSON.parse(response.responseText);
console.log('[Discourse Saver] Notion 页面创建成功:', data.id);
resolve(data);
} else {
let errorMessage = `HTTP ${response.status}`;
try {
const error = JSON.parse(response.responseText);
console.error('[Discourse Saver] Notion 错误:', error);
errorMessage = error.message || errorMessage;
// 打印失败的块信息便于调试
if (error.code === 'validation_error') {
console.error('[Discourse Saver] 验证错误,前3个块:', JSON.stringify(children.slice(0, 3), null, 2));
}
} catch (parseErr) {
console.error('[Discourse Saver] Notion 响应:', response.responseText);
}
console.error('[Discourse Saver] 请求数据:', { properties, childrenCount: children.length });
reject(new Error(errorMessage));
}
} catch (e) {
console.error('[Discourse Saver] 处理Notion响应失败:', e);
reject(new Error('处理Notion响应失败: ' + e.message));
}
},
onerror: function(error) {
console.error('[Discourse Saver] Notion 网络错误:', error);
reject(new Error('网络请求失败'));
}
});
});
// 如果有超过100个块,分批追加剩余的块
if (children.length > 100) {
const pageId = pageData.id;
const remainingChildren = children.slice(100);
const totalBatches = Math.ceil(remainingChildren.length / 100);
console.log(`[Discourse Saver] 需要追加 ${remainingChildren.length} 个块,分 ${totalBatches} 批`);
let successBatches = 0;
let failedBatches = 0;
// 每批最多100个块
for (let i = 0; i < remainingChildren.length; i += 100) {
const batchNum = Math.floor(i/100) + 1;
const batch = remainingChildren.slice(i, i + 100);
console.log(`[Discourse Saver] 追加第 ${batchNum}/${totalBatches} 批,${batch.length} 个块`);
// 重试机制
let retries = 3;
let success = false;
while (retries > 0 && !success) {
try {
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) {
success = true;
resolve();
} else if (response.status === 429) {
// API 限流,等待后重试
console.warn(`[Discourse Saver] 批次 ${batchNum} API 限流,等待重试...`);
reject(new Error('rate_limited'));
} else {
console.warn(`[Discourse Saver] 批次 ${batchNum} 失败 (${response.status}):`, response.responseText.substring(0, 500));
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: function(error) {
console.warn(`[Discourse Saver] 批次 ${batchNum} 网络错误`);
reject(new Error('network_error'));
}
});
});
} catch (batchError) {
retries--;
if (retries > 0) {
// 等待后重试(限流情况等待更长时间)
const waitTime = batchError.message === 'rate_limited' ? 2000 : 500;
console.log(`[Discourse Saver] 批次 ${batchNum} 重试中... (剩余 ${retries} 次)`);
await new Promise(r => setTimeout(r, waitTime));
}
}
}
if (success) {
successBatches++;
} else {
failedBatches++;
console.error(`[Discourse Saver] 批次 ${batchNum} 最终失败,跳过`);
}
// 避免 API 限流,稍微延迟
if (i + 100 < remainingChildren.length) {
await new Promise(r => setTimeout(r, 400));
}
}
console.log(`[Discourse Saver] 块追加完成: 成功 ${successBatches}/${totalBatches} 批`);
if (failedBatches > 0) {
console.warn(`[Discourse Saver] ${failedBatches} 批追加失败,部分内容可能丢失`);
}
}
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) {
// 确保输入有效
if (!text || typeof text !== 'string') {
return [{ text: { content: ' ' } }];
}
const safeText = text.substring(0, 2000); // Notion 限制
const richText = [];
try {
// 检测链接 [text](url)
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match;
while ((match = linkRegex.exec(safeText)) !== null) {
// 添加链接前的文本
if (match.index > lastIndex) {
const before = safeText.substring(lastIndex, match.index);
if (before && before.trim()) {
const formatted = parseInlineFormatting(before);
if (formatted && formatted.length > 0) {
richText.push(...formatted);
}
}
}
// 处理链接
const linkText = match[1] || '链接';
const linkUrl = normalizeUrl(match[2]);
if (linkUrl) {
richText.push({
text: { content: linkText.substring(0, 2000), link: { url: linkUrl } }
});
} else {
// URL 无效,只显示文本
richText.push({
text: { content: linkText.substring(0, 2000) }
});
}
lastIndex = match.index + match[0].length;
}
// 添加剩余文本
if (lastIndex < safeText.length) {
const remaining = safeText.substring(lastIndex);
if (remaining && remaining.trim()) {
const formatted = parseInlineFormatting(remaining);
if (formatted && formatted.length > 0) {
richText.push(...formatted);
}
}
}
} catch (e) {
console.warn('[Discourse Saver] parseRichText 错误:', e.message);
return [{ text: { content: safeText || ' ' } }];
}
// 确保不返回空数组
if (richText.length === 0) {
return [{ text: { content: safeText || ' ' } }];
}
return richText;
}
// 辅助函数:解析内联格式(加粗、斜体、代码)- 修复顺序问题
function parseInlineFormatting(text) {
if (!text || text.trim() === '') {
return [{ text: { content: ' ' } }]; // Notion 不允许空 rich_text
}
const parts = [];
// 使用正则分割并保持顺序
const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
// 添加匹配前的普通文本
if (match.index > lastIndex) {
const before = text.substring(lastIndex, match.index);
if (before) {
parts.push({ text: { content: before.substring(0, 2000) } });
}
}
const matched = match[1];
// 加粗 **text**
if (matched.startsWith('**') && matched.endsWith('**')) {
const content = matched.slice(2, -2);
if (content) {
parts.push({ text: { content: content.substring(0, 2000) }, annotations: { bold: true } });
}
}
// 行内代码 `text`
else if (matched.startsWith('`') && matched.endsWith('`')) {
const content = matched.slice(1, -1);
if (content) {
parts.push({ text: { content: content.substring(0, 2000) }, annotations: { code: true } });
}
}
// 斜体 *text*
else if (matched.startsWith('*') && matched.endsWith('*')) {
const content = matched.slice(1, -1);
if (content) {
parts.push({ text: { content: content.substring(0, 2000) }, annotations: { italic: true } });
}
}
lastIndex = match.index + match[0].length;
}
// 添加剩余文本
if (lastIndex < text.length) {
const remaining = text.substring(lastIndex);
if (remaining) {
parts.push({ text: { content: remaining.substring(0, 2000) } });
}
}
// 确保不返回空数组
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('```')) {
// Notion 支持的语言映射
const notionLanguageMap = {
'plaintext': 'plain text',
'text': 'plain text',
'txt': 'plain text',
'sh': 'bash',
'shell': 'bash',
'zsh': 'bash',
'ps1': 'powershell',
'ps': 'powershell',
'yml': 'yaml',
'js': 'javascript',
'ts': 'typescript',
'py': 'python',
'rb': 'ruby',
'cs': 'c#',
'cpp': 'c++',
'objc': 'objective-c',
'kt': 'kotlin',
'rs': 'rust',
'md': 'markdown',
'htm': 'html',
'dockerfile': 'docker',
'make': 'makefile',
'asm': 'assembly',
'': 'plain text'
};
const rawLang = line.substring(3).trim().toLowerCase();
const lang = notionLanguageMap[rawLang] || rawLang || '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' ? '⚠️' : '💡' }
}
});
}
}
// HTML 块(折叠评论)- 转换为 Notion toggle 块
else if (line.startsWith('') || line.match(/^')) {
// 提取 summary 内容
const summaryMatch = currentLine.match(/(.+?)<\/summary>/);
if (summaryMatch) {
// 移除 HTML 标签
summaryTitle = summaryMatch[1].replace(/<[^>]+>/g, '').trim() || '展开';
}
i++;
continue;
}
if (currentLine.includes('
')) {
break;
}
if (currentLine.trim()) {
detailsContent += currentLine + '\n';
}
i++;
}
// 创建 toggle 块
const safeTitle = (summaryTitle || '展开').substring(0, 2000);
const safeContent = (detailsContent.trim() || ' ').substring(0, 2000);
blocks.push({
type: 'toggle',
toggle: {
rich_text: [{ text: { content: safeTitle } }],
children: [{
type: 'paragraph',
paragraph: {
rich_text: parseRichText(safeContent)
}
}]
}
});
} catch (detailsError) {
console.warn('[Discourse Saver] details 块解析失败:', detailsError.message);
// 作为普通段落处理
blocks.push({
type: 'paragraph',
paragraph: { rich_text: [{ text: { content: line.replace(/<[^>]+>/g, '') } }] }
});
}
}
// 跳过 结束标签
else if (line.includes(' ')) {
// 已在上面处理,跳过
}
// 引用块
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
}
});
}
// Markdown 表格
else if (line.startsWith('|') && line.endsWith('|')) {
try {
// 收集所有表格行
const tableRows = [];
let tableIndex = i;
while (tableIndex < lines.length) {
const tableLine = lines[tableIndex];
if (tableLine.startsWith('|') && tableLine.endsWith('|')) {
// 跳过分隔行 (|---|---|)
if (!tableLine.match(/^\|[\s\-:]+\|$/)) {
// 解析单元格
const cells = tableLine
.slice(1, -1) // 移除首尾的 |
.split('|')
.map(cell => cell.trim());
if (cells.length > 0) {
tableRows.push(cells);
}
}
tableIndex++;
} else {
break;
}
}
// 如果有有效的表格行,创建 Notion table
if (tableRows.length > 0 && tableRows.some(row => row.length > 0)) {
const columnCount = Math.max(1, ...tableRows.map(row => row.length || 1));
// 创建表格行
const notionRows = tableRows.map((row, rowIndex) => {
// 补齐单元格数量
while (row.length < columnCount) {
row.push('');
}
return {
type: 'table_row',
table_row: {
cells: row.slice(0, columnCount).map(cellContent => {
// 解析单元格内容中的链接和格式
try {
return parseRichText((cellContent || '').substring(0, 2000) || ' ');
} catch (e) {
return [{ text: { content: cellContent || ' ' } }];
}
})
}
};
});
// 创建表格块
blocks.push({
type: 'table',
table: {
table_width: columnCount,
has_column_header: true,
has_row_header: false,
children: notionRows
}
});
// 更新索引,跳过已处理的表格行
i = tableIndex - 1; // -1 因为循环末尾会 i++
console.log(`[Discourse Saver] 解析表格: ${tableRows.length} 行, ${columnCount} 列`);
} else {
// 表格解析失败,作为普通段落处理
blocks.push({
type: 'paragraph',
paragraph: { rich_text: [{ text: { content: line } }] }
});
}
} catch (tableError) {
console.warn('[Discourse Saver] 表格解析失败:', tableError.message);
// 作为普通段落处理
blocks.push({
type: 'paragraph',
paragraph: { rich_text: [{ text: { content: line } }] }
});
}
}
// 分割线
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++;
}
// 辅助函数:验证并修复 rich_text 数组
function validateRichText(richText) {
if (!Array.isArray(richText) || richText.length === 0) {
return [{ text: { content: ' ' } }];
}
const validated = [];
for (const item of richText) {
try {
if (!item || typeof item !== 'object') {
validated.push({ text: { content: ' ' } });
continue;
}
if (!item.text || typeof item.text !== 'object') {
validated.push({ text: { content: ' ' } });
continue;
}
// 确保 content 是字符串且不为空
let content = item.text.content;
if (content === null || content === undefined || content === '') {
content = ' ';
} else if (typeof content !== 'string') {
content = String(content);
}
// Notion 限制内容长度为 2000
content = content.substring(0, 2000);
const validItem = {
text: { content: content }
};
// 验证链接
if (item.text.link && item.text.link.url) {
try {
const url = item.text.link.url.trim();
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
new URL(url); // 验证 URL 格式
validItem.text.link = { url: url };
}
} catch (urlError) {
// URL 无效,不添加链接
}
}
// 保留格式注解
if (item.annotations && typeof item.annotations === 'object') {
validItem.annotations = {};
if (item.annotations.bold === true) validItem.annotations.bold = true;
if (item.annotations.italic === true) validItem.annotations.italic = true;
if (item.annotations.code === true) validItem.annotations.code = true;
if (item.annotations.strikethrough === true) validItem.annotations.strikethrough = true;
if (item.annotations.underline === true) validItem.annotations.underline = true;
}
validated.push(validItem);
} catch (itemError) {
console.warn('[Discourse Saver] rich_text 项验证失败:', itemError.message);
validated.push({ text: { content: ' ' } });
}
}
return validated.length > 0 ? validated : [{ text: { content: ' ' } }];
}
// 过滤并验证所有块,确保每个块都有效
const validBlocks = blocks.filter(block => {
try {
if (!block || !block.type) return false;
// 检查需要 rich_text 的块类型
const richTextTypes = ['paragraph', 'heading_1', 'heading_2', 'heading_3',
'bulleted_list_item', 'numbered_list_item', 'quote',
'to_do', 'callout', 'code'];
if (richTextTypes.includes(block.type)) {
const content = block[block.type];
if (!content) return false;
// 检查并修复 rich_text
if (content.rich_text) {
content.rich_text = validateRichText(content.rich_text);
}
}
// 检查图片/视频/PDF 块
if (['image', 'video', 'pdf'].includes(block.type)) {
const content = block[block.type];
if (!content || !content.external || !content.external.url) {
return false;
}
}
// 检查书签块
if (block.type === 'bookmark') {
if (!block.bookmark || !block.bookmark.url) {
return false;
}
}
// 检查表格块
if (block.type === 'table') {
if (!block.table || !block.table.children || block.table.children.length === 0) {
return false;
}
// 验证每个表格行
const tableWidth = block.table.table_width || 1;
block.table.children = block.table.children.filter(row => {
if (!row || row.type !== 'table_row' || !row.table_row || !row.table_row.cells) {
return false;
}
// 确保每行有正确数量的单元格,每个单元格都是有效的 rich_text
while (row.table_row.cells.length < tableWidth) {
row.table_row.cells.push([{ text: { content: ' ' } }]);
}
row.table_row.cells = row.table_row.cells.slice(0, tableWidth).map(cell => {
return validateRichText(cell);
});
return true;
});
if (block.table.children.length === 0) return false;
}
// 检查 toggle 块
if (block.type === 'toggle') {
if (!block.toggle) return false;
block.toggle.rich_text = validateRichText(block.toggle.rich_text);
// 验证 children
if (block.toggle.children && Array.isArray(block.toggle.children)) {
block.toggle.children = block.toggle.children.filter(child => {
if (!child || !child.type) return false;
if (child.type === 'paragraph' && child.paragraph) {
child.paragraph.rich_text = validateRichText(child.paragraph.rich_text);
}
return true;
});
}
}
return true;
} catch (e) {
console.warn('[Discourse Saver] 块验证失败:', e.message);
return false;
}
});
console.log(`[Discourse Saver] Notion 块: 总计 ${blocks.length}, 有效 ${validBlocks.length}`);
return validBlocks;
}
// 测试 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('网络请求失败'));
}
});
});
}
// ============================================================
// 飞书多维表格功能(从 background.js 移植,使用 GM_xmlhttpRequest)
// ============================================================
// GM_xmlhttpRequest 的 fetch 兼容包装(用于跨域请求)
function gmFetch(url, options = {}) {
return new Promise((resolve, reject) => {
const details = {
method: options.method || 'GET',
url: url,
headers: options.headers || {},
timeout: 30000,
onload: function(resp) {
const text = resp.responseText;
const status = resp.status;
resolve({
ok: status >= 200 && status < 300,
status: status,
text: () => Promise.resolve(text),
json: () => {
try { return Promise.resolve(JSON.parse(text)); }
catch(e) { return Promise.reject(e); }
}
});
},
onerror: function() { reject(new Error('网络请求失败: ' + url)); },
ontimeout: function() { reject(new Error('请求超时: ' + url)); }
};
if (options.body) {
details.data = options.body;
}
GM_xmlhttpRequest(details);
});
}
const FEISHU_API_DOMAINS = { feishu: 'https://open.feishu.cn', lark: 'https://open.larksuite.com' };
const FEISHU_TEXT_LIMIT = 100000;
let feishuTokenCache = { feishu: { token: null, expireTime: 0 }, lark: { token: null, expireTime: 0 } };
const FEISHU_ERR = {
10003: { msg: 'App ID 或 App Secret 错误', hint: '请检查飞书开放平台的应用凭证是否正确复制' },
10014: { msg: 'App Secret 错误', hint: '请重新复制 App Secret' },
99991663: { msg: 'App ID 格式错误', hint: 'App ID 应该以「cli_」开头' },
99991664: { msg: 'App Secret 格式错误', hint: 'App Secret 格式不正确,请重新复制' },
1254043: { msg: '应用权限不足', hint: '请在飞书开放平台添加 bitable:app 权限,并将应用添加为多维表格的协作者' },
1254044: { msg: '无访问此文档的权限', hint: '请确保已将应用添加为多维表格的「可编辑」协作者' },
1254045: { msg: '文档不存在或无权限', hint: '请检查 app_token 是否正确' },
1254607: { msg: '数据表不存在', hint: 'table_id 错误,请从 URL「?table=」后面复制(以 tbl 开头)' },
1254301: { msg: '多维表格不存在', hint: 'app_token 错误,请从 URL「/base/」后到「?」之间复制' },
1254016: { msg: '字段不存在', hint: '请确保多维表格有:标题、链接、作者、分类、标签、保存时间、评论数、附件、正文 字段' },
1254017: { msg: '字段类型不匹配', hint: '链接字段必须是「超链接」类型,保存时间必须是「日期」类型,评论数必须是「数字」类型' },
1254060: { msg: '多行文本字段格式错误', hint: '「正文」字段类型需为「多行文本」,或内容超过10万字符' },
99991400: { msg: 'API 调用频率超限', hint: '请稍后再试' }
};
function feishuParseError(code, msg, ctx) {
const e = FEISHU_ERR[code];
return e ? `${ctx}失败:${e.msg}\n💡 ${e.hint}` : `${ctx}失败:${msg}(错误码: ${code})`;
}
async function feishuSafeJson(response, ctx) {
const text = await response.text();
if (!text || text.trim() === '') throw new Error(`${ctx}失败:服务器返回空响应`);
if (!response.ok) {
try {
const d = JSON.parse(text);
if (d.code !== undefined) throw new Error(feishuParseError(d.code, d.msg || '未知错误', ctx));
} catch(pe) {
if (pe.message.includes('失败:')) throw pe;
}
throw new Error(`${ctx}失败:HTTP ${response.status}`);
}
try { return JSON.parse(text); }
catch(e) { throw new Error(`${ctx}失败:无法解析服务器响应\n${text.substring(0, 100)}`); }
}
async function getFeishuToken(appId, appSecret, domain = 'feishu') {
if (!appId || !appId.trim()) throw new Error('App ID 未填写');
if (!appSecret || !appSecret.trim()) throw new Error('App Secret 未填写');
if (!appId.startsWith('cli_')) throw new Error(`App ID 格式错误,应以「cli_」开头`);
const cache = feishuTokenCache[domain] || feishuTokenCache.feishu;
if (cache.token && Date.now() < cache.expireTime - 300000) return cache.token;
const baseUrl = FEISHU_API_DOMAINS[domain] || FEISHU_API_DOMAINS.feishu;
let response;
try {
response = await gmFetch(`${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ app_id: appId.trim(), app_secret: appSecret.trim() })
});
} catch(err) {
throw new Error(`无法访问飞书服务器,请检查网络:${err.message}`);
}
const data = await feishuSafeJson(response, '获取访问令牌');
if (data.code !== 0) throw new Error(feishuParseError(data.code, data.msg, '获取访问令牌'));
if (!feishuTokenCache[domain]) feishuTokenCache[domain] = {};
feishuTokenCache[domain].token = data.tenant_access_token;
feishuTokenCache[domain].expireTime = Date.now() + (data.expire * 1000);
return data.tenant_access_token;
}
function feishuSanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
let s = content.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
s = s.replace(/[\u200B-\u200D\uFEFF\u2028\u2029]/g, '');
s = s.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
s = s.replace(/!\[([^\]]*)\]\([^)]+\)/g, (m, alt) => alt.trim() ? `[图片: ${alt.trim()}]` : '');
s = s.replace(/\[([^\]]*)\]\(([^)]+)\)/g, (m, text, url) => text.trim() || '');
s = s.replace(/\n{3,}/g, '\n\n');
s = s.replace(/[ \t]+/g, ' ');
s = s.replace(/^ +| +$/gm, '');
if (s.length > FEISHU_TEXT_LIMIT) {
s = s.substring(0, FEISHU_TEXT_LIMIT - 100) + '\n\n... (内容过长,已截断)';
}
return s;
}
async function feishuUploadMd(token, appToken, title, content, domain) {
const safeTitle = title.replace(/[《》<>:"/\\|?*]/g, '').replace(/\s+/g, '-').substring(0, 50);
const fileName = `${safeTitle}.md`;
const blob = new Blob([content], { type: 'text/markdown' });
const formData = new FormData();
formData.append('file', blob, fileName);
formData.append('file_name', fileName);
formData.append('parent_type', 'bitable_file');
formData.append('parent_node', appToken);
formData.append('size', blob.size.toString());
const baseUrl = FEISHU_API_DOMAINS[domain] || FEISHU_API_DOMAINS.feishu;
const response = await gmFetch(`${baseUrl}/open-apis/drive/v1/medias/upload_all`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await feishuSafeJson(response, '上传MD文件');
if (data.code !== 0) throw new Error(`上传MD文件失败: ${data.msg}`);
return data.data.file_token;
}
async function feishuUploadHtmlFile(token, appToken, title, htmlContent, domain) {
const safeTitle = title.replace(/[《》<>:"/\\|?*]/g, '').replace(/\s+/g, '-').substring(0, 50);
const fileName = `${safeTitle}.html`;
const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
const formData = new FormData();
formData.append('file', blob, fileName);
formData.append('file_name', fileName);
formData.append('parent_type', 'bitable_file');
formData.append('parent_node', appToken);
formData.append('size', blob.size.toString());
const baseUrl = FEISHU_API_DOMAINS[domain] || FEISHU_API_DOMAINS.feishu;
const response = await gmFetch(`${baseUrl}/open-apis/drive/v1/medias/upload_all`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
const data = await feishuSafeJson(response, '上传HTML文件');
if (data.code !== 0) throw new Error(`上传HTML文件失败: ${data.msg}`);
return data.data.file_token;
}
async function feishuBuildAndSaveRecord(token, appToken, tableId, domain, postData, uploadContent, uploadAttachment, uploadHtml = false, isUpdate = false, recordId = null) {
const baseUrl = FEISHU_API_DOMAINS[domain] || FEISHU_API_DOMAINS.feishu;
const fields = {
'标题': postData.title,
'链接': { link: postData.url, text: postData.title },
'作者': postData.author,
'分类': postData.category || '',
'标签': postData.tags && postData.tags.length > 0 ? postData.tags.join(', ') : '',
'保存时间': Date.now(),
'评论数': postData.commentCount || 0
};
const attachments = [];
const uploadErrors = [];
if (uploadAttachment && postData.content) {
try {
const fileToken = await feishuUploadMd(token, appToken, postData.title, postData.content, domain);
attachments.push({ file_token: fileToken });
} catch(e) {
uploadErrors.push('MD附件: ' + e.message);
}
}
if (uploadHtml && postData.content && postData.metadata) {
try {
const htmlContent = generateHtmlContent(postData.content, postData.metadata);
const fileToken = await feishuUploadHtmlFile(token, appToken, postData.title, htmlContent, domain);
attachments.push({ file_token: fileToken });
} catch(e) {
uploadErrors.push('HTML附件: ' + e.message);
}
}
if (attachments.length > 0) fields['附件'] = attachments;
if (uploadContent !== false) {
fields['正文'] = feishuSanitizeContent(postData.content);
}
const url = isUpdate
? `${baseUrl}/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`
: `${baseUrl}/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records`;
const response = await gmFetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ fields })
});
const data = await feishuSafeJson(response, isUpdate ? '更新记录' : '保存记录');
if (data.code !== 0) throw new Error(feishuParseError(data.code, data.msg, isUpdate ? '更新记录' : '保存记录'));
const result = data.data.record;
if (uploadErrors.length > 0) result._uploadWarnings = uploadErrors;
return result;
}
async function feishuFindRecord(config, url, title) {
const { feishuApiDomain, feishuAppId, feishuAppSecret, feishuAppToken, feishuTableId } = config;
const domain = feishuApiDomain || 'feishu';
const token = await getFeishuToken(feishuAppId, feishuAppSecret, domain);
const baseUrl = FEISHU_API_DOMAINS[domain] || FEISHU_API_DOMAINS.feishu;
const baseTitle = title.replace(/\s*\[\d+楼\]$/, '');
const response = await gmFetch(`${baseUrl}/open-apis/bitable/v1/apps/${feishuAppToken}/tables/${feishuTableId}/records/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({
filter: { conjunction: 'and', conditions: [{ field_name: '标题', operator: 'contains', value: [baseTitle] }] },
page_size: 20
})
});
const data = await feishuSafeJson(response, '搜索记录');
if (data.code !== 0) throw new Error(`飞书搜索失败(${data.code}): ${data.msg}`);
if (data.data.total > 0 && data.data.items) {
for (const item of data.data.items) {
const recordLink = item.fields?.['链接'];
const recordUrl = typeof recordLink === 'object' ? recordLink.link : recordLink;
if (recordUrl === url) return item;
}
}
return null;
}
async function saveToFeishu(markdown, metadata, config) {
const domain = config.feishuApiDomain || 'feishu';
if (!config.feishuAppId || !config.feishuAppSecret || !config.feishuAppToken || !config.feishuTableId) {
throw new Error('飞书配置不完整,请检查 App ID/Secret/Token/Table ID');
}
const token = await getFeishuToken(config.feishuAppId, config.feishuAppSecret, domain);
const postData = {
title: metadata.title,
url: metadata.url,
author: metadata.author || '',
category: metadata.category || '',
tags: metadata.tags || [],
commentCount: metadata.commentCount || 0,
content: markdown,
metadata: metadata // 用于生成 HTML 附件
};
const uploadHtml = !!config.feishuUploadHtml;
// 搜索是否已存在,决定新建还是更新
let existingRecord = null;
try {
existingRecord = await feishuFindRecord(config, metadata.url, metadata.title);
} catch(e) {
console.warn('[Discourse Saver→飞书] 搜索已有记录失败,将新建:', e.message);
}
if (existingRecord) {
console.log('[Discourse Saver→飞书] 更新已有记录:', existingRecord.record_id);
return await feishuBuildAndSaveRecord(token, config.feishuAppToken, config.feishuTableId, domain, postData,
config.feishuUploadContent !== false, config.feishuUploadAttachment, uploadHtml, true, existingRecord.record_id);
} else {
console.log('[Discourse Saver→飞书] 新建记录');
return await feishuBuildAndSaveRecord(token, config.feishuAppToken, config.feishuTableId, domain, postData,
config.feishuUploadContent !== false, config.feishuUploadAttachment, uploadHtml, false);
}
}
async function testFeishuConnection(appId, appSecret, domain) {
try {
const token = await getFeishuToken(appId, appSecret, domain || 'feishu');
return { success: true, token: token.substring(0, 8) + '...' };
} catch(e) {
return { success: false, error: e.message };
}
}
// 独立导出:仅保存到飞书
async function saveToFeishuOnly(targetPostNumber = null) {
try {
const config = ConfigModule.get();
if (!config.feishuAppId || !config.feishuAppSecret || !config.feishuAppToken || !config.feishuTableId) {
throw new Error('请先配置飞书 App ID/Secret/Token/Table ID');
}
UtilModule.showNotification('正在准备保存到飞书...', 'info');
const { markdown, metadata, comments } = await prepareData(targetPostNumber);
await saveToFeishu(markdown, metadata, config);
const commentInfo = comments.length > 0 ? `(含${comments.length}条评论)` : '';
UtilModule.showNotification(`已保存到飞书${commentInfo}`, 'success');
return { success: true, target: '飞书' };
} catch (error) {
console.error('[Discourse Saver] 飞书保存失败:', error);
UtilModule.showNotification('飞书保存失败: ' + error.message, 'error');
return { success: false, target: '飞书', error: error.message };
}
}
// 准备保存数据(公共函数)
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
);
// rawMarkdown:外链版本,用于飞书/Notion/HTML
const rawMarkdown = markdown;
let fileName = UtilModule.sanitizeFileName(title);
let displayTitle = title;
if (isSingleCommentMode) {
fileName += `-${targetPostNumber}楼`;
displayTitle = `${title} #${targetPostNumber}楼`;
}
const metadata = { title: displayTitle, url, author, category, tags, commentCount: comments.length };
return { markdown: rawMarkdown, rawMarkdown, fileName, metadata, comments, config, isSingleCommentMode, targetPostNumber };
}
// 独立导出:仅保存到 Obsidian
async function saveToObsidianOnly(targetPostNumber = null) {
try {
UtilModule.showNotification('正在准备保存到 Obsidian...', 'info');
const { rawMarkdown, fileName, comments, config } = await prepareData(targetPostNumber);
let obsidianMarkdown = rawMarkdown;
if (config.embedImages) {
try {
const result = await UtilModule.embedImagesInMarkdown(rawMarkdown);
if (result && result.length > 0) obsidianMarkdown = result;
} catch (e) { console.error('[Discourse Saver] 图片嵌入失败,使用外链:', e); }
} else if (config.downloadImages && config.restApiKey) {
try {
const siteName = window.location.hostname.replace(/\./g, '_');
const siteFolderPath = config.folderPath ? `${config.folderPath}/${siteName}` : siteName;
obsidianMarkdown = await UtilModule.downloadAndReplaceMedia(rawMarkdown, config, siteFolderPath);
} catch (e) { console.error('[Discourse Saver] 媒体下载失败,使用外链:', e); }
}
await sendToObsidian(obsidianMarkdown, 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.saveToFeishu && !config.exportHtml) {
UtilModule.showNotification('请在设置中至少选择一个保存目标', 'warning');
return;
}
// 提取数据(只提取一次)
UtilModule.showNotification('正在提取内容...', 'info');
const { rawMarkdown, fileName, metadata, comments } = await prepareData(targetPostNumber);
// 构建任务列表(Obsidian 单独处理,因为会跳转页面)
const tasks = [];
const taskNames = [];
let shouldSaveToObsidian = false;
// Notion 和 HTML 先执行(不会跳转页面),始终使用外链版 rawMarkdown
if (config.saveToNotion && config.notionToken && config.notionDatabaseId) {
tasks.push(
saveToNotion(rawMarkdown, 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(rawMarkdown, metadata, fileName, config);
return { success: true, target: 'HTML' };
})
.catch(e => ({ success: false, target: 'HTML', error: e.message }))
);
taskNames.push('HTML');
}
if (config.saveToFeishu && config.feishuAppId && config.feishuAppSecret && config.feishuAppToken && config.feishuTableId) {
tasks.push(
saveToFeishu(rawMarkdown, metadata, config)
.then(() => ({ success: true, target: '飞书' }))
.catch(e => ({ success: false, target: '飞书', error: e.message }))
);
taskNames.push('飞书');
}
// 记录是否需要保存到 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');
}
// OB 专属图片处理(embedImages / downloadImages),失败则 fallback 外链
let obsidianMarkdown = rawMarkdown;
if (config.embedImages) {
try {
UtilModule.showNotification('正在嵌入图片...', 'info');
const result = await UtilModule.embedImagesInMarkdown(rawMarkdown, (c, t) => {
UtilModule.showNotification(`正在嵌入图片 ${c}/${t}...`, 'info');
});
if (result && result.length > 0) obsidianMarkdown = result;
} catch (e) {
console.error('[Discourse Saver] 图片嵌入失败,使用外链:', e);
UtilModule.showNotification('图片嵌入失败,使用外链', 'warning');
}
} else if (config.downloadImages && config.restApiKey) {
try {
const siteName = window.location.hostname.replace(/\./g, '_');
const siteFolderPath = config.folderPath ? `${config.folderPath}/${siteName}` : siteName;
obsidianMarkdown = await UtilModule.downloadAndReplaceMedia(rawMarkdown, config, siteFolderPath);
} catch (e) {
console.error('[Discourse Saver] 媒体下载失败,使用外链:', e);
UtilModule.showNotification('媒体下载失败,使用外链', 'warning');
}
}
// 等待一段时间让用户看到通知
await new Promise(resolve => setTimeout(resolve, 1500));
try {
await sendToObsidian(obsidianMarkdown, 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
saveToFeishuOnly, // 仅保存到飞书
exportHtmlOnly, // 仅导出 HTML
testNotionConnection, // 测试 Notion 连接
testFeishuConnection, // 测试飞书连接
downloadLastLargeFile // 备选:下载大文件
};
})();
// ============================================================
// 模块6: 用户界面 (UIModule)
// ============================================================
const UIModule = (function() {
// 注入样式
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;
}
`);
}
// V5.5.3: 悬浮保存按钮(支持拖拽、移动端)
function createFloatingButton() {
if (document.getElementById('ds-float-btn')) return;
const btn = document.createElement('div');
btn.id = 'ds-float-btn';
btn.title = '保存帖子(长按更多选项)';
btn.innerHTML = '';
const savedPos = (() => { try { return JSON.parse(GM_getValue('ds_float_pos', 'null')); } catch(e) { return null; } })();
Object.assign(btn.style, {
position: 'fixed', zIndex: '999999',
right: savedPos ? 'auto' : '18px', bottom: savedPos ? 'auto' : '80px',
left: savedPos ? savedPos.left : 'auto', top: savedPos ? savedPos.top : 'auto',
width: '48px', height: '48px', borderRadius: '50%',
background: 'linear-gradient(135deg,#667eea,#764ba2)',
boxShadow: '0 4px 15px rgba(102,126,234,.55)',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
userSelect: 'none', touchAction: 'none', transition: 'transform .1s,box-shadow .1s',
});
let dragging = false, moved = false, longPressed = false, startX, startY, startL, startT, longTimer;
function gp(e) { const t = e.touches ? e.touches[0] : e; return { x: t.clientX, y: t.clientY }; }
function onStart(e) {
dragging = true; moved = false; longPressed = false;
const p = gp(e); startX = p.x; startY = p.y;
const r = btn.getBoundingClientRect(); startL = r.left; startT = r.top;
btn.style.transition = 'none';
longTimer = setTimeout(() => {
if (!moved) { longPressed = true; showFloatMenu(); }
}, 600);
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
const p = gp(e), dx = p.x - startX, dy = p.y - startY;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { moved = true; clearTimeout(longTimer); }
if (!moved) return;
btn.style.left = Math.max(0, Math.min(window.innerWidth - 48, startL + dx)) + 'px';
btn.style.top = Math.max(0, Math.min(window.innerHeight - 48, startT + dy)) + 'px';
btn.style.right = btn.style.bottom = 'auto';
e.preventDefault();
}
function onEnd() {
if (!dragging) return;
dragging = false; clearTimeout(longTimer);
btn.style.transition = 'transform .1s,box-shadow .1s';
if (moved) {
GM_setValue('ds_float_pos', JSON.stringify({ left: btn.style.left, top: btn.style.top }));
} else if (!longPressed) {
btn.style.transform = 'scale(.88)'; setTimeout(() => btn.style.transform = '', 120);
SaveModule.save(null);
}
moved = false; longPressed = false;
}
btn.addEventListener('mousedown', onStart);
btn.addEventListener('touchstart', onStart, { passive: false });
window.addEventListener('mousemove', onMove);
window.addEventListener('touchmove', onMove, { passive: false });
window.addEventListener('mouseup', onEnd);
window.addEventListener('touchend', onEnd);
document.body.appendChild(btn);
}
function showFloatMenu() {
const ex = document.getElementById('ds-float-menu');
if (ex) { ex.remove(); return; }
const btn = document.getElementById('ds-float-btn');
const r = btn.getBoundingClientRect();
const menu = document.createElement('div');
menu.id = 'ds-float-menu';
// 解析楼层字符串:"5" / "2,5,8" / "2-8" / "1-5,8,10-12"
function parseFloors(str) {
const floors = new Set();
for (const part of str.replace(/\s/g, '').split(',')) {
if (!part) continue;
const m = part.match(/^(\d+)-(\d+)$/);
if (m) {
const a = +m[1], b = +m[2];
if (a > b || b - a > 500) return null;
for (let i = a; i <= b; i++) floors.add(i);
} else if (/^\d+$/.test(part)) {
floors.add(+part);
} else return null;
}
return floors.size > 0 ? Array.from(floors).sort((a, b) => a - b) : null;
}
menu.innerHTML = `
Discourse Saver
💾 保存整个帖子
指定楼层(如: 5 或 2-8 或 1,3,5-7)
`;
// 定位:按钮左侧弹出,不够则右侧
const menuW = 220;
let left = r.left - menuW - 10;
let top = r.top;
if (left < 8) left = r.right + 10;
if (top + 180 > window.innerHeight) top = window.innerHeight - 185;
Object.assign(menu.style, {
position: 'fixed', zIndex: '1000000', left: left + 'px', top: top + 'px',
background: '#fff', borderRadius: '12px', boxShadow: '0 8px 32px rgba(0,0,0,.18)',
width: menuW + 'px', fontSize: '14px',
});
document.body.appendChild(menu);
function closeMenu() { menu.remove(); document.removeEventListener('click', outsideClick); }
function outsideClick(e) { if (!menu.contains(e.target) && e.target !== btn) closeMenu(); }
// 保存整帖
menu.querySelector('#dsm-save-all').addEventListener('click', () => { closeMenu(); SaveModule.save(null); });
menu.querySelector('#dsm-save-all').addEventListener('pointerover', e => e.currentTarget.style.background = '#f5f5f5');
menu.querySelector('#dsm-save-all').addEventListener('pointerout', e => e.currentTarget.style.background = '');
// 楼层输入实时提示
const input = menu.querySelector('#dsm-floor-input');
const hint = menu.querySelector('#dsm-floor-hint');
input.addEventListener('input', () => {
const v = input.value.trim();
if (!v) { hint.textContent = ''; return; }
const floors = parseFloors(v);
if (!floors) { hint.style.color = '#ef4444'; hint.textContent = '格式错误'; }
else { hint.style.color = '#22c55e'; hint.textContent = `共 ${floors.length} 楼: ${floors.slice(0, 6).join(', ')}${floors.length > 6 ? '...' : ''}`; }
});
// 执行楼层保存
function doFloorSave() {
const v = input.value.trim();
if (!v) { input.style.borderColor = '#ef4444'; input.focus(); return; }
const floors = parseFloors(v);
if (!floors) { input.style.borderColor = '#ef4444'; hint.style.color = '#ef4444'; hint.textContent = '格式错误'; return; }
closeMenu();
if (floors.length === 1) {
SaveModule.save(floors[0] === 1 ? null : String(floors[0]));
} else {
SaveModule.save(floors);
}
}
menu.querySelector('#dsm-floor-go').addEventListener('click', doFloorSave);
input.addEventListener('keydown', e => { if (e.key === 'Enter') doFloorSave(); if (e.key === 'Escape') closeMenu(); });
setTimeout(() => { document.addEventListener('click', outsideClick); input.focus(); }, 100);
}
// 显示设置面板
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-test-feishu').addEventListener('click', async () => {
const appId = overlay.querySelector('#ds-feishu-app-id').value.trim();
const appSecret = overlay.querySelector('#ds-feishu-app-secret').value.trim();
const domain = overlay.querySelector('#ds-feishu-domain').value;
const statusEl = overlay.querySelector('#ds-feishu-status');
if (!appId || !appSecret) {
statusEl.textContent = '请先填写 App ID 和 App Secret';
statusEl.style.color = '#ef4444';
return;
}
statusEl.textContent = '测试中...';
statusEl.style.color = '#6b7280';
try {
const result = await SaveModule.testFeishuConnection(appId, appSecret, domain);
if (result.success) {
statusEl.textContent = '连接成功,Token 已获取';
statusEl.style.color = '#22c55e';
} else {
statusEl.textContent = '连接失败: ' + result.error;
statusEl.style.color = '#ef4444';
}
} catch (e) {
statusEl.textContent = '测试出错: ' + e.message;
statusEl.style.color = '#ef4444';
}
});
// 下载图片 checkbox 显隐
overlay.querySelector('#ds-download-images').addEventListener('change', (e) => {
overlay.querySelector('#ds-download-images-panel').style.display = e.target.checked ? '' : 'none';
});
// 测试 REST API 连接
overlay.querySelector('#ds-test-rest-api').addEventListener('click', async () => {
const statusEl = overlay.querySelector('#ds-rest-api-status');
const apiKey = overlay.querySelector('#ds-rest-api-key').value.trim();
const apiPort = parseInt(overlay.querySelector('#ds-rest-api-port').value) || 27124;
statusEl.textContent = '正在测试...';
statusEl.style.color = '#6b7280';
try {
const result = await UtilModule.testRestApiConnection(apiKey, apiPort);
if (result.success) {
statusEl.textContent = '连接成功';
statusEl.style.color = '#10b981';
} else {
statusEl.textContent = result.message;
statusEl.style.color = '#ef4444';
}
} catch (e) {
statusEl.textContent = '测试失败: ' + e.message;
statusEl.style.color = '#ef4444';
}
});
// OB 测试按钮(使用 v4.3.8 验证过的剪贴板方式)
overlay.querySelector('#ds-test-ob').addEventListener('click', async () => {
const vaultName = overlay.querySelector('#ds-vault').value.trim();
const folderPath = overlay.querySelector('#ds-folder').value.trim() || 'Discourse收集箱';
const encode = (str) => encodeURIComponent(str);
console.log('[Discourse Saver] OB 测试: vault=' + vaultName + ', folder=' + folderPath);
// 测试内容
const testContent = '# Discourse Saver 测试\n\n如果你看到这个文件,说明连接成功!\n\n时间: ' + new Date().toISOString();
try {
// 步骤1:写入剪贴板(v4.3.8 方式)
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(testContent);
console.log('[Discourse Saver] 测试内容已写入剪贴板 (原生API)');
} else {
GM_setClipboard(testContent, 'text');
console.log('[Discourse Saver] 测试内容已写入剪贴板 (GM API)');
}
// 步骤2:构建 URI(始终用 clipboard=true)
const vaultParam = vaultName ? 'vault=' + encode(vaultName) + '&' : '';
const testUri = 'obsidian://advanced-uri?' + vaultParam +
'filepath=' + encode(folderPath + '/DS-测试文件.md') + '&' +
'clipboard=true&' +
'mode=overwrite';
console.log('[Discourse Saver] 测试 URI:', testUri);
alert('测试内容已复制到剪贴板。\n\n即将打开 Obsidian,如果成功会在 "' + folderPath + '" 文件夹中看到 "DS-测试文件.md"');
window.location.href = testUri;
} catch (e) {
console.error('[Discourse Saver] 测试失败:', e);
alert('测试失败: ' + e.message + '\n\n请确保浏览器允许剪贴板访问。');
}
});
// 取消按钮
overlay.querySelector('#ds-cancel').addEventListener('click', () => {
overlay.remove();
});
// 导出配置
overlay.querySelector('#ds-export-config').addEventListener('click', () => {
const cfg = ConfigModule.get();
const json = JSON.stringify(cfg, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'discourse-saver-config.json';
a.click();
URL.revokeObjectURL(url);
UtilModule.showNotification('配置已导出', 'success');
});
// 导入配置
overlay.querySelector('#ds-import-config').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imported = JSON.parse(ev.target.result);
ConfigModule.setAll(imported);
overlay.remove();
UtilModule.showNotification('配置已导入,重新打开设置即可看到', 'success');
} catch (err) {
UtilModule.showNotification('导入失败:JSON 格式不正确', 'error');
}
};
reader.readAsText(file);
});
// 保存按钮
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,
saveToFeishu: overlay.querySelector('#ds-save-feishu').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,
// 下载图片到本地
downloadImages: overlay.querySelector('#ds-download-images').checked,
downloadVideos: overlay.querySelector('#ds-download-videos').checked,
restApiKey: overlay.querySelector('#ds-rest-api-key').value.trim(),
restApiPort: parseInt(overlay.querySelector('#ds-rest-api-port').value) || 27124,
mediaFolderName: overlay.querySelector('#ds-media-folder-name').value.trim() || 'media',
// Notion设置
notionToken: overlay.querySelector('#ds-notion-token').value.trim(),
notionDatabaseId: overlay.querySelector('#ds-notion-db').value.trim(),
// 飞书设置
feishuApiDomain: overlay.querySelector('#ds-feishu-domain').value,
feishuAppId: overlay.querySelector('#ds-feishu-app-id').value.trim(),
feishuAppSecret: overlay.querySelector('#ds-feishu-app-secret').value.trim(),
feishuAppToken: overlay.querySelector('#ds-feishu-app-token').value.trim(),
feishuTableId: overlay.querySelector('#ds-feishu-table-id').value.trim(),
feishuUploadContent: overlay.querySelector('#ds-feishu-upload-content').checked,
feishuUploadAttachment: overlay.querySelector('#ds-feishu-upload-attachment').checked,
feishuUploadHtml: overlay.querySelector('#ds-feishu-upload-html').checked,
// 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();
createFloatingButton();
// 注册油猴菜单
GM_registerMenuCommand('⚙️ 设置', showSettingsPanel);
GM_registerMenuCommand('📥 保存当前帖子(全部目标)', () => SaveModule.save(null));
GM_registerMenuCommand('📝 仅保存到 Obsidian', () => SaveModule.saveToObsidianOnly(null));
GM_registerMenuCommand('📑 仅保存到 Notion', () => SaveModule.saveToNotionOnly(null));
GM_registerMenuCommand('🐦 仅保存到飞书', () => SaveModule.saveToFeishuOnly(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] 油猴脚本已加载 (V5.5.9)');
}
return { init, showSettingsPanel };
})();
// ============================================================
// 启动脚本
// ============================================================
UIModule.init();
})();