From 752cb8455af2c3f709025280bf0796a5a30ebc86 Mon Sep 17 00:00:00 2001 From: zscc Date: Tue, 24 Mar 2026 03:34:53 +0800 Subject: [PATCH 01/48] fix: MutationObserver infinite loop crashing linux.do and WeChat pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: ensureFloatingSaveButton() modifies DOM (creates/updates the floating MD button), which triggers MutationObserver, which calls bindAll() again → infinite loop. X/Twitter pages were unaffected because detectFloatingSaveSite() returns null for x.com. Fixes: - Skip button update if it already exists with the correct siteKey - Debounce MutationObserver callback (200ms) to prevent high-frequency DOM mutations from causing repeated expensive querySelectorAll calls --- extension/content.js | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/extension/content.js b/extension/content.js index e9ab625..acfbe13 100644 --- a/extension/content.js +++ b/extension/content.js @@ -841,6 +841,11 @@ function ensureFloatingSaveButton() { return; } + // 按钮已存在且 siteKey 未变 → 跳过更新,避免 MutationObserver 死循环 + if (btn && btn.dataset.siteKey === siteKey) { + return; + } + if (!btn) { btn = document.createElement("button"); btn.id = SITE_FLOATING_SAVE_BUTTON_ID; @@ -887,6 +892,15 @@ function bindAll() { ensureFloatingSaveButton(); } +let _bindAllTimer = null; +function bindAllDebounced() { + if (_bindAllTimer) return; + _bindAllTimer = setTimeout(() => { + _bindAllTimer = null; + bindAll(); + }, 200); +} + document.addEventListener("click", (event) => { if (!isLinuxDoTopicPage()) return; const btn = event.target?.closest?.(LINUX_DO_LIKE_SELECTOR); @@ -894,7 +908,7 @@ document.addEventListener("click", (event) => { setTimeout(() => captureLinuxDoPost(btn), 250); }, true); -const observer = new MutationObserver(bindAll); +const observer = new MutationObserver(bindAllDebounced); observer.observe(document.body, { childList: true, subtree: true }); requestRuntimeConfig(); bindAll(); From 2b3f235a8cd432d6fe52a09c1074c19f39dbc41d Mon Sep 17 00:00:00 2001 From: zscc Date: Tue, 24 Mar 2026 03:59:35 +0800 Subject: [PATCH 02/48] fix: scroll-collect stops halfway on long Feishu documents Root cause: scrollHeight was captured once at start, but grows as virtual blocks render. The loop ended at the old maxPos, missing content beyond it. Fix: replace fixed-maxPos loop with dynamic bottom detection - keep scrolling until scrollTop reaches (scrollHeight - clientHeight) 3 consecutive times, dynamically tracking scrollHeight growth. --- extension/content.js | 38 ++++++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/extension/content.js b/extension/content.js index acfbe13..8193197 100644 --- a/extension/content.js +++ b/extension/content.js @@ -490,7 +490,7 @@ async function scrollAndCollectFeishuBlocks() { return null; } - const seen = new Map(); // blockId -> DOM element (clone) + const seen = new Map(); const originalScrollTop = container.scrollTop; function collect() { @@ -498,38 +498,44 @@ async function scrollAndCollectFeishuBlocks() { for (const block of blocks) { const id = block.getAttribute("data-block-id"); if (!id || seen.has(id)) continue; - // 克隆节点以保留 DOM 状态(虚拟渲染会回收原节点) seen.set(id, block.cloneNode(true)); } } - const step = Math.max(container.clientHeight * 0.7, 300); - const maxPos = container.scrollHeight; + const step = Math.max(container.clientHeight * 0.6, 400); - // 向下滚动收集 + // 向下滚动收集 — 动态跟踪 scrollHeight,直到确认到底 collect(); - for (let pos = 0; pos <= maxPos + step; pos += step) { + let pos = 0; + let stuckCount = 0; + while (stuckCount < 3) { + pos += step; container.scrollTop = pos; - await new Promise((r) => setTimeout(r, 80)); + await new Promise((r) => setTimeout(r, 100)); collect(); + // 检查是否真的滚到了(scrollHeight 可能在增长) + const maxScroll = container.scrollHeight - container.clientHeight; + if (container.scrollTop >= maxScroll - 10) { + stuckCount++; + // scrollHeight 可能因为新渲染而增长,给它一点时间 + await new Promise((r) => setTimeout(r, 150)); + // 更新 pos 到当前实际位置,防止 scrollHeight 增长后漏掉 + pos = container.scrollTop; + } else { + stuckCount = 0; + } } - container.scrollTop = maxPos; - await new Promise((r) => setTimeout(r, 200)); - collect(); - // 回滚收集(确保首屏区域的 block 也被收集) - for (let pos = maxPos; pos >= 0; pos -= step) { - container.scrollTop = pos; + // 回滚收集(补漏首屏区域 block) + for (let p = container.scrollHeight; p >= 0; p -= step) { + container.scrollTop = p; await new Promise((r) => setTimeout(r, 60)); collect(); } - // 恢复原始滚动位置 container.scrollTop = originalScrollTop; - console.log(`[x2md] 飞书滚动收集完成:${seen.size} unique blocks`); - // 按 blockId 排序返回(blockId 在飞书中通常是递增的) const sorted = Array.from(seen.entries()) .sort((a, b) => { const na = parseInt(a[0], 10); From 91bbd1a2678b9789b31d7c55d7f2976f4c317a9f Mon Sep 17 00:00:00 2001 From: HappyCapy Deploy Date: Wed, 25 Mar 2026 01:58:36 +0000 Subject: [PATCH 03/48] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Windows=20DLL?= =?UTF-8?q?=20=E5=B4=A9=E6=BA=83=20+=2010=20=E4=B8=AA=E4=BB=A3=E7=A0=81=20?= =?UTF-8?q?BUG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关键修复: - x2md.spec: Python DLL 加入 UPX 排除列表,禁用 strip,修复 Windows 启动崩溃 - server.py: CORS 白名单替代通配符、配置字段白名单校验、10MB 请求体上限、视频去重逻辑修正 - extension/background.js: 移除重复 importScripts - extension/options.js: 移除硬编码开发者路径 - extension/popup.js: 修复 XSS (innerHTML -> createElement+textContent) - extension/content.js: 替换全页 innerHTML 扫描为精准 DOM 查询 - tray_app.py: 修复开发模式下向导子进程路径 - config.json: 清理开发者个人路径,改为空默认值 Co-Authored-By: Claude Opus 4.6 --- config.json | 8 +++---- extension/background.js | 1 - extension/content.js | 17 +++++++++++++- extension/options.js | 4 ++-- extension/popup.js | 8 ++++++- server.py | 50 +++++++++++++++++++++++++++++++++++------ tray_app.py | 4 +++- x2md.spec | 10 +++++++-- 8 files changed, 83 insertions(+), 19 deletions(-) diff --git a/config.json b/config.json index 2119ef6..8552021 100644 --- a/config.json +++ b/config.json @@ -1,9 +1,9 @@ { "port": 9527, - "save_paths": [ - "/Users/zscc.in/Desktop/船仓文件/Obsidian/OB/00-资料库/📄 素材库" - ], + "save_paths": [], "filename_format": "{summary}", "max_filename_length": 60, - "setup_completed": false + "setup_completed": false, + "video_save_path": "/home/node/Desktop/X2MD/Videos", + "show_site_save_icon": true } \ No newline at end of file diff --git a/extension/background.js b/extension/background.js index c784911..9922e4b 100644 --- a/extension/background.js +++ b/extension/background.js @@ -9,7 +9,6 @@ importScripts("media_helpers.js"); importScripts("twitter_graphql.js"); -importScripts("twitter_graphql.js"); const SERVER_BASE = "http://127.0.0.1:9527"; const GRAPHQL_DISCOVERY_CACHE = new Map(); diff --git a/extension/content.js b/extension/content.js index 8193197..58bdd33 100644 --- a/extension/content.js +++ b/extension/content.js @@ -276,7 +276,22 @@ function detectAndExtractArticle() { } // ── 网页全局 MP4 提取与高清优选 (包含 React Fiber 深度探测) ── - const allMp4s = document.documentElement.innerHTML.match(/https?:\/\/video\.twimg\.com\/[^"'\s\\]+?\.mp4(?:\?tag=\d+)?/g) || []; + // 优先从 script 标签和 video 元素提取,避免 innerHTML 全量序列化导致大页面卡顿 + const allMp4s = []; + const mp4Pattern = /https?:\/\/video\.twimg\.com\/[^"'\s\\]+?\.mp4(?:\?tag=\d+)?/g; + document.querySelectorAll('script[type="application/json"], script:not([src])').forEach(script => { + const text = script.textContent || ""; + if (text.includes("video.twimg.com")) { + const matches = text.match(mp4Pattern) || []; + allMp4s.push(...matches); + } + }); + document.querySelectorAll('video[src]').forEach(v => { + if (v.src && v.src.includes("video.twimg.com")) allMp4s.push(v.src); + }); + document.querySelectorAll('source[src]').forEach(s => { + if (s.src && s.src.includes("video.twimg.com")) allMp4s.push(s.src); + }); // 强制探测所有的 video 节点及其父元素的 React state,寻找被长文框架封闭的 MP4 直链 const getReactPropsKey = (element) => Object.keys(element).find(k => k.startsWith('__reactProps$') || k.startsWith('__reactFiber$')); diff --git a/extension/options.js b/extension/options.js index 20dcbd8..460010f 100644 --- a/extension/options.js +++ b/extension/options.js @@ -38,7 +38,7 @@ function applyConfigToUI(cfg) { // 视频设置回显 document.getElementById("enableVideoDownload").checked = cfg.enable_video_download !== false; - document.getElementById("videoSavePath").value = cfg.video_save_path || "/Users/zscc.in/Desktop/船仓文件/Obsidian/OB/00-资料库/附件/视频/2026"; + document.getElementById("videoSavePath").value = cfg.video_save_path || ""; document.getElementById("videoDurationThreshold").value = cfg.video_duration_threshold || 5; document.getElementById("showSiteSaveIcon").checked = cfg.show_site_save_icon !== false; @@ -125,7 +125,7 @@ function saveConfig() { // 媒体设置读取 const enableVideoDownload = document.getElementById("enableVideoDownload").checked; - const videoSavePath = document.getElementById("videoSavePath").value.trim() || "/Users/zscc.in/Desktop/船仓文件/Obsidian/OB/00-资料库/附件/视频/2026"; + const videoSavePath = document.getElementById("videoSavePath").value.trim(); const videoDurationThreshold = parseFloat(document.getElementById("videoDurationThreshold").value) || 5; const showSiteSaveIcon = document.getElementById("showSiteSaveIcon").checked; diff --git a/extension/popup.js b/extension/popup.js index 1c976ed..d41f28c 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -18,5 +18,11 @@ chrome.runtime.sendMessage({ action: "get_config" }, (resp) => { list.innerHTML = '
未配置保存路径
'; return; } - list.innerHTML = paths.map(p => `
${p}
`).join(""); + list.innerHTML = ""; + paths.forEach(p => { + const div = document.createElement("div"); + div.className = "path-item"; + div.textContent = p; + list.appendChild(div); + }); }); diff --git a/server.py b/server.py index 7ab4bda..bf9e82d 100644 --- a/server.py +++ b/server.py @@ -239,9 +239,11 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str]: else: vid_map[vid_url] = f"🎞️ [推特媒体:点击播放视频]({vid_url})" - def append_unused_videos(lines_list, content_text): - if not videos: return - unused_vids = [v for v in videos if f"[MEDIA_VIDEO_URL:{v}]" not in (content_text or "")] + def append_unused_videos(lines_list, content_text, video_list=None): + """追加未在正文中内联的视频,video_list 默认为主推文 videos""" + vids = video_list if video_list is not None else videos + if not vids: return + unused_vids = [v for v in vids if v in vid_map and f"[MEDIA_VIDEO_URL:{v}]" not in (content_text or "")] if unused_vids: lines_list.append("") for v in unused_vids: @@ -320,8 +322,26 @@ def log_message(self, format, *args): """覆盖默认日志,使用自定义 logger""" logger.info(f"{self.address_string()} - {format % args}") + # 允许访问本地服务的来源白名单 + ALLOWED_ORIGINS = { + "chrome-extension://", # Chrome 扩展(前缀匹配) + "http://127.0.0.1", + "http://localhost", + } + + def _get_allowed_origin(self): + """检查请求来源是否合法,返回允许的 Origin 或 None""" + origin = self.headers.get("Origin", "") + if not origin: + return "http://127.0.0.1" # 无 Origin 的本地请求(如直接 curl) + for allowed in self.ALLOWED_ORIGINS: + if origin.startswith(allowed): + return origin + return None + def _send_cors_headers(self): - self.send_header("Access-Control-Allow-Origin", "*") + origin = self._get_allowed_origin() + self.send_header("Access-Control-Allow-Origin", origin or "http://127.0.0.1") self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") self.send_header("Access-Control-Allow-Headers", "Content-Type") @@ -346,9 +366,14 @@ def do_GET(self): else: self._respond(404, {"error": "Not Found"}) + MAX_REQUEST_SIZE = 10 * 1024 * 1024 # 10MB 请求体上限 + def do_POST(self): path = urlparse(self.path).path length = int(self.headers.get("Content-Length", 0)) + if length > self.MAX_REQUEST_SIZE: + self._respond(413, {"error": f"请求体过大({length} 字节),上限 {self.MAX_REQUEST_SIZE} 字节"}) + return body = self.rfile.read(length) try: @@ -408,12 +433,23 @@ def _handle_save(self, data: dict): else: self._respond(500, {"success": False, "errors": errors}) + # 允许通过 API 更新的配置字段白名单 + ALLOWED_CONFIG_KEYS = { + "port", "save_paths", "filename_format", "max_filename_length", + "video_save_path", "enable_video_download", "video_duration_threshold", + "show_site_save_icon", "setup_completed", + } + def _handle_config_update(self, data: dict): - """更新配置""" + """更新配置(仅允许白名单内的字段)""" cfg = load_config() - cfg.update(data) + filtered = {k: v for k, v in data.items() if k in self.ALLOWED_CONFIG_KEYS} + if not filtered: + self._respond(400, {"error": "没有有效的配置字段"}) + return + cfg.update(filtered) save_config(cfg) - logger.info(f"配置已更新:{data}") + logger.info(f"配置已更新:{filtered}") self._respond(200, {"success": True, "config": cfg}) def _respond(self, code: int, payload: dict): diff --git a/tray_app.py b/tray_app.py index 4449529..f4238e1 100644 --- a/tray_app.py +++ b/tray_app.py @@ -83,7 +83,9 @@ def launch_wizard_subprocess(): # 打包环境:直接启动自身并附加 --wizard 参数 subprocess.Popen([sys.executable, "--wizard"]) else: - script = os.path.join(APP_DIR, "setup_wizard.py") + # 开发模式:setup_wizard.py 在源码目录(与 tray_app.py 同级),而非 APP_DIR + source_dir = os.path.dirname(os.path.abspath(__file__)) + script = os.path.join(source_dir, "setup_wizard.py") subprocess.Popen([sys.executable, script]) diff --git a/x2md.spec b/x2md.spec index aa81918..85184da 100644 --- a/x2md.spec +++ b/x2md.spec @@ -73,9 +73,15 @@ coll = COLLECT( a.binaries, a.zipfiles, a.datas, - strip=True, + strip=False, # strip 在 Windows 上会损坏 DLL,统一关闭 upx=True, - upx_exclude=[], + upx_exclude=[ + 'python3*.dll', # Python DLL 不能被 UPX 压缩,否则加载时内存访问无效 + 'python*.dll', + 'vcruntime*.dll', # VC++ 运行时也不能压缩 + 'ucrtbase.dll', + 'api-ms-win-*.dll', + ], name='X2MD', ) From 38485f342ec2b1339231156a26342d09bd6195bd Mon Sep 17 00:00:00 2001 From: HappyCapy Deploy Date: Wed, 25 Mar 2026 02:30:52 +0000 Subject: [PATCH 04/48] =?UTF-8?q?feat:=20=E8=B7=A8=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=20+=20Windows=20=E5=85=BC=E5=AE=B9=E6=80=A7?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E6=89=A9=E5=B1=95=20bug=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20(v1.1.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能: - chrome.storage.sync 跨设备同步偏好设置(文件命名、视频下载等) - 设置页新增同步开关卡片,同步状态实时显示 - 端口配置持久化到 chrome.storage.local,自定义端口重启后生效 Windows 修复: - APPDATA 环境变量为空字符串时回退到用户目录 - StreamHandler 强制 UTF-8 编码,修复 emoji 日志导致的崩溃 - 托盘图标字体使用 Windows 完整路径 (C:\Windows\Fonts\arial.ttf) - pystray hiddenimport 增加 Linux (_xorg) 分支 扩展 bug 修复: - XSS: showToast 改用 textContent 替代 innerHTML - 硬编码端口: SERVER_BASE 改为动态,从 storage 读取 - manifest host_permissions 放宽为 http://127.0.0.1/* - popup.js/options.js 添加 chrome.runtime.lastError 检查 - like 按钮保存增加 500ms 防抖,防止重复触发 Co-Authored-By: Claude Opus 4.6 --- extension/background.js | 52 ++++++++++++++++++++++++++++++++++++++++- extension/content.js | 11 +++++++-- extension/manifest.json | 4 ++-- extension/options.html | 26 +++++++++++++++++++++ extension/options.js | 23 ++++++++++++++++++ extension/popup.js | 2 ++ server.py | 7 +++--- setup_wizard.py | 2 +- tray_app.py | 18 ++++++++------ x2md.spec | 4 ++-- 10 files changed, 131 insertions(+), 18 deletions(-) diff --git a/extension/background.js b/extension/background.js index 9922e4b..17a3595 100644 --- a/extension/background.js +++ b/extension/background.js @@ -10,7 +10,12 @@ importScripts("media_helpers.js"); importScripts("twitter_graphql.js"); -const SERVER_BASE = "http://127.0.0.1:9527"; +let SERVER_BASE = "http://127.0.0.1:9527"; + +// 从本地存储恢复用户自定义端口 +chrome.storage.local.get("x2md_port", (data) => { + if (data.x2md_port) SERVER_BASE = `http://127.0.0.1:${data.x2md_port}`; +}); const GRAPHQL_DISCOVERY_CACHE = new Map(); function hasDiscoveredOperationIds(ids) { @@ -712,6 +717,22 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { try { const resp = await fetch(`${SERVER_BASE}/config`); const cfg = await resp.json(); + // 如果开启了同步,用 sync 中的扩展配置覆盖(save_paths 等本地专属字段不同步) + const syncData = await chrome.storage.sync.get("x2md_sync").catch(() => ({})); + if (syncData.x2md_sync && syncData.x2md_sync.sync_enabled) { + const synced = syncData.x2md_sync; + const SYNC_FIELDS = ["filename_format", "max_filename_length", + "enable_video_download", "video_duration_threshold", "show_site_save_icon"]; + for (const k of SYNC_FIELDS) { + if (synced[k] !== undefined) cfg[k] = synced[k]; + } + cfg.sync_enabled = true; + } + // 缓存端口到 local storage,下次启动时无需先请求服务即可使用正确端口 + if (cfg.port && cfg.port !== 9527) { + chrome.storage.local.set({ x2md_port: cfg.port }); + SERVER_BASE = `http://127.0.0.1:${cfg.port}`; + } sendResponse({ success: true, config: cfg }); } catch (err) { sendResponse({ success: false, error: err.message }); @@ -729,6 +750,23 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { body: JSON.stringify(message.config) }); const json = await resp.json(); + // 如果开启了同步,将可同步字段写入 chrome.storage.sync + if (message.config.sync_enabled) { + const SYNC_FIELDS = ["filename_format", "max_filename_length", + "enable_video_download", "video_duration_threshold", "show_site_save_icon"]; + const toSync = { sync_enabled: true }; + for (const k of SYNC_FIELDS) { + if (message.config[k] !== undefined) toSync[k] = message.config[k]; + } + await chrome.storage.sync.set({ x2md_sync: toSync }).catch(() => {}); + } else if (message.config.sync_enabled === false) { + await chrome.storage.sync.remove("x2md_sync").catch(() => {}); + } + // 更新端口缓存 + if (message.config.port) { + chrome.storage.local.set({ x2md_port: message.config.port }); + SERVER_BASE = `http://127.0.0.1:${message.config.port}`; + } sendResponse({ success: json.success !== false, config: json.config }); } catch (err) { sendResponse({ success: false, error: err.message }); @@ -737,6 +775,18 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (message.action === "get_sync_status") { + (async () => { + try { + const data = await chrome.storage.sync.get("x2md_sync"); + sendResponse({ enabled: !!(data.x2md_sync && data.x2md_sync.sync_enabled) }); + } catch { + sendResponse({ enabled: false }); + } + })(); + return true; + } + if (message.action === "ping") { (async () => { try { diff --git a/extension/content.js b/extension/content.js index 58bdd33..d16706b 100644 --- a/extension/content.js +++ b/extension/content.js @@ -834,7 +834,12 @@ function showToast(message, type = "loading", duration = null) { const icons = { loading: "⏳", success: "✅", error: "❌" }; t.style.background = c.bg; t.style.boxShadow = `0 8px 24px ${c.shadow}`; - t.innerHTML = `${icons[type]}${message}`; + t.textContent = ""; + const icon = document.createElement("span"); + icon.style.marginRight = "8px"; + icon.textContent = icons[type] || ""; + t.appendChild(icon); + t.appendChild(document.createTextNode(message)); t.style.opacity = "1"; t.style.transform = "translateY(0)"; clearTimeout(t.__timer); @@ -922,11 +927,13 @@ function bindAllDebounced() { }, 200); } +let _likeSaveTimer = null; document.addEventListener("click", (event) => { if (!isLinuxDoTopicPage()) return; const btn = event.target?.closest?.(LINUX_DO_LIKE_SELECTOR); if (!btn) return; - setTimeout(() => captureLinuxDoPost(btn), 250); + clearTimeout(_likeSaveTimer); + _likeSaveTimer = setTimeout(() => captureLinuxDoPost(btn), 500); }, true); const observer = new MutationObserver(bindAllDebounced); diff --git a/extension/manifest.json b/extension/manifest.json index c10eead..b214e7e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "X2MD - 网页内容转Markdown", - "version": "1.0.8", + "version": "1.1.0", "description": "支持 X、LINUX DO、飞书知识库、飞书云文档、微信公众号的一键保存 Markdown", "permissions": [ "storage", @@ -17,7 +17,7 @@ "https://*.feishu.cn/wiki/*", "https://*.feishu.cn/docx/*", "https://mp.weixin.qq.com/*", - "http://127.0.0.1:9527/*", + "http://127.0.0.1/*", "https://cdn.syndication.twimg.com/*", "https://publish.twitter.com/*" ], diff --git a/extension/options.html b/extension/options.html index f4ae20a..0270257 100644 --- a/extension/options.html +++ b/extension/options.html @@ -513,6 +513,32 @@

站点快捷保存

+ +
+
+
🔄
+
+

跨设备同步

+

开启后,文件命名、视频下载等偏好设置通过 Chrome 账号自动同步到其他设备

+
+
+
+
+
开启同步
+
+ + +
+
+
+ 同步范围:文件命名格式、文件名限长、视频下载设置、站点图标开关。
+ 不同步:保存路径、视频保存路径、服务端口(这些因设备而异)。 +
+
+
+
+ + + + + + +
+
+
🎬
+
+

视频嵌入模式

+

控制 YouTube、Bilibili 等视频在 Markdown 中的嵌入方式

+
+
+
+
+
嵌入方式
+
+ +
+ iframe 模式:在 Markdown 中插入 <iframe> 标签,需联网播放,Obsidian 阅读视图支持。
+ 本地下载:视频下载到本地 assets 文件夹,用 ![[文件名.mp4]] 引用,离线可用。 +
+
+
+
+
+ + +
+
+
💬
+
+

评论/回复提取

+

保存 LinuxDo 帖子或 X 推文时,同时提取评论/回复内容

+
+
+
+
+
启用提取
+
+ + +
+
+
+
+
+
显示模式
+
+ +
折叠模式在 Obsidian 中默认收起,标题模式直接展开显示
+
+
+
+
+
最大评论数
+ +
+
+
+
楼层范围
+
+ +
+ 留空表示提取全部评论。支持范围(1-10)和指定楼层(2,5,8)混合使用(如 1-3,7,10)。
+ 楼层范围和最大评论数同时生效:先按楼层过滤,再截取前 N 条。 +
+
+
+
+
+
+
diff --git a/extension/options.js b/extension/options.js index 739ad44..c22565f 100644 --- a/extension/options.js +++ b/extension/options.js @@ -11,6 +11,15 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("btnAdd").addEventListener("click", addPath); document.getElementById("btnSave").addEventListener("click", saveConfig); + // 评论开关联动:关闭时禁用子控件 + document.getElementById("enableComments").addEventListener("change", toggleCommentSubControls); + + // Discourse 域名添加 + document.getElementById("btnAddDomain").addEventListener("click", addDiscourseDomain); + document.getElementById("newDiscourseDomain").addEventListener("keydown", (e) => { + if (e.key === "Enter") addDiscourseDomain(); + }); + loadConfig(); checkStatus(); }); @@ -63,6 +72,19 @@ function applyConfigToUI(cfg) { // 覆盖策略(默认关闭) document.getElementById("overwriteExisting").checked = !!cfg.overwrite_existing; + // 评论/回复设置(默认关闭) + document.getElementById("enableComments").checked = !!cfg.enable_comments; + document.getElementById("commentsDisplay").value = cfg.comments_display || "details"; + document.getElementById("maxComments").value = cfg.max_comments || 200; + document.getElementById("commentFloorRange").value = cfg.comment_floor_range || ""; + toggleCommentSubControls(); + + // Discourse 域名列表 + renderDiscourseDomains(cfg.discourse_domains || ["linux.do"]); + + // 嵌入模式 + document.getElementById("embedMode").value = cfg.embed_mode || "local"; + // 同步开关回显(默认开启,与 server DEFAULT_CONFIG 一致) document.getElementById("syncEnabled").checked = cfg.sync_enabled !== false; updateSyncStatus(cfg.sync_enabled !== false); @@ -81,6 +103,143 @@ function updateSyncStatus(enabled) { } } +function toggleCommentSubControls() { + const enabled = document.getElementById("enableComments").checked; + document.querySelectorAll(".comment-sub").forEach((el) => { + el.disabled = !enabled; + el.style.opacity = enabled ? "1" : "0.4"; + }); + const container = document.getElementById("commentSubControls"); + if (container) { + container.style.opacity = enabled ? "1" : "0.5"; + } +} + +// ───────────────────────────────────────────── +// Discourse 域名管理 +// ───────────────────────────────────────────── +let _discourseDomainsList = ["linux.do"]; + +function renderDiscourseDomains(domains) { + _discourseDomainsList = domains && domains.length ? [...domains] : ["linux.do"]; + const list = document.getElementById("discourseDomainList"); + list.innerHTML = ""; + + _discourseDomainsList.forEach((domain, i) => { + const row = document.createElement("div"); + row.className = "path-row"; + + const icon = document.createElement("span"); + icon.className = "path-row-icon"; + icon.textContent = "🌐"; + + const domainSpan = document.createElement("span"); + domainSpan.style.cssText = "flex:1; font-size:13px; font-family:monospace;"; + domainSpan.textContent = domain; + + // 站点类型标签(自动检索显示) + const typeTag = document.createElement("span"); + typeTag.style.cssText = "font-size:11px; padding:2px 8px; border-radius:4px; background:var(--surface2); border:1px solid var(--border); color:var(--accent); margin-right:8px;"; + typeTag.textContent = "检测中…"; + typeTag.id = `domain-type-${i}`; + + // 异步检测站点类型 + detectSiteType(domain, typeTag); + + const isBuiltin = domain === "linux.do"; + const btn = document.createElement("button"); + btn.className = "btn-remove"; + btn.title = isBuiltin ? "内置域名,不可删除" : "删除"; + btn.textContent = "×"; + btn.style.opacity = isBuiltin ? "0.3" : "1"; + btn.disabled = isBuiltin; + if (!isBuiltin) { + btn.addEventListener("click", () => { + _discourseDomainsList.splice(i, 1); + renderDiscourseDomains(_discourseDomainsList); + }); + } + + row.appendChild(icon); + row.appendChild(domainSpan); + row.appendChild(typeTag); + row.appendChild(btn); + list.appendChild(row); + }); +} + +async function detectSiteType(domain, tagElement) { + const lowerDomain = domain.toLowerCase(); + + // 已知平台快速匹配 + const knownPlatforms = { + "linux.do": "Discourse (LinuxDo)", + "meta.discourse.org": "Discourse (Official)", + "x.com": "Twitter/X", + "twitter.com": "Twitter/X", + "mp.weixin.qq.com": "微信公众号", + }; + + // 飞书匹配 + if (lowerDomain.endsWith(".feishu.cn")) { + tagElement.textContent = "飞书知识库"; + tagElement.style.color = "var(--success)"; + return; + } + + if (knownPlatforms[lowerDomain]) { + tagElement.textContent = knownPlatforms[lowerDomain]; + tagElement.style.color = "var(--success)"; + return; + } + + // 尝试检测 Discourse 实例(通过 /site.json API) + try { + const resp = await fetch(`https://${domain}/site.json`, { + signal: AbortSignal.timeout(5000), + headers: { "Accept": "application/json" }, + }); + if (resp.ok) { + const data = await resp.json(); + const siteName = data.title || data.description || domain; + tagElement.textContent = `Discourse (${siteName})`; + tagElement.style.color = "var(--success)"; + return; + } + } catch { /* 网络错误或非 Discourse */ } + + // 检测失败,显示为未知类型 + tagElement.textContent = "Discourse (未验证)"; + tagElement.style.color = "var(--text-muted)"; +} + +function addDiscourseDomain() { + const input = document.getElementById("newDiscourseDomain"); + let domain = input.value.trim().toLowerCase(); + if (!domain) return; + + // 清理输入:去掉协议前缀和尾部斜杠 + domain = domain.replace(/^https?:\/\//, "").replace(/\/.*$/, ""); + + if (!domain || !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) { + showToast("请输入有效的域名格式,如 forum.example.com", true); + return; + } + + if (_discourseDomainsList.includes(domain)) { + showToast("该域名已存在", true); + return; + } + + _discourseDomainsList.push(domain); + renderDiscourseDomains(_discourseDomainsList); + input.value = ""; +} + +function collectDiscourseDomains() { + return [..._discourseDomainsList]; +} + // ───────────────────────────────────────────── // 路径列表 // ───────────────────────────────────────────── @@ -184,6 +343,18 @@ function saveConfig() { // 覆盖策略 const overwriteExisting = document.getElementById("overwriteExisting").checked; + // 评论/回复设置 + const enableComments = document.getElementById("enableComments").checked; + const commentsDisplay = document.getElementById("commentsDisplay").value || "details"; + const maxComments = parseInt(document.getElementById("maxComments").value) || 200; + const commentFloorRange = document.getElementById("commentFloorRange").value.trim(); + + // Discourse 域名列表 + const discourseDomains = collectDiscourseDomains(); + + // 嵌入模式 + const embedMode = document.getElementById("embedMode").value || "local"; + if (!savePaths.length) { showToast("请至少添加一个保存路径", true); return; @@ -204,21 +375,27 @@ function saveConfig() { download_images: downloadImages, image_subfolder: imageSubfolder, overwrite_existing: overwriteExisting, + enable_comments: enableComments, + comments_display: commentsDisplay, + max_comments: maxComments, + comment_floor_range: commentFloorRange, + discourse_domains: discourseDomains, + embed_mode: embedMode, }; document.getElementById("portLabel").textContent = port; chrome.runtime.sendMessage({ action: "update_config", config: newConfig }, (resp) => { if (chrome.runtime.lastError) { - showToast("❌ 扩展通信失败,请刷新页面重试", true); + showToast("扩展通信失败,请刷新页面重试", true); return; } if (resp && resp.success) { currentConfig = newConfig; updateSyncStatus(syncEnabled); - showToast("✅ 设置已保存"); + showToast("设置已保存"); } else { - showToast("❌ 保存失败,服务是否在线?", true); + showToast("保存失败,服务是否在线?", true); } }); } diff --git a/server.py b/server.py index 91c8880..9b5c35b 100644 --- a/server.py +++ b/server.py @@ -85,6 +85,15 @@ def get_app_dir(): "overwrite_existing": False, # 跨设备同步(默认开启) "sync_enabled": True, + # 评论/回复提取(默认关闭) + "enable_comments": False, + "comments_display": "details", # "details" = 折叠, "heading" = 标题 + "max_comments": 200, + "comment_floor_range": "", # 楼层范围,如 "1-10" 或 "2,5,8",空=全部 + # Discourse 论坛域名列表(支持任意 Discourse 实例) + "discourse_domains": ["linux.do"], + # 嵌入模式:iframe = 用 iframe 嵌入视频, local = 下载到本地引用 + "embed_mode": "local", } _log_handlers = [logging.FileHandler(os.path.join(APP_DIR, "x2md.log"), encoding="utf-8")] @@ -287,6 +296,53 @@ def _normalize_publish_time(raw: str) -> str: return raw +def parse_floor_range(range_str: str) -> set | None: + """解析楼层范围字符串,返回楼层号集合,空字符串返回 None(表示全部) + 支持格式: "2,5,8" | "1-10" | "1-3,7,10" """ + if not range_str or not range_str.strip(): + return None + floors = set() + for part in range_str.split(","): + part = part.strip() + if "-" in part: + try: + start, end = part.split("-", 1) + floors.update(range(int(start), int(end) + 1)) + except (ValueError, TypeError): + continue + elif part.isdigit(): + floors.add(int(part)) + return floors or None + + +def _is_embeddable_video(url: str) -> bool: + """判断视频 URL 是否可以用 iframe 嵌入(YouTube / Bilibili)""" + if not url: + return False + lower = url.lower() + return any(p in lower for p in [ + "youtube.com/watch", "youtu.be/", "youtube.com/embed/", + "bilibili.com/video/", "player.bilibili.com", + ]) + + +def _make_video_iframe(url: str) -> str: + """为可嵌入的视频 URL 生成 iframe HTML""" + # YouTube + yt_match = re.search(r'(?:watch\?v=|youtu\.be/|embed/)([a-zA-Z0-9_-]+)', url) + if yt_match: + return f'\n\n' + # Bilibili + bv_match = re.search(r'bilibili\.com/video/(BV[a-zA-Z0-9]+)', url, re.IGNORECASE) + if bv_match: + return f'\n\n' + # Bilibili player 直接链接 + if "player.bilibili.com" in url: + return f'\n\n' + # fallback + return f"[视频:点击播放]({url})" + + def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list]: """ 将接收到的推文/文章数据构建为 Markdown 字符串。 @@ -372,6 +428,7 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list]: vid_map = {} video_tasks = [] # (url, subfolder, filename) — 与图片同结构,在 _handle_save 中处理 + embed_mode = cfg.get("embed_mode", "local") all_videos = list(videos) for t in thread_tweets: @@ -381,7 +438,11 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list]: for vid_url in all_videos: if vid_url in vid_map: continue - if download_video: + # iframe 模式:YouTube/Bilibili 用 iframe 嵌入 + if embed_mode == "iframe" and _is_embeddable_video(vid_url): + vid_map[vid_url] = _make_video_iframe(vid_url) + video_idx += 1 + elif download_video: vid_filename = f"{filename}_video_{video_idx}.mp4" video_tasks.append((vid_url, image_subfolder, vid_filename)) # 使用相对路径引用,与图片一致,Obsidian 可直接识别 @@ -499,6 +560,47 @@ def _repl(m): _inlined_videos.add(v_url) body = "\n".join(lines) + + # ── 评论/回复渲染 ──────────────────────────────── + comments = data.get("comments", []) + if cfg.get("enable_comments") and comments: + allowed_floors = parse_floor_range(cfg.get("comment_floor_range", "")) + max_count = cfg.get("max_comments", 200) + display = cfg.get("comments_display", "details") + + filtered = [] + for i, c in enumerate(comments): + floor = c.get("floor") or (i + 2) + if allowed_floors and floor not in allowed_floors: + continue + filtered.append((floor, c)) + if len(filtered) >= max_count: + break + + if filtered: + body += "\n\n---\n\n" + if display == "heading": + body += "## 评论/回复\n\n" + for floor, c in filtered: + c_author = c.get("author", "匿名") + c_content = c.get("content", "").strip() + c_published = _normalize_publish_time(c.get("published", "")) + body += f"### #{floor} {c_author}" + if c_published: + body += f" ({c_published})" + body += f"\n\n{c_content}\n\n" + else: # details + body += "
\n评论/回复\n\n" + for floor, c in filtered: + c_author = c.get("author", "匿名") + c_content = c.get("content", "").strip() + c_published = _normalize_publish_time(c.get("published", "")) + body += f"**#{floor} {c_author}**" + if c_published: + body += f" *{c_published}*" + body += f"\n\n{c_content}\n\n---\n\n" + body += "
\n" + return filename, front_matter + "\n" + body, image_tasks, video_tasks @@ -661,6 +763,8 @@ def _handle_save(self, data: dict): "enable_platform_folders", "platform_folder_names", "download_images", "image_subfolder", "overwrite_existing", "sync_enabled", + "enable_comments", "comments_display", "max_comments", "comment_floor_range", + "discourse_domains", "embed_mode", } def _handle_config_update(self, data: dict): diff --git a/test_full.py b/test_full.py index 8584443..2d1f99a 100644 --- a/test_full.py +++ b/test_full.py @@ -457,6 +457,282 @@ def test_thread_tweets(): test_thread_tweets() +# ═══════════════════════════════════════════════ +# 10. 评论/回复功能测试 +# ═══════════════════════════════════════════════ +print("\n=== 10. 评论/回复功能测试 ===") + +COMMENTS_CFG = {**BASE_CFG, "enable_comments": True, "comments_display": "details", "max_comments": 200, "comment_floor_range": ""} +SAMPLE_COMMENTS = [ + {"floor": 2, "author": "user_a", "content": "回复内容A", "published": "2026-03-25T10:00:00+08:00"}, + {"floor": 3, "author": "user_b", "content": "回复内容B", "published": "2026-03-25T11:00:00+08:00"}, + {"floor": 4, "author": "user_c", "content": "回复内容C", "published": ""}, +] + + +@test("parse_floor_range: 空字符串返回 None") +def test_floor_range_empty(): + assert server.parse_floor_range("") is None + assert server.parse_floor_range(" ") is None + assert server.parse_floor_range(None) is None +test_floor_range_empty() + + +@test("parse_floor_range: 指定楼层") +def test_floor_range_specific(): + result = server.parse_floor_range("2,5,8") + assert result == {2, 5, 8} +test_floor_range_specific() + + +@test("parse_floor_range: 范围") +def test_floor_range_range(): + result = server.parse_floor_range("1-10") + assert result == set(range(1, 11)) +test_floor_range_range() + + +@test("parse_floor_range: 混合") +def test_floor_range_mixed(): + result = server.parse_floor_range("1-3,7,10") + assert result == {1, 2, 3, 7, 10} +test_floor_range_mixed() + + +@test("评论渲染: details 折叠模式") +def test_comments_details(): + data = { + "author": "TestUser", "handle": "@testuser", "text": "主推文", + "url": "https://x.com/testuser/status/123", "platform": "Twitter/X", + "type": "tweet", "images": [], "videos": [], + "comments": SAMPLE_COMMENTS, + } + _, content, _, _ = server.build_markdown(data, COMMENTS_CFG) + assert "
" in content + assert "评论/回复" in content + assert "**#2 user_a**" in content + assert "回复内容A" in content + assert "**#3 user_b**" in content + assert "
" in content +test_comments_details() + + +@test("评论渲染: heading 标题模式") +def test_comments_heading(): + cfg = {**COMMENTS_CFG, "comments_display": "heading"} + data = { + "author": "TestUser", "handle": "@testuser", "text": "主推文", + "url": "https://x.com/testuser/status/123", "platform": "Twitter/X", + "type": "tweet", "images": [], "videos": [], + "comments": SAMPLE_COMMENTS, + } + _, content, _, _ = server.build_markdown(data, cfg) + assert "## 评论/回复" in content + assert "### #2 user_a" in content + assert "回复内容B" in content + assert "
" not in content +test_comments_heading() + + +@test("评论渲染: 关闭时无评论输出") +def test_comments_disabled(): + cfg = {**BASE_CFG, "enable_comments": False} + data = { + "author": "TestUser", "handle": "@testuser", "text": "主推文", + "url": "https://x.com/testuser/status/123", "platform": "Twitter/X", + "type": "tweet", "images": [], "videos": [], + "comments": SAMPLE_COMMENTS, + } + _, content, _, _ = server.build_markdown(data, cfg) + assert "评论/回复" not in content + assert "user_a" not in content +test_comments_disabled() + + +@test("评论渲染: 楼层过滤") +def test_comments_floor_filter(): + cfg = {**COMMENTS_CFG, "comment_floor_range": "2,4"} + data = { + "author": "TestUser", "handle": "@testuser", "text": "主推文", + "url": "https://x.com/testuser/status/123", "platform": "Twitter/X", + "type": "tweet", "images": [], "videos": [], + "comments": SAMPLE_COMMENTS, + } + _, content, _, _ = server.build_markdown(data, cfg) + assert "user_a" in content # floor 2 + assert "user_c" in content # floor 4 + assert "user_b" not in content # floor 3 被过滤 +test_comments_floor_filter() + + +@test("评论渲染: max_comments 限制") +def test_comments_max_limit(): + cfg = {**COMMENTS_CFG, "max_comments": 1} + data = { + "author": "TestUser", "handle": "@testuser", "text": "主推文", + "url": "https://x.com/testuser/status/123", "platform": "Twitter/X", + "type": "tweet", "images": [], "videos": [], + "comments": SAMPLE_COMMENTS, + } + _, content, _, _ = server.build_markdown(data, cfg) + assert "user_a" in content # 第一条通过 + assert "user_b" not in content # 被截断 + assert "user_c" not in content # 被截断 +test_comments_max_limit() + + +@test("评论渲染: 无楼层号的评论自动编号") +def test_comments_auto_floor(): + comments_no_floor = [ + {"author": "reply_1", "content": "第一条回复", "published": ""}, + {"author": "reply_2", "content": "第二条回复", "published": ""}, + ] + data = { + "author": "TestUser", "handle": "@testuser", "text": "主推文", + "url": "https://x.com/testuser/status/123", "platform": "Twitter/X", + "type": "tweet", "images": [], "videos": [], + "comments": comments_no_floor, + } + _, content, _, _ = server.build_markdown(data, COMMENTS_CFG) + assert "**#2 reply_1**" in content # i=0 -> floor=2 + assert "**#3 reply_2**" in content # i=1 -> floor=3 +test_comments_auto_floor() + + +# ═══════════════════════════════════════════════ +# 11. 新配置项测试(discourse_domains, embed_mode) +# ═══════════════════════════════════════════════ +print("\n=== 11. 新配置项测试 ===") + + +@test("配置: discourse_domains 默认包含 linux.do") +def test_config_discourse_domains(): + cfg = server.DEFAULT_CONFIG + assert "discourse_domains" in cfg + assert "linux.do" in cfg["discourse_domains"] +test_config_discourse_domains() + + +@test("配置: discourse_domains 在白名单中") +def test_config_discourse_whitelist(): + assert "discourse_domains" in server.X2MDHandler.ALLOWED_CONFIG_KEYS +test_config_discourse_whitelist() + + +@test("配置: embed_mode 默认为 local") +def test_config_embed_mode(): + cfg = server.DEFAULT_CONFIG + assert cfg.get("embed_mode") == "local" +test_config_embed_mode() + + +@test("配置: embed_mode 在白名单中") +def test_config_embed_whitelist(): + assert "embed_mode" in server.X2MDHandler.ALLOWED_CONFIG_KEYS +test_config_embed_whitelist() + + +# ═══════════════════════════════════════════════ +# 12. iframe 嵌入测试 +# ═══════════════════════════════════════════════ +print("\n=== 12. iframe 嵌入测试 ===") + + +@test("_is_embeddable_video: YouTube URL 识别") +def test_embeddable_youtube(): + assert server._is_embeddable_video("https://www.youtube.com/watch?v=abc123") + assert server._is_embeddable_video("https://youtu.be/abc123") + assert server._is_embeddable_video("https://www.youtube.com/embed/abc123") + assert not server._is_embeddable_video("https://example.com/video.mp4") + assert not server._is_embeddable_video("") + assert not server._is_embeddable_video(None) +test_embeddable_youtube() + + +@test("_is_embeddable_video: Bilibili URL 识别") +def test_embeddable_bilibili(): + assert server._is_embeddable_video("https://www.bilibili.com/video/BV1xx411c7mD") + assert server._is_embeddable_video("https://player.bilibili.com/player.html?bvid=BV1xx411c7mD") + assert not server._is_embeddable_video("https://example.com/not-bilibili") +test_embeddable_bilibili() + + +@test("_make_video_iframe: YouTube iframe 生成") +def test_make_iframe_youtube(): + result = server._make_video_iframe("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + assert " 引用块 + +| 列A | 列B | +| --- | --- | +| 1 | 2 |""" + data = { + "type": "article", + "url": "https://example.feishu.cn/docx/xyz", + "author": "作者", + "handle": "", + "published": "", + "article_title": "Markdown测试", + "article_content": md_content, + "images": [], + "videos": [], + "platform": "Feishu", + } + _, content, _, _ = server.build_markdown(data, cfg) + assert "# 一级标题" in content + assert "## 二级标题" in content + assert "```python" in content + assert "> 引用块" in content + assert "| 列A | 列B |" in content + + +# ═══════════════════════════════════════════════ +# 2. iframe 嵌入 + embed_mode 切换 +# ═══════════════════════════════════════════════ +print("\n=== 2. iframe 嵌入 + embed_mode 切换 ===") + + +@test("embed_mode=iframe: YouTube → iframe 标签") +def test_embed_iframe_youtube(): + cfg = {**server.DEFAULT_CONFIG, "embed_mode": "iframe"} + data = { + "author": "A", "handle": "@a", "text": "Video", + "url": "https://x.com/a/status/1", + "platform": "Twitter/X", "images": [], + "videos": ["https://www.youtube.com/watch?v=abc123"], + "download_video": True, + } + _, content, _, vid_tasks = server.build_markdown(data, cfg) + assert "&', + "url": "https://x.com/test/status/999", + "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, cfg) + # front matter 应以 --- 开闭 + assert content.startswith("---\n") + assert "\n---\n" in content + # 双引号在 title 中应被转义为单引号 + lines = content.split("---\n")[1].split("\n") + for line in lines: + if line.startswith("标题:"): + assert '"' not in line.split(": ", 1)[1].strip('"'), \ + f"标题值内含未转义双引号: {line}" + break + + +@test("Obsidian: iframe 在阅读模式下可渲染") +def test_ob_iframe_render(): + """Obsidian 阅读模式支持 HTML iframe 标签""" + cfg = {**server.DEFAULT_CONFIG, "embed_mode": "iframe"} + data = { + "author": "OB", "handle": "@ob", + "text": "视频", "url": "https://x.com/ob/status/111", + "platform": "Twitter/X", "images": [], + "videos": ["https://www.youtube.com/watch?v=obTest"], + "download_video": True, + } + _, content, _, _ = server.build_markdown(data, cfg) + # Obsidian 要求 iframe 在空行包围中 + iframe_match = re.search(r'\n\n', content) + assert iframe_match, "iframe 没有被空行包围,Obsidian 可能无法渲染" + + +@test("Obsidian: details 折叠在阅读模式下可渲染") +def test_ob_details_render(): + cfg = {**server.DEFAULT_CONFIG, "enable_comments": True, "comments_display": "details"} + data = { + "author": "OB2", "handle": "@ob2", + "text": "评论测试", "url": "https://x.com/ob2/status/222", + "platform": "Twitter/X", "images": [], "videos": [], + "comments": [ + {"floor": 2, "author": "回复者", "content": "好帖!", "published": "2026-03-25"}, + ], + } + _, content, _, _ = server.build_markdown(data, cfg) + assert "
" in content + assert "" in content + assert "
" in content + # Obsidian 要求 details 标签与内容间有空行 + assert "
\n" in content + + +@test("Obsidian: 图片相对路径在 assets 子文件夹") +def test_ob_image_relative_path(): + cfg = {**server.DEFAULT_CONFIG, "download_images": True, "image_subfolder": "assets"} + data = { + "author": "Img", "handle": "@img", + "text": "图片", + "url": "https://x.com/img/status/333", + "platform": "Twitter/X", + "images": ["https://pbs.twimg.com/media/test.jpg?name=orig"], + "videos": [], + } + _, content, img_tasks, _ = server.build_markdown(data, cfg) + assert "![1](assets/" in content + assert len(img_tasks) == 1 + + +@test("Obsidian: 视频本地引用格式 ![video_N](assets/...)") +def test_ob_video_local_ref(): + cfg = {**server.DEFAULT_CONFIG, "embed_mode": "local"} + data = { + "author": "Vid", "handle": "@vid", + "text": "视频", + "url": "https://x.com/vid/status/444", + "platform": "Twitter/X", + "images": [], + "videos": ["https://video.twimg.com/ext/v.mp4"], + "download_video": True, + } + _, content, _, vid_tasks = server.build_markdown(data, cfg) + assert "![video_1](assets/" in content + # video_1 出现在 alt text 和文件名中,检查只有一个 ![video_1] 引用 + assert content.count("![video_1]") == 1, "视频引用应只出现一次" + + +@test("Obsidian: 文件名不含非法字符") +def test_ob_filename_safe(): + cfg = dict(server.DEFAULT_CONFIG) + data = { + "author": "非法/字符", "handle": '@bad:"chars"', + "text": "推文<内容>包含|管道*星号?问号", + "url": "https://x.com/bad/status/555", + "platform": "Twitter/X", + "images": [], "videos": [], + } + fname, _, _, _ = server.build_markdown(data, cfg) + # Windows 不允许: \ / : * ? " < > | + for c in r'\/:*?"<>|': + assert c not in fname, f"文件名含非法字符 '{c}': {fname}" + + +# ═══════════════════════════════════════════════ +# 8. 评论 + iframe 混合场景 +# ═══════════════════════════════════════════════ +print("\n=== 8. 评论 + iframe 混合场景 ===") + + +@test("评论 + iframe: 同时开启两个功能不冲突") +def test_comments_and_iframe_together(): + cfg = {**server.DEFAULT_CONFIG, + "enable_comments": True, "comments_display": "heading", + "embed_mode": "iframe"} + data = { + "author": "Mix", "handle": "@mix", + "text": "混合测试", + "url": "https://x.com/mix/status/666", + "platform": "Twitter/X", + "images": [], + "videos": ["https://www.youtube.com/watch?v=mixVid"], + "download_video": True, + "comments": [ + {"floor": 2, "author": "评论者", "content": "nice!", "published": ""}, + ], + } + _, content, _, vid_tasks = server.build_markdown(data, cfg) + # iframe 存在 + assert "youtube.com/embed/mixVid" in content + # 评论存在 + assert "## 评论/回复" in content + assert "#2 评论者" in content + # iframe 在评论前面 + iframe_pos = content.index("iframe") + comment_pos = content.index("评论/回复") + assert iframe_pos < comment_pos + + +@test("评论楼层过滤 + max_comments 限制同时生效") +def test_comments_filter_and_limit(): + cfg = {**server.DEFAULT_CONFIG, + "enable_comments": True, + "comment_floor_range": "2-4", + "max_comments": 2} + comments = [ + {"floor": i, "author": f"User{i}", "content": f"Reply {i}", "published": ""} + for i in range(2, 10) + ] + data = { + "author": "A", "handle": "@a", "text": "Test", + "url": "https://x.com/a/status/777", + "platform": "Twitter/X", + "images": [], "videos": [], + "comments": comments, + } + _, content, _, _ = server.build_markdown(data, cfg) + # 楼层范围 2-4,但 max_comments=2,所以只有 2 和 3 + assert "#2 User2" in content + assert "#3 User3" in content + assert "#4 User4" not in content # 被 max_comments 截断 + assert "#5 User5" not in content # 不在范围内 + + +# ═══════════════════════════════════════════════ +# 9. 边界情况与异常输入 +# ═══════════════════════════════════════════════ +print("\n=== 9. 边界情况与异常输入 ===") + + +@test("空视频列表 + embed_mode=iframe 不崩溃") +def test_no_videos_iframe_mode(): + cfg = {**server.DEFAULT_CONFIG, "embed_mode": "iframe"} + data = { + "author": "Empty", "handle": "@empty", + "text": "无视频推文", + "url": "https://x.com/empty/status/800", + "platform": "Twitter/X", + "images": [], "videos": [], + } + fname, content, _, _ = server.build_markdown(data, cfg) + assert "无视频推文" in content + assert " 60" + + +@test("[MEDIA_VIDEO_URL:xxx] 占位符在 article 中被正确替换") +def test_video_placeholder_replacement(): + cfg = {**server.DEFAULT_CONFIG, "embed_mode": "iframe"} + vid_url = "https://www.youtube.com/watch?v=placeholder123" + data = { + "type": "article", + "url": "https://x.com/a/status/803", + "author": "P", "handle": "@p", + "published": "", + "article_title": "Placeholder Test", + "article_content": f"正文\n\n[MEDIA_VIDEO_URL:{vid_url}]\n\n尾部", + "images": [], "videos": [vid_url], + "download_video": True, + "platform": "Twitter/X", + } + _, content, _, _ = server.build_markdown(data, cfg) + assert "[MEDIA_VIDEO_URL:" not in content, "占位符未被替换" + assert "youtube.com/embed/placeholder123" in content + + +@test("微信公众号平台 front matter 正确") +def test_wechat_platform(): + cfg = dict(server.DEFAULT_CONFIG) + data = { + "type": "article", + "url": "https://mp.weixin.qq.com/s/abc123", + "author": "公众号作者", + "handle": "", + "published": "", + "article_title": "微信公众号文章标题", + "article_content": "公众号文章正文", + "images": [], "videos": [], + "platform": "WeChat", + } + _, content, _, _ = server.build_markdown(data, cfg) + assert '平台: "WeChat"' in content + # WeChat 应有对应的文件夹名 + folder = cfg["platform_folder_names"].get("WeChat") + assert folder == "WeChat" + + +@test("parse_floor_range: 混合格式 '1-3,7,10-12' 解析正确") +def test_parse_floor_range_mixed(): + result = server.parse_floor_range("1-3,7,10-12") + assert result == {1, 2, 3, 7, 10, 11, 12} + + +@test("parse_floor_range: 非法输入不崩溃") +def test_parse_floor_range_bad_input(): + assert server.parse_floor_range("abc") is None + assert server.parse_floor_range("1-abc") is None or isinstance(server.parse_floor_range("1-abc"), set) + assert server.parse_floor_range("") is None + assert server.parse_floor_range(" ") is None + + +# ═══════════════════════════════════════════════ +# 10. SYNC_FIELDS 一致性验证(模拟检查) +# ═══════════════════════════════════════════════ +print("\n=== 10. SYNC_FIELDS 一致性验证 ===") + + +@test("background.js 两处 SYNC_FIELDS 包含新字段(通过文件读取验证)") +def test_sync_fields_in_background(): + bg_path = os.path.join(os.path.dirname(__file__), "extension", "background.js") + with open(bg_path, "r", encoding="utf-8") as f: + bg_content = f.read() + + # 查找所有 SYNC_FIELDS 数组 + sync_blocks = re.findall(r'SYNC_FIELDS\s*=\s*\[([^\]]+)\]', bg_content) + assert len(sync_blocks) >= 2, f"预期至少 2 处 SYNC_FIELDS,实际 {len(sync_blocks)}" + + new_fields = ["discourse_domains", "embed_mode", + "enable_comments", "comments_display", "max_comments", "comment_floor_range"] + + for i, block in enumerate(sync_blocks): + for field in new_fields: + assert f'"{field}"' in block, \ + f"第 {i+1} 处 SYNC_FIELDS 缺少 \"{field}\"" + + +@test("options.js 读写新配置字段") +def test_options_js_fields(): + opts_path = os.path.join(os.path.dirname(__file__), "extension", "options.js") + with open(opts_path, "r", encoding="utf-8") as f: + opts_content = f.read() + + # saveConfig 中应包含这些字段 + new_fields_in_save = ["discourse_domains", "embed_mode", + "enable_comments", "comments_display", "max_comments", "comment_floor_range"] + for field in new_fields_in_save: + assert field in opts_content, f"options.js 中缺少 {field}" + + +@test("manifest.json 包含 optional_host_permissions") +def test_manifest_optional_permissions(): + manifest_path = os.path.join(os.path.dirname(__file__), "extension", "manifest.json") + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + + assert "optional_host_permissions" in manifest, "manifest 缺少 optional_host_permissions" + assert "https://*/*" in manifest["optional_host_permissions"] + + +# ═══════════════════════════════════════════════ +# 11. chrome.permissions.request 竞态风险检查 +# ═══════════════════════════════════════════════ +print("\n=== 11. 扩展交互逻辑风险检查 ===") + + +@test("background.js: registerDiscourseContentScripts 有异常捕获") +def test_register_has_try_catch(): + bg_path = os.path.join(os.path.dirname(__file__), "extension", "background.js") + with open(bg_path, "r", encoding="utf-8") as f: + bg_content = f.read() + + # 函数内应有 try/catch + func_match = re.search( + r'async function registerDiscourseContentScripts.*?\n\}', bg_content, re.DOTALL) + assert func_match, "未找到 registerDiscourseContentScripts 函数" + func_body = func_match.group() + assert "try" in func_body and "catch" in func_body, \ + "registerDiscourseContentScripts 缺少异常捕获" + + +@test("background.js: permissions.request 有 .catch 防护") +def test_permissions_request_catch(): + bg_path = os.path.join(os.path.dirname(__file__), "extension", "background.js") + with open(bg_path, "r", encoding="utf-8") as f: + bg_content = f.read() + + # chrome.permissions.request 应有 .catch() 处理 + assert "permissions.request" in bg_content + # 检查 request 后有 .catch + perm_idx = bg_content.index("permissions.request") + nearby = bg_content[perm_idx:perm_idx+200] + assert ".catch" in nearby, "chrome.permissions.request 没有 .catch() 防护" + + +@test("background.js: 启动时配置获取有超时和异常捕获") +def test_startup_config_timeout(): + bg_path = os.path.join(os.path.dirname(__file__), "extension", "background.js") + with open(bg_path, "r", encoding="utf-8") as f: + bg_content = f.read() + + # 启动 IIFE 中应有 AbortSignal.timeout + assert "AbortSignal.timeout" in bg_content, "启动配置获取缺少超时设置" + + +@test("content.js: Discourse 域名初始化有安全检查") +def test_content_discourse_init_safe(): + cs_path = os.path.join(os.path.dirname(__file__), "extension", "content.js") + with open(cs_path, "r", encoding="utf-8") as f: + cs_content = f.read() + + # setDiscourseDomains 调用前应检查函数存在 + assert 'typeof setDiscourseDomains === "function"' in cs_content, \ + "content.js 未检查 setDiscourseDomains 是否存在" + + +# ═══════════════════════════════════════════════ +# 12. 飞书 iframe 嵌入处理验证 +# ═══════════════════════════════════════════════ +print("\n=== 12. 飞书 iframe 嵌入处理 ===") + + +@test("飞书: 文档中含 YouTube 视频链接时的处理") +def test_feishu_youtube_in_content(): + """飞书文档 article_content 中包含 YouTube 链接,embed_mode=iframe 时应生效""" + cfg = {**server.DEFAULT_CONFIG, "embed_mode": "iframe"} + data = { + "type": "article", + "url": "https://example.feishu.cn/wiki/abc", + "author": "作者", "handle": "", + "published": "", + "article_title": "含视频的飞书文档", + "article_content": "正文内容", + "images": [], + "videos": ["https://www.youtube.com/watch?v=feishuYT"], + "download_video": True, + "platform": "Feishu", + } + _, content, _, vid_tasks = server.build_markdown(data, cfg) + assert "youtube.com/embed/feishuYT" in content + assert len(vid_tasks) == 0 + + +@test("飞书: embed_mode=local 时视频走本地下载") +def test_feishu_local_video(): + cfg = {**server.DEFAULT_CONFIG, "embed_mode": "local"} + data = { + "type": "article", + "url": "https://example.feishu.cn/wiki/abc", + "author": "作者", "handle": "", + "published": "", + "article_title": "本地视频", + "article_content": "正文", + "images": [], + "videos": ["https://www.youtube.com/watch?v=localYT"], + "download_video": True, + "platform": "Feishu", + } + _, content, _, vid_tasks = server.build_markdown(data, cfg) + assert " false)" in bg or ".catch" in bg[bg.index("permissions.request"):bg.index("permissions.request")+150], \ + "permissions.request 缺少 .catch 防护" + + +@test("风险: _config_cache 双重检查锁模式正确") +def test_risk_config_double_check(): + """验证 load_config 的双重检查锁模式""" + import inspect + source = inspect.getsource(server.load_config) + # 应有两次 _config_cache 检查(锁前 + 锁后) + cache_checks = source.count("_config_cache is not None") + assert cache_checks >= 2, f"load_config 双重检查锁不完整,只有 {cache_checks} 次检查" + + +@test("风险: discourse.js fetchDiscourseReplies 使用 hostname 参数") +def test_risk_discourse_hostname_param(): + """验证 fetchDiscourseReplies 接受 hostname 参数,不再硬编码 linux.do""" + ds_path = os.path.join(os.path.dirname(__file__), "extension", "discourse.js") + with open(ds_path, "r", encoding="utf-8") as f: + ds = f.read() + # 函数签名应含 hostname 参数 + assert "fetchDiscourseReplies(topicId, hostname)" in ds + # 使用 hostname 构建 URL,而非硬编码 + assert "${host}" in ds + + +# ═══════════════════════════════════════════════ +# 汇总 +# ═══════════════════════════════════════════════ +print(f"\n{'='*50}") +print(f"测试结果: {_pass} 通过, {_fail} 失败") +if _fail == 0: + print("全部通过!") +else: + print(f"有 {_fail} 个测试失败") + sys.exit(1) diff --git a/test_obsidian_render.py b/test_obsidian_render.py index 7ffe2b3..30d975d 100644 --- a/test_obsidian_render.py +++ b/test_obsidian_render.py @@ -814,6 +814,168 @@ def test_special_title(): print("-" * 60) +# ═══════════════════════════════════════════════ +# 11. 评论/回复 Obsidian 渲染验证 +# ═══════════════════════════════════════════════ +print("\n=== 11. 评论/回复 Obsidian 渲染验证 ===") + +COMMENTS_CFG = {**CFG_NO_DOWNLOAD, "enable_comments": True, "comments_display": "details", "max_comments": 200, "comment_floor_range": ""} +OB_COMMENTS = [ + {"floor": 2, "author": "reviewer", "content": "这是一条评论\n包含多行", "published": "2026-03-25T10:00:00+08:00"}, + {"floor": 3, "author": "commenter", "content": "另一条评论", "published": ""}, +] + + +@test("Obsidian: details 模式评论渲染") +def test_ob_comments_details(): + data = { + "author": "TestUser", "handle": "@testuser", "text": "主推文内容", + "url": "https://x.com/testuser/status/456", "platform": "Twitter/X", + "type": "tweet", "images": [], "videos": [], + "comments": OB_COMMENTS, + } + _, content, _, _ = server.build_markdown(data, COMMENTS_CFG) + # Obsidian 支持
/ 标签 + assert "
" in content + assert "评论/回复" in content + assert "**#2 reviewer**" in content + assert "2026-03-25 10:00" in content # 时间被规范化 + assert "这是一条评论" in content + assert "**#3 commenter**" in content + assert "
" in content + # 主内容仍在 + assert "主推文内容" in content +test_ob_comments_details() + + +@test("Obsidian: heading 模式评论渲染") +def test_ob_comments_heading(): + cfg = {**COMMENTS_CFG, "comments_display": "heading"} + data = { + "author": "TestUser", "handle": "@testuser", "text": "主推文内容", + "url": "https://x.com/testuser/status/456", "platform": "Twitter/X", + "type": "tweet", "images": [], "videos": [], + "comments": OB_COMMENTS, + } + _, content, _, _ = server.build_markdown(data, cfg) + assert "## 评论/回复" in content + assert "### #2 reviewer (2026-03-25 10:00)" in content + assert "### #3 commenter" in content + # heading 模式不应有
+ assert "
" not in content +test_ob_comments_heading() + + +@test("Obsidian: 评论含图片引用") +def test_ob_comments_with_images(): + comments_with_img = [ + {"floor": 2, "author": "img_user", "content": "看这个图 ![图片](https://example.com/img.jpg)", "published": ""}, + ] + data = { + "author": "TestUser", "handle": "@testuser", "text": "主推文", + "url": "https://x.com/testuser/status/789", "platform": "Twitter/X", + "type": "tweet", "images": [], "videos": [], + "comments": comments_with_img, + } + _, content, _, _ = server.build_markdown(data, COMMENTS_CFG) + assert "![图片](https://example.com/img.jpg)" in content +test_ob_comments_with_images() + + +@test("Obsidian: LinuxDo 评论渲染") +def test_ob_linuxdo_comments(): + linuxdo_comments = [ + {"floor": 2, "author": "linux_user", "content": "很好的教程,感谢分享!", "published": "2026-03-25T09:00:00+08:00"}, + {"floor": 5, "author": "another", "content": "学到了", "published": "2026-03-25T12:30:00+08:00"}, + ] + data = { + "author": "original_poster", "handle": "", + "text": "", "article_content": "# 技术教程\n\n这是一篇教程", + "article_title": "Linux DO 教程", + "url": "https://linux.do/t/topic/12345", "platform": "LinuxDo", + "type": "article", "images": [], "videos": [], + "comments": linuxdo_comments, + } + _, content, _, _ = server.build_markdown(data, COMMENTS_CFG) + assert "技术教程" in content + assert "**#2 linux_user**" in content + assert "感谢分享" in content + assert "**#5 another**" in content +test_ob_linuxdo_comments() + + +# ═══════════════════════════════════════════════ +# 12. iframe 嵌入 Obsidian 渲染验证 +# ═══════════════════════════════════════════════ +print("\n=== 12. iframe 嵌入 Obsidian 渲染验证 ===") + + +@test("Obsidian: YouTube iframe 嵌入渲染正确") +def test_ob_youtube_iframe(): + cfg = {**server.DEFAULT_CONFIG, "embed_mode": "iframe"} + data = { + "author": "YouTuber", "handle": "@youtuber", + "text": "Great video!", + "url": "https://x.com/youtuber/status/111", + "platform": "Twitter/X", + "images": [], + "videos": ["https://www.youtube.com/watch?v=dQw4w9WgXcQ"], + "download_video": True, + } + _, content, _, _ = server.build_markdown(data, cfg) + assert " Date: Sun, 29 Mar 2026 04:00:30 +0000 Subject: [PATCH 25/48] =?UTF-8?q?feat:=20=E9=A3=9E=E4=B9=A6=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=B8=80=E9=94=AE=E5=A4=8D=E5=88=B6=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=20+=20=E5=A4=9A=E5=B9=B3=E5=8F=B0=E6=B5=AE=E5=8A=A8=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增飞书文档一键复制功能(opt-in),支持只读/禁止复制的飞书文档: - 三策略内容提取:API → 滚动收集 → DOM兜底 - 输出 Obsidian 兼容 Markdown(图片URL链接、视频iframe嵌入) - 剪贴板写入双重降级(Clipboard API → execCommand) - 自动解除飞书CSS/JS复制保护 - 标题#字符转义防止Markdown格式破坏 - URL括号编码防止图片链接断裂 扩展 site_actions 浮动保存按钮支持微信公众号页面 Co-Authored-By: Claude Opus 4.6 --- extension/content.js | 307 +++++++++++++++++++++++++++++++++++--- extension/feishu.js | 200 ++++++++++++++++++++++++- extension/manifest.json | 14 ++ extension/options.html | 27 ++++ extension/options.js | 3 + extension/site_actions.js | 5 +- 6 files changed, 530 insertions(+), 26 deletions(-) diff --git a/extension/content.js b/extension/content.js index 4f1577d..58516f1 100644 --- a/extension/content.js +++ b/extension/content.js @@ -444,6 +444,7 @@ let runtimeConfig = null; function requestRuntimeConfig() { if (runtimeConfig) { ensureFloatingSaveButton(); + ensureFloatingCopyButton(); return; } @@ -454,6 +455,7 @@ function requestRuntimeConfig() { setDiscourseDomains(runtimeConfig.discourse_domains); } ensureFloatingSaveButton(); + ensureFloatingCopyButton(); }); } @@ -514,13 +516,15 @@ function findCurrentLinuxDoPost() { /** * 飞书虚拟渲染应对:滚动收集所有 block 元素。 * 飞书用 IntersectionObserver 按需渲染,视口外的 block 会被替换为 placeholder。 - * 我们通过滚动 .bear-web-x-container 逐步让每个区域进入视口, + * 我们通过滚动容器逐步让每个区域进入视口, * 对每个出现的 block 按 data-block-id 去重收集,最终返回完整的 block 数组。 */ async function scrollAndCollectFeishuBlocks() { - const container = document.querySelector(".bear-web-x-container"); + // 使用通用容器查找(支持 docx/wiki/minutes 等多种页面) + const container = findFeishuScrollContainer(document) || + document.querySelector(".bear-web-x-container"); if (!container) { - console.log("[x2md] 未找到 .bear-web-x-container,跳过滚动收集"); + console.log("[x2md] 未找到可滚动容器,跳过滚动收集"); return null; } @@ -528,11 +532,22 @@ async function scrollAndCollectFeishuBlocks() { const originalScrollTop = container.scrollTop; function collect() { - const blocks = document.querySelectorAll("#docx .block[data-block-type]"); - for (const block of blocks) { - const id = block.getAttribute("data-block-id"); - if (!id || seen.has(id)) continue; - seen.set(id, block.cloneNode(true)); + // 扩大选择器范围:兼容 docx blocks + 通用内容块 + const selectors = [ + "#docx .block[data-block-type]", + ".block[data-block-type]", + "[class*='transcript-item']", + "[class*='subtitle-item']", + ]; + for (const sel of selectors) { + const blocks = document.querySelectorAll(sel); + for (const block of blocks) { + const id = block.getAttribute("data-block-id") || + block.getAttribute("data-id") || + ("pos_" + block.getBoundingClientRect().top.toFixed(0)); + if (!id || seen.has(id)) continue; + seen.set(id, block.cloneNode(true)); + } } } @@ -547,13 +562,10 @@ async function scrollAndCollectFeishuBlocks() { container.scrollTop = pos; await new Promise((r) => setTimeout(r, 100)); collect(); - // 检查是否真的滚到了(scrollHeight 可能在增长) const maxScroll = container.scrollHeight - container.clientHeight; if (container.scrollTop >= maxScroll - 10) { stuckCount++; - // scrollHeight 可能因为新渲染而增长,给它一点时间 await new Promise((r) => setTimeout(r, 150)); - // 更新 pos 到当前实际位置,防止 scrollHeight 增长后漏掉 pos = container.scrollTop; } else { stuckCount = 0; @@ -583,11 +595,45 @@ async function scrollAndCollectFeishuBlocks() { } function captureFeishuDocument() { - showToast("正在通过 API 读取飞书文档…", "loading", null); + // 第一步:解除复制保护(在所有策略之前执行) + neutralizeFeishuCopyProtection(document); + const pageUrl = location.href; const options = { pageUrl }; + const pageType = detectFeishuPageType(location); + + // 智能纪要走单独路径(没有 docx block 结构) + if (pageType === "minutes") { + showToast("正在提取飞书智能纪要…", "loading", null); + // 先尝试直接提取 + const minutesData = extractFeishuMinutesData(document, options); + if (minutesData && minutesData.article_content.length >= 100) { + showToast("正在保存智能纪要…", "loading", null); + sendToBackground(minutesData); + return; + } + // 不够长:滚动加载更多内容后重试 + showToast("正在滚动加载完整纪要…", "loading", null); + scrollAndCollectFeishuBlocks().then(() => { + const retryData = extractFeishuMinutesData(document, options); + if (retryData && retryData.article_content.length >= 50) { + showToast("正在保存智能纪要…", "loading", null); + sendToBackground(retryData); + } else { + showToast("智能纪要提取失败", "error", 4000); + } + }).catch((err) => { + console.error("[x2md] Minutes 滚动提取出错:", err); + showToast("智能纪要提取失败", "error", 4000); + }); + return; + } - // 策略1:通过飞书内部 JSON API 直接读取(无需滚动) + // ─── docx / wiki 文档:三策略流程(已修复优先级) ─── + + showToast("正在通过 API 读取飞书文档…", "loading", null); + + // 策略1:通过飞书内部 JSON API 直接读取(最完整,不受滚动/权限影响) fetchFeishuDocViaApi(pageUrl).then((apiData) => { if (apiData) { const apiContent = convertFeishuApiDataToMarkdown(apiData); @@ -595,19 +641,13 @@ function captureFeishuDocument() { console.log("[x2md] Feishu 文档(JSON API 读取成功,长度=" + apiContent.length + ")"); return finishFeishuCapture(apiContent); } - console.log("[x2md] Feishu JSON API 返回内容太短,尝试 DOM 提取"); + console.log("[x2md] Feishu JSON API 返回内容太短(" + (apiContent?.length || 0) + "),降级到滚动收集"); } else { - console.log("[x2md] Feishu JSON API 不可用,尝试 DOM 提取"); - } - - // 策略2:直接从当前 DOM 提取(不滚动,拿到多少算多少) - const domData = extractFeishuDocumentData(document, options); - if (domData && domData.article_content && domData.article_content.length >= 50) { - console.log("[x2md] Feishu 文档(DOM 直接提取,长度=" + domData.article_content.length + ")"); - return finishFeishuCapture(domData.article_content); + console.log("[x2md] Feishu JSON API 不可用(可能权限限制),降级到滚动收集"); } - // 策略3:滚动收集(最后兜底) + // 策略2(原策略3):直接跳到滚动收集 — 跳过 DOM 直读 + // 原因:DOM 直读受虚拟滚动限制,只能拿到一屏内容,极易误判为"成功" showToast("正在滚动页面加载全部内容…", "loading", null); scrollAndCollectFeishuBlocks().then((collectedBlocks) => { let scrollContent = null; @@ -616,10 +656,18 @@ function captureFeishuDocument() { } if (scrollContent && scrollContent.length >= 50) { - console.log("[x2md] Feishu 文档(滚动收集 " + collectedBlocks.length + " blocks)"); + console.log("[x2md] Feishu 文档(滚动收集 " + collectedBlocks.length + " blocks,长度=" + scrollContent.length + ")"); return finishFeishuCapture(scrollContent); } + // 策略3(兜底):如果滚动收集也失败了,尝试 DOM 直读(聊胜于无) + console.log("[x2md] 滚动收集失败,最后尝试 DOM 直读"); + const domData = extractFeishuDocumentData(document, options); + if (domData && domData.article_content && domData.article_content.length >= 30) { + console.log("[x2md] Feishu 文档(DOM 兜底提取,长度=" + domData.article_content.length + ")"); + return finishFeishuCapture(domData.article_content); + } + showToast("飞书文档提取失败", "error", 4000); }).catch((err) => { console.error("[x2md] Feishu 滚动收集出错:", err); @@ -955,12 +1003,223 @@ function ensureFloatingSaveButton() { btn.style.boxShadow = `0 10px 24px ${siteConfig.shadow}`; } +// ───────────────────────────────────────────── +// 一键复制按钮(飞书文档解锁复制) +// ───────────────────────────────────────────── +const COPY_FLOATING_BUTTON_ID = "__x2md_copy_button"; + +function ensureFloatingCopyButton() { + const pageType = typeof detectFeishuPageType === "function" ? detectFeishuPageType(location) : null; + const enabled = runtimeConfig && runtimeConfig.enable_copy_unlock; + let btn = document.getElementById(COPY_FLOATING_BUTTON_ID); + + if (!pageType || !enabled) { + btn?.remove(); + return; + } + + if (btn) return; // 已存在 + + btn = document.createElement("button"); + btn.id = COPY_FLOATING_BUTTON_ID; + btn.type = "button"; + btn.textContent = "\uD83D\uDCCB"; // 📋 + btn.title = "一键复制全部内容(Markdown 格式)"; + Object.assign(btn.style, { + position: "fixed", + top: "148px", + right: "24px", + width: "42px", + height: "42px", + border: "none", + borderRadius: "999px", + background: "#8b5cf6", + color: "#fff", + fontSize: "18px", + cursor: "pointer", + zIndex: "2147483646", + boxShadow: "0 10px 24px rgba(139, 92, 246, 0.35)", + transition: "transform .15s ease, opacity .15s ease", + display: "flex", + alignItems: "center", + justifyContent: "center", + }); + btn.addEventListener("mouseenter", () => { btn.style.transform = "translateY(-1px)"; }); + btn.addEventListener("mouseleave", () => { btn.style.transform = "translateY(0)"; }); + btn.addEventListener("click", handleCopyFullContent); + document.body.appendChild(btn); +} + +let _copyInProgress = false; + +async function handleCopyFullContent() { + if (_copyInProgress) return; // 防止重复点击 + _copyInProgress = true; + + const pageType = detectFeishuPageType(location); + if (!pageType) { _copyInProgress = false; return; } + + // 解除复制保护 + neutralizeFeishuCopyProtection(document); + + showToast("正在提取全部内容…", "loading", null); + + try { + let markdown = ""; + const pageUrl = location.href; + const options = { pageUrl }; + + if (pageType === "minutes") { + // 智能纪要 + let data = extractFeishuMinutesData(document, options); + if (!data || !data.article_content || data.article_content.length < 100) { + await scrollAndCollectFeishuBlocks(); + data = extractFeishuMinutesData(document, options); + } + if (data && data.article_content) { + const safeTitle = data.article_title ? data.article_title.replace(/#/g, "\\#") : ""; + const title = safeTitle ? `# ${safeTitle}\n\n` : ""; + markdown = title + data.article_content; + } + } else { + // docx / wiki / docs / sheets / mindnotes + // 策略1: API + let content = null; + try { + const apiData = await fetchFeishuDocViaApi(pageUrl); + if (apiData) { + content = convertFeishuApiDataToMarkdown(apiData); + if (content && content.length < 50) content = null; + } + } catch (e) { + console.log("[x2md copy] API fallback:", e); + } + + // 策略2: 滚动收集 + if (!content) { + const blocks = await scrollAndCollectFeishuBlocks(); + if (blocks && blocks.length > 0) { + content = extractFeishuMarkdownFromBlocks(blocks, options); + if (content && content.length < 50) content = null; + } + } + + // 策略3: DOM 兜底 + if (!content) { + const domData = extractFeishuDocumentData(document, options); + if (domData && domData.article_content) { + content = domData.article_content; + } + } + + if (content) { + let title = extractFeishuTitle(document); + if (title) title = title.replace(/#/g, "\\#"); + markdown = (title ? `# ${title}\n\n` : "") + content; + } + } + + if (!markdown || markdown.length < 20) { + showToast("内容提取失败,请稍后重试", "error", 4000); + _copyInProgress = false; + return; + } + + // 处理图片:确保所有图片都是 ![](url) 格式(保留原始链接) + // 处理视频:将视频链接转为 iframe 嵌入 + markdown = postProcessMarkdownForClipboard(markdown, pageUrl); + + // 写入剪贴板(带 fallback,因为 async 操作后 user gesture 可能已过期) + const copied = await copyTextToClipboard(markdown); + if (copied) { + showToast("已复制全部内容到剪贴板(Markdown 格式)", "success", 3000); + } else { + showToast("剪贴板写入失败,请重试", "error", 4000); + } + } catch (err) { + console.error("[x2md copy] 复制失败:", err); + showToast("复制失败:" + (err.message || "未知错误"), "error", 4000); + } finally { + _copyInProgress = false; + } +} + +async function copyTextToClipboard(text) { + // 优先使用 Clipboard API + try { + await navigator.clipboard.writeText(text); + return true; + } catch { /* user gesture 可能已过期,降级到 execCommand */ } + + // Fallback: textarea + execCommand(异步操作后 user gesture 失效时使用) + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.cssText = "position:fixed;left:-9999px;top:-9999px;opacity:0;pointer-events:none;"; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + return ok; + } catch { + return false; + } +} + +function postProcessMarkdownForClipboard(md, pageUrl) { + // 0. 将残留的 HTML 标签转为 Markdown 图片语法 + md = md.replace(/]+src=["']([^"']+)["'][^>]*(?:alt=["']([^"']*)["'])?[^>]*\/?>/gi, + (match, src, alt) => `![${alt || ""}](${src})` + ); + // 补充:alt 在 src 之前的情况 + md = md.replace(/]+alt=["']([^"']*)["'][^>]+src=["']([^"']+)["'][^>]*\/?>/gi, + (match, alt, src) => `![${alt || ""}](${src})` + ); + + // 1. 确保图片链接是完整 URL,并转义 URL 中的括号(防止 Markdown 语法断裂) + md = md.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { + if (url.startsWith("//")) url = "https:" + url; + else if (url.startsWith("/")) { + try { + const base = new URL(pageUrl); + url = base.origin + url; + } catch { /* keep as-is */ } + } + // 转义 URL 中的括号,防止 Markdown 渲染断裂 + url = url.replace(/\(/g, "%28").replace(/\)/g, "%29"); + return `![${alt}](${url})`; + }); + + // 2. 查找页面中的视频元素,提取视频 URL 并转为 iframe + const videos = document.querySelectorAll("video[src], video source[src]"); + const videoUrls = new Set(); + videos.forEach(v => { + const src = v.src || v.getAttribute("src"); + if (src) videoUrls.add(src); + }); + + // 将页面视频 URL 追加为 iframe(使用固定像素宽度,OB 兼容性更好) + for (const vUrl of videoUrls) { + if (!md.includes(vUrl)) { + md += `\n\n\n`; + } + } + + // 3. 将已有的裸视频链接转为 iframe + md = md.replace(/(?:^|\n)(https?:\/\/[^\s]+\.mp4[^\s]*)(?:\n|$)/gm, (match, url) => { + return `\n\n`; + }); + + return md; +} + // ───────────────────────────────────────────── // MutationObserver:监听动态加载的推文 // ───────────────────────────────────────────── function bindAll() { document.querySelectorAll(BOOKMARK_SELECTORS).forEach(attachBookmarkListener); ensureFloatingSaveButton(); + ensureFloatingCopyButton(); } let _bindAllTimer = null; diff --git a/extension/feishu.js b/extension/feishu.js index 72ff2e8..3d3a1e2 100644 --- a/extension/feishu.js +++ b/extension/feishu.js @@ -1,4 +1,196 @@ (function (globalScope) { + + // ───────────────────────────────────────────── + // 飞书权限绕过:解除"仅阅读/禁止复制"的前端限制 + // ───────────────────────────────────────────── + + function neutralizeFeishuCopyProtection(doc) { + doc = doc || globalScope.document; + if (!doc || doc.__x2md_protection_neutralized) return; + doc.__x2md_protection_neutralized = true; + + // 1. CSS:强制恢复文字可选中,隐藏复制保护提示/遮罩 + var style = doc.createElement("style"); + style.id = "__x2md_copy_unlock"; + style.textContent = [ + "* { -webkit-user-select: text !important; user-select: text !important; }", + ".copy-forbidden-toast, .copy-protection-mask, .copy-tip-container,", + ".gpf-biz-action-manager-forbidden-placeholder,", + ".suite-permission-toast, .permission-denied-dialog,", + "[class*='copy-forbidden'], [class*='copy-protection'],", + "[class*='permission-toast'], [class*='no-copy'] {", + " display: none !important; visibility: hidden !important; pointer-events: none !important;", + "}", + ].join("\n"); + (doc.head || doc.documentElement).appendChild(style); + + // 2. JS事件:在捕获阶段拦截飞书的复制保护监听器 + var protectedEvents = ["copy", "cut", "contextmenu", "selectstart", "beforecopy"]; + protectedEvents.forEach(function (eventType) { + doc.addEventListener(eventType, function (e) { + e.stopPropagation(); + }, true); + }); + + // 3. 页面上下文注入:清除飞书在 document 上挂的 on* 保护 + var script = doc.createElement("script"); + script.textContent = "(function(){" + + "document.oncopy=null;document.oncut=null;document.onselectstart=null;document.oncontextmenu=null;" + + // 覆盖 ClipboardEvent 阻止逻辑:让 navigator.clipboard 正常工作 + "try{var _dp=Event.prototype.preventDefault;Event.prototype.preventDefault=function(){" + + "if(this instanceof ClipboardEvent||this.type==='copy'||this.type==='cut'||this.type==='selectstart')return;" + + "_dp.call(this);};}catch(e){}" + + "})();"; + (doc.head || doc.documentElement).appendChild(script); + script.remove(); + } + + // ───────────────────────────────────────────── + // 飞书页面类型检测(扩展:支持 wiki/docx/minutes/sheets/mindnotes) + // ───────────────────────────────────────────── + + function detectFeishuPageType(locationLike) { + locationLike = locationLike || globalScope.location; + var hostname = String(locationLike.hostname || "").toLowerCase(); + var pathname = String(locationLike.pathname || ""); + + if (!hostname.endsWith(".feishu.cn") && !hostname.endsWith(".larksuite.com")) return null; + + if (/^\/wiki\/[^/]+/.test(pathname)) return "wiki"; + if (/^\/docx\/[^/]+/.test(pathname)) return "docx"; + if (/^\/minutes\/[^/]+/.test(pathname)) return "minutes"; + if (/^\/sheets\/[^/]+/.test(pathname)) return "sheets"; + if (/^\/mindnotes\/[^/]+/.test(pathname)) return "mindnotes"; + if (/^\/docs\/[^/]+/.test(pathname)) return "docs"; // 旧版飞书文档 + if (/^\/drive\/[^/]+/.test(pathname)) return "drive"; + + return null; + } + + function isFeishuContentPage(locationLike) { + return detectFeishuPageType(locationLike) !== null; + } + + // ───────────────────────────────────────────── + // 飞书智能纪要 (minutes) 提取 + // ───────────────────────────────────────────── + + function extractFeishuMinutesData(doc, options) { + doc = doc || globalScope.document; + options = options || {}; + var pageUrl = options.pageUrl || doc.location?.href || globalScope.location?.href || ""; + + // 智能纪要标题 + var title = ""; + var titleSelectors = [ + ".minutes-title", ".vc-minutes-title", "[class*='minutes-title']", + ".header-title", "h1", "title" + ]; + for (var i = 0; i < titleSelectors.length; i++) { + var el = doc.querySelector(titleSelectors[i]); + if (el) { + var t = cleanZeroWidth(getNodeText(el)).trim(); + if (t && t !== "飞书智能纪要" && t !== "飞书") { title = t; break; } + } + } + if (!title) title = String(doc.title || "").replace(/\s*-\s*飞书.*$/, "").trim(); + + // 纪要内容:多种容器尝试 + var contentParts = []; + + // 方法A:结构化段落(发言人 + 内容) + var speakerBlocks = doc.querySelectorAll( + "[class*='transcript-item'], [class*='speaker-block'], " + + "[class*='subtitle-item'], [class*='minutes-content'] > div" + ); + if (speakerBlocks.length > 0) { + for (var j = 0; j < speakerBlocks.length; j++) { + var block = speakerBlocks[j]; + var speaker = ""; + var speakerEl = block.querySelector("[class*='speaker-name'], [class*='name'], [class*='avatar'] + span"); + if (speakerEl) speaker = cleanZeroWidth(getNodeText(speakerEl)).trim(); + var text = cleanZeroWidth(getNodeText(block)).trim(); + if (speaker && text.startsWith(speaker)) { + text = text.slice(speaker.length).trim(); + } + if (text) { + contentParts.push(speaker ? ("**" + speaker + "**:" + text) : text); + } + } + } + + // 方法B:AI总结区域 + var summarySelectors = [ + "[class*='ai-summary'], [class*='minutes-summary'], [class*='smart-summary']", + "[class*='key-points'], [class*='action-items']" + ]; + for (var k = 0; k < summarySelectors.length; k++) { + var summaryEl = doc.querySelector(summarySelectors[k]); + if (summaryEl) { + var summaryText = cleanZeroWidth(getNodeText(summaryEl)).trim(); + if (summaryText.length > 20) { + contentParts.unshift("## AI 总结\n\n" + summaryText); + } + } + } + + // 方法C:兜底 - 全页面正文区域 + if (contentParts.length === 0) { + var bodySelectors = [ + ".minutes-body", ".vc-minutes-body", "[class*='minutes-content']", + ".main-content", "#content", "main" + ]; + for (var m = 0; m < bodySelectors.length; m++) { + var bodyEl = doc.querySelector(bodySelectors[m]); + if (bodyEl) { + var bodyText = cleanZeroWidth(getNodeText(bodyEl)).trim(); + if (bodyText.length > 50) { + contentParts.push(bodyText); + break; + } + } + } + } + + var articleContent = contentParts.join("\n\n").replace(/\n{3,}/g, "\n\n").trim(); + if (!articleContent || articleContent.length < 30) return null; + + return { + type: "article", + url: cleanFeishuUrl(pageUrl), + author: extractFeishuAuthor(doc), + handle: "", + author_url: "", + published: "", + article_title: title, + article_content: articleContent, + images: [], + videos: [], + platform: "Feishu", + }; + } + + // ───────────────────────────────────────────── + // 飞书通用页面滚动收集(兼容 minutes 等非 docx 页面) + // ───────────────────────────────────────────── + + function findFeishuScrollContainer(doc) { + doc = doc || globalScope.document; + var candidates = [ + ".bear-web-x-container", + ".minutes-body", ".vc-minutes-body", + "[class*='minutes-content']", + ".main-content", + "main", + "#content", + ]; + for (var i = 0; i < candidates.length; i++) { + var el = doc.querySelector(candidates[i]); + if (el && el.scrollHeight > el.clientHeight + 100) return el; + } + return null; + } + function cleanFeishuUrl(url) { if (!url) return ""; try { @@ -31,7 +223,8 @@ function isFeishuWikiOrDocxPage(locationLike = globalScope.location) { const hostname = String(locationLike?.hostname || "").toLowerCase(); const pathname = String(locationLike?.pathname || ""); - return hostname.endsWith(".feishu.cn") && (/^\/wiki\/[^/]+/.test(pathname) || /^\/docx\/[^/]+/.test(pathname)); + return (hostname.endsWith(".feishu.cn") || hostname.endsWith(".larksuite.com")) && + (/^\/wiki\/[^/]+/.test(pathname) || /^\/docx\/[^/]+/.test(pathname)); } function shouldSkipFeishuInlineNode(node) { @@ -562,16 +755,21 @@ const exported = { cleanFeishuUrl, convertFeishuApiDataToMarkdown, + detectFeishuPageType, extractFeishuAuthor, extractFeishuBlockMarkdown, extractFeishuDocToken, extractFeishuDocumentData, extractFeishuInlineMarkdown, extractFeishuMarkdownFromBlocks, + extractFeishuMinutesData, extractFeishuTitle, extractFeishuUpdated, fetchFeishuDocViaApi, + findFeishuScrollContainer, + isFeishuContentPage, isFeishuWikiOrDocxPage, + neutralizeFeishuCopyProtection, }; if (typeof module !== "undefined" && module.exports) { diff --git a/extension/manifest.json b/extension/manifest.json index 9d3d475..11182b7 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -16,6 +16,13 @@ "https://linux.do/*", "https://*.feishu.cn/wiki/*", "https://*.feishu.cn/docx/*", + "https://*.feishu.cn/docs/*", + "https://*.feishu.cn/minutes/*", + "https://*.feishu.cn/sheets/*", + "https://*.feishu.cn/mindnotes/*", + "https://*.larksuite.com/wiki/*", + "https://*.larksuite.com/docx/*", + "https://*.larksuite.com/minutes/*", "https://mp.weixin.qq.com/*", "http://127.0.0.1/*", "https://cdn.syndication.twimg.com/*", @@ -36,6 +43,13 @@ "https://linux.do/*", "https://*.feishu.cn/wiki/*", "https://*.feishu.cn/docx/*", + "https://*.feishu.cn/docs/*", + "https://*.feishu.cn/minutes/*", + "https://*.feishu.cn/sheets/*", + "https://*.feishu.cn/mindnotes/*", + "https://*.larksuite.com/wiki/*", + "https://*.larksuite.com/docx/*", + "https://*.larksuite.com/minutes/*", "https://mp.weixin.qq.com/*" ], "js": [ diff --git a/extension/options.html b/extension/options.html index f32db58..9764ab6 100644 --- a/extension/options.html +++ b/extension/options.html @@ -716,6 +716,33 @@

评论/回复提取

+ +
+
+
📋
+
+

飞书文档一键复制

+

在飞书仅阅读/禁止复制的文档页面显示复制按钮,一键复制全部内容到剪贴板

+
+
+
+
+
启用功能
+
+ + +
+
+
+ 开启后,飞书知识库、云文档、智能纪要等页面右上角会出现 📋 复制按钮。
+ 点击后自动提取全部内容(含图片链接和视频 iframe),复制为 Markdown 格式。
+ 粘贴到 Obsidian 后,图片可通过链接预览,视频以 iframe 方式嵌入。 +
+
+
+
diff --git a/extension/options.js b/extension/options.js index c22565f..b2215a5 100644 --- a/extension/options.js +++ b/extension/options.js @@ -54,6 +54,7 @@ function applyConfigToUI(cfg) { document.getElementById("videoSavePath").value = cfg.video_save_path || ""; document.getElementById("videoDurationThreshold").value = cfg.video_duration_threshold || 5; document.getElementById("showSiteSaveIcon").checked = cfg.show_site_save_icon !== false; + document.getElementById("enableCopyUnlock").checked = !!cfg.enable_copy_unlock; // 平台分类文件夹(V1.2) document.getElementById("enablePlatformFolders").checked = cfg.enable_platform_folders !== false; @@ -324,6 +325,7 @@ function saveConfig() { const videoSavePath = document.getElementById("videoSavePath").value.trim(); const videoDurationThreshold = parseFloat(document.getElementById("videoDurationThreshold").value) || 5; const showSiteSaveIcon = document.getElementById("showSiteSaveIcon").checked; + const enableCopyUnlock = document.getElementById("enableCopyUnlock").checked; const syncEnabled = document.getElementById("syncEnabled").checked; // 平台分类文件夹(V1.2) @@ -369,6 +371,7 @@ function saveConfig() { video_save_path: videoSavePath, video_duration_threshold: videoDurationThreshold, show_site_save_icon: showSiteSaveIcon, + enable_copy_unlock: enableCopyUnlock, sync_enabled: syncEnabled, enable_platform_folders: enablePlatformFolders, platform_folder_names: platformFolderNames, diff --git a/extension/site_actions.js b/extension/site_actions.js index 47028b5..0cb1982 100644 --- a/extension/site_actions.js +++ b/extension/site_actions.js @@ -13,7 +13,10 @@ return "linux_do"; } - if (hostname.endsWith(".feishu.cn") && (/^\/wiki\/[^/]+/.test(pathname) || /^\/docx\/[^/]+/.test(pathname))) { + if ((hostname.endsWith(".feishu.cn") || hostname.endsWith(".larksuite.com")) && + (/^\/wiki\/[^/]+/.test(pathname) || /^\/docx\/[^/]+/.test(pathname) || + /^\/docs\/[^/]+/.test(pathname) || /^\/minutes\/[^/]+/.test(pathname) || + /^\/sheets\/[^/]+/.test(pathname) || /^\/mindnotes\/[^/]+/.test(pathname))) { return "feishu"; } From b9187999f8909266f1d0d710ae40c6c3a5dbbb99 Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 04:03:17 +0000 Subject: [PATCH 26/48] chore: bump version to v1.4.0 Co-Authored-By: Claude Opus 4.6 --- extension/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/manifest.json b/extension/manifest.json index 11182b7..409697e 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "X2MD - 网页内容转Markdown", - "version": "1.3.0", + "version": "1.4.0", "description": "支持 X、LINUX DO、飞书知识库、飞书云文档、微信公众号的一键保存 Markdown,支持评论提取、iframe嵌入、Discourse多域名", "permissions": [ "storage", From 75a3f84b8c86477ee31ce8d7854a54c542715ad4 Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 04:14:53 +0000 Subject: [PATCH 27/48] =?UTF-8?q?feat:=20Discourse=E7=82=B9=E8=B5=9E?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E5=8D=95=E5=87=BB=E4=BF=9D=E5=AD=98/?= =?UTF-8?q?=E5=8F=8C=E5=87=BB=E7=82=B9=E8=B5=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单击点赞按钮:拦截点赞行为,自动保存帖子到OB 双击点赞按钮:恢复原始点赞功能,不保存 Co-Authored-By: Claude Opus 4.6 --- extension/content.js | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/extension/content.js b/extension/content.js index 58516f1..4b0516f 100644 --- a/extension/content.js +++ b/extension/content.js @@ -1231,13 +1231,46 @@ function bindAllDebounced() { }, 200); } -let _likeSaveTimer = null; +// Discourse 点赞按钮:单击 = 保存到 OB(拦截点赞),双击 = 正常点赞(不保存) +let _likeClickCount = 0; +let _likeClickTimer = null; +let _likeClickBtn = null; +let _likePassThrough = false; // 标记:放行重放的点击,防止循环拦截 + document.addEventListener("click", (event) => { if (!isDiscourseTopicPage()) return; const btn = event.target?.closest?.(LINUX_DO_LIKE_SELECTOR); if (!btn) return; - clearTimeout(_likeSaveTimer); - _likeSaveTimer = setTimeout(() => captureLinuxDoPost(btn), 500); + + // 放行标记:双击重放的点击不拦截 + if (_likePassThrough) { + _likePassThrough = false; + return; + } + + // 拦截本次点击 + event.preventDefault(); + event.stopPropagation(); + + _likeClickCount++; + _likeClickBtn = btn; + + clearTimeout(_likeClickTimer); + _likeClickTimer = setTimeout(() => { + const clicks = _likeClickCount; + const targetBtn = _likeClickBtn; + _likeClickCount = 0; + _likeClickBtn = null; + + if (clicks >= 2) { + // 双击:恢复点赞,不保存 + _likePassThrough = true; + targetBtn.click(); + } else { + // 单击:保存到 OB,不点赞 + captureLinuxDoPost(targetBtn); + } + }, 300); }, true); const observer = new MutationObserver(bindAllDebounced); From d1dfe1b2fd386b01ca8e1ee8f408378f7ef81f5a Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 04:15:48 +0000 Subject: [PATCH 28/48] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0Discourse?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E4=BF=9D=E5=AD=98=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一帖子/评论在当前会话内只保存一次,重复点击会提示"已保存过" Co-Authored-By: Claude Opus 4.6 --- extension/content.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/extension/content.js b/extension/content.js index 4b0516f..0b87e8b 100644 --- a/extension/content.js +++ b/extension/content.js @@ -459,12 +459,21 @@ function requestRuntimeConfig() { }); } +// 重复保存保护:记录本次会话已保存的 post-id,防止重复保存 +const _savedPostIds = new Set(); + async function captureLinuxDoPostElement(post) { if (!post) { showToast("未找到对应帖子内容", "error", 3500); return; } + const postId = post.getAttribute("data-post-id"); + if (postId && _savedPostIds.has(postId)) { + showToast("该内容已保存过,无需重复保存", "info", 2000); + return; + } + const topicTitle = document.querySelector("h1 a")?.innerText?.trim() || document.querySelector("h1")?.innerText?.trim() || @@ -498,6 +507,9 @@ async function captureLinuxDoPostElement(post) { console.log(`[x2md] ${siteName} 帖子:`, { url: data.url, author: data.author, title: data.article_title }); showToast(`正在保存 ${siteName} 帖子…`, "loading", null); sendToBackground(data); + + // 标记已保存,防止重复 + if (postId) _savedPostIds.add(postId); } function captureLinuxDoPost(btn) { From e92f0ef7cbd9acc4ea9e607d04daf419cb7a1d04 Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 04:17:59 +0000 Subject: [PATCH 29/48] =?UTF-8?q?feat:=20=E8=AF=84=E8=AE=BA=E7=82=B9?= =?UTF-8?q?=E8=B5=9E=E4=BF=9D=E5=AD=98=E9=80=BB=E8=BE=91=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 点赞主帖:保存主帖 + 全部评论(不变) - 点赞评论:保存主帖 + 该评论 + 回复该评论的所有内容 - 不再保存不相关的评论 Co-Authored-By: Claude Opus 4.6 --- extension/content.js | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/extension/content.js b/extension/content.js index 0b87e8b..23324d7 100644 --- a/extension/content.js +++ b/extension/content.js @@ -474,12 +474,18 @@ async function captureLinuxDoPostElement(post) { return; } + // 判断点赞的是主帖(#1)还是评论 + const postNumberMatch = String(post.id || "").match(/post_(\d+)/); + const clickedPostNumber = postNumberMatch ? Number(postNumberMatch[1]) : 1; + const topicTitle = document.querySelector("h1 a")?.innerText?.trim() || document.querySelector("h1")?.innerText?.trim() || document.title.replace(/\s*-\s*LINUX DO.*$/, "").trim(); - const data = extractLinuxDoPostData(post, { + // 始终提取主帖内容(#1) + const mainPost = document.getElementById("post_1") || document.querySelector("article[data-post-id]"); + const data = extractLinuxDoPostData(mainPost, { pageUrl: location.href, topicTitle, }); @@ -489,14 +495,26 @@ async function captureLinuxDoPostElement(post) { return; } - // 评论/回复提取(通过 Discourse JSON API) + // 通过 Discourse JSON API 获取回复数据 const topicMatch = location.pathname.match(/^\/t\/[^/]+\/(\d+)/); if (topicMatch) { try { - const replies = await fetchDiscourseReplies(topicMatch[1], location.hostname); - if (replies && replies.length > 0) { - data.comments = replies; - console.log(`[x2md] 获取到 ${replies.length} 条 Discourse 回复`); + const allReplies = await fetchDiscourseReplies(topicMatch[1], location.hostname); + if (allReplies && allReplies.length > 0) { + if (clickedPostNumber <= 1) { + // 点赞主帖:附带全部评论 + data.comments = allReplies; + console.log(`[x2md] 主帖保存,附带 ${allReplies.length} 条回复`); + } else { + // 点赞评论:只保留该评论 + 回复该评论的内容 + const likedComment = allReplies.find(r => r.floor === clickedPostNumber); + const repliesToComment = allReplies.filter(r => r.reply_to === clickedPostNumber); + const filtered = []; + if (likedComment) filtered.push(likedComment); + filtered.push(...repliesToComment); + data.comments = filtered; + console.log(`[x2md] 评论 #${clickedPostNumber} 保存,附带 ${repliesToComment.length} 条回复`); + } } } catch (e) { console.warn("[x2md] 获取 Discourse 回复失败:", e); From 3ce1b057ddb99b9e9bd13d0952aca145b871c7df Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 04:25:14 +0000 Subject: [PATCH 30/48] =?UTF-8?q?fix:=20=E5=8F=91=E5=B8=83=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=BD=AC=E5=8C=97=E4=BA=AC=E6=97=B6=E9=97=B4(UTC+8)?= =?UTF-8?q?=20+=20=E5=B9=B3=E5=8F=B0=E5=90=8D=E7=BB=9F=E4=B8=80=E4=B8=AD?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server.py: _normalize_publish_time 支持ISO8601/RFC2822/通用格式转北京时间 - 平台名: Feishu→飞书, WeChat→微信公众号, LinuxDo→LINUX DO - options.html: 平台文件夹配置同步更新 Co-Authored-By: Claude Opus 4.6 --- extension/content.js | 2 +- extension/discourse.js | 2 +- extension/feishu.js | 4 ++-- extension/options.html | 10 +++++----- extension/wechat.js | 2 +- server.py | 15 +++++++++++---- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/extension/content.js b/extension/content.js index 23324d7..8fad70a 100644 --- a/extension/content.js +++ b/extension/content.js @@ -722,7 +722,7 @@ function captureFeishuDocument() { article_content: articleContent, images: [], videos: [], - platform: "Feishu", + platform: "飞书", }; showToast("正在保存飞书文档…", "loading", null); diff --git a/extension/discourse.js b/extension/discourse.js index fbce2ee..347e63e 100644 --- a/extension/discourse.js +++ b/extension/discourse.js @@ -349,7 +349,7 @@ function getDiscoursePlatformName() { const host = String(globalScope.location?.hostname || "").toLowerCase(); - if (host === "linux.do") return "LinuxDo"; + if (host === "linux.do") return "LINUX DO"; // 其他 Discourse 站点用域名作为平台名 return host.replace(/\./g, "_") || "Discourse"; } diff --git a/extension/feishu.js b/extension/feishu.js index 3d3a1e2..d9bc549 100644 --- a/extension/feishu.js +++ b/extension/feishu.js @@ -166,7 +166,7 @@ article_content: articleContent, images: [], videos: [], - platform: "Feishu", + platform: "飞书", }; } @@ -507,7 +507,7 @@ article_content: articleContent, images: [], videos: [], - platform: "Feishu", + platform: "飞书", }; } diff --git a/extension/options.html b/extension/options.html index 9764ab6..06315b5 100644 --- a/extension/options.html +++ b/extension/options.html @@ -520,12 +520,12 @@

平台分类文件夹

Twitter/X: - LinuxDo: - - 飞书 Feishu: - + LINUX DO: + + 飞书: + 微信公众号: - +
diff --git a/extension/wechat.js b/extension/wechat.js index 08879f1..be7d48d 100644 --- a/extension/wechat.js +++ b/extension/wechat.js @@ -344,7 +344,7 @@ article_content: articleContent, images: [], videos: [], - platform: "WeChat", + platform: "微信公众号", }; } diff --git a/server.py b/server.py index 9b5c35b..84f9be2 100644 --- a/server.py +++ b/server.py @@ -265,27 +265,34 @@ def _guess_image_ext(url: str) -> str: def _normalize_publish_time(raw: str) -> str: - """将各平台不同格式的发布时间统一为 YYYY-MM-DD HH:MM 或 YYYY-MM-DD""" + """将各平台不同格式的发布时间统一为北京时间 YYYY-MM-DD HH:MM""" if not raw or not raw.strip(): return "" raw = raw.strip() + from zoneinfo import ZoneInfo + beijing = ZoneInfo("Asia/Shanghai") + # Twitter RFC 2822: "Wed Mar 25 10:00:00 +0000 2026" try: from email.utils import parsedate_to_datetime dt = parsedate_to_datetime(raw) + dt = dt.astimezone(beijing) return dt.strftime("%Y-%m-%d %H:%M") except Exception: pass - # ISO 8601 with timezone offset: "2026-03-25T10:00:00+08:00" + # ISO 8601 with timezone offset: "2026-03-25T10:00:00+08:00" / "...Z" try: dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) + dt = dt.astimezone(beijing) return dt.strftime("%Y-%m-%d %H:%M") except (ValueError, AttributeError): pass - # 其他常见格式 + # 其他常见格式(无时区信息,假定为 UTC) for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"): try: - dt = datetime.strptime(raw, fmt) + from datetime import timezone + dt = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc) + dt = dt.astimezone(beijing) return dt.strftime("%Y-%m-%d %H:%M") except ValueError: continue From fd440fd6785074feb30735c86b24a82c5b048f22 Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 04:33:35 +0000 Subject: [PATCH 31/48] =?UTF-8?q?feat:=20=E8=87=AA=E5=8A=A8=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E6=A0=87=E7=AD=BE=E5=86=99=E5=85=A5frontmatter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Twitter/X: 从GraphQL API提取推文#hashtags - LINUX DO/Discourse: 从topic JSON提取帖子标签(tags) - 微信公众号: 从og:article:tag meta标签提取 - server.py: 合并tags+hashtags去重,写入frontmatter tags字段 Co-Authored-By: Claude Opus 4.6 --- extension/background.js | 14 ++++++++++++++ extension/content.js | 6 +++++- extension/discourse.js | 5 ++++- extension/wechat.js | 8 ++++++++ server.py | 10 +++++++++- 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/extension/background.js b/extension/background.js index 36c07b7..d37a19f 100644 --- a/extension/background.js +++ b/extension/background.js @@ -507,6 +507,19 @@ function parseLegacyTweet(result, userLegacy) { const handle = userLegacy?.screen_name ? "@" + userLegacy.screen_name : ""; const published = legacy.created_at || ""; + // 提取推文 hashtags + const hashtags = []; + const hashtagEntities = legacy.entities?.hashtags || []; + for (const h of hashtagEntities) { + if (h.text) hashtags.push(h.text); + } + // note_tweet 也可能有 hashtags + if (noteTweetResult?.entity_set?.hashtags) { + for (const h of noteTweetResult.entity_set.hashtags) { + if (h.text && !hashtags.includes(h.text)) hashtags.push(h.text); + } + } + return { text, images: Array.from(new Set(images)), @@ -515,6 +528,7 @@ function parseLegacyTweet(result, userLegacy) { author, handle, published, + hashtags, }; } diff --git a/extension/content.js b/extension/content.js index 8fad70a..cfdd7b6 100644 --- a/extension/content.js +++ b/extension/content.js @@ -495,11 +495,15 @@ async function captureLinuxDoPostElement(post) { return; } - // 通过 Discourse JSON API 获取回复数据 + // 通过 Discourse JSON API 获取回复数据 + 帖子标签 const topicMatch = location.pathname.match(/^\/t\/[^/]+\/(\d+)/); if (topicMatch) { try { const allReplies = await fetchDiscourseReplies(topicMatch[1], location.hostname); + // 提取 topic 级别标签 + if (allReplies._topicTags && allReplies._topicTags.length > 0) { + data.tags = allReplies._topicTags; + } if (allReplies && allReplies.length > 0) { if (clickedPostNumber <= 1) { // 点赞主帖:附带全部评论 diff --git a/extension/discourse.js b/extension/discourse.js index 347e63e..2458308 100644 --- a/extension/discourse.js +++ b/extension/discourse.js @@ -367,7 +367,7 @@ const data = await resp.json(); const posts = data.post_stream?.posts || []; // posts[0] 是主帖,posts[1:] 是回复 - return posts.slice(1).map(p => ({ + const replies = posts.slice(1).map(p => ({ floor: p.post_number, author: p.username || "匿名", content: cookedHtmlToMarkdown(p.cooked || "", `https://${host}/t/${topicId}`), @@ -375,6 +375,9 @@ likes: p.like_count || 0, reply_to: p.reply_to_post_number || null, })); + // 附带 topic 级别的标签 + replies._topicTags = Array.isArray(data.tags) ? data.tags : []; + return replies; } // 向后兼容别名 diff --git a/extension/wechat.js b/extension/wechat.js index be7d48d..7318d75 100644 --- a/extension/wechat.js +++ b/extension/wechat.js @@ -333,6 +333,13 @@ const title = extractWechatTitle(doc); const author = extractWechatAuthor(doc); + // 提取 og:article:tag 标签 + const tags = []; + doc.querySelectorAll?.('meta[property="og:article:tag"], meta[property="article:tag"]').forEach(el => { + const tag = safeGetAttribute(el, "content"); + if (tag && tag.trim()) tags.push(tag.trim()); + }); + return { type: "article", url: cleanWechatUrl(pageUrl), @@ -345,6 +352,7 @@ images: [], videos: [], platform: "微信公众号", + tags, }; } diff --git a/server.py b/server.py index 84f9be2..c4abff6 100644 --- a/server.py +++ b/server.py @@ -370,6 +370,12 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list]: thread_tweets = data.get("thread_tweets", []) # 线程推文列表 platform = data.get("platform", "Twitter/X") + # 标签:优先使用平台提供的 tags,其次使用 hashtags(Twitter) + raw_tags = data.get("tags", []) or [] + raw_hashtags = data.get("hashtags", []) or [] + # 合并去重 + all_tags = list(dict.fromkeys(raw_tags + raw_hashtags)) + now = datetime.now() date_str = now.strftime("%Y-%m-%d") datetime_str = now.strftime("%Y-%m-%d %H:%M") @@ -414,9 +420,11 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list]: if not author_url and handle: author_url = f"https://x.com/{handle.lstrip('@')}" + tags_yaml = "[" + ", ".join(f'"{t}"' for t in all_tags) + "]" if all_tags else "[]" + front_matter = f"""--- 标题: "{title}" -tags: [] +tags: {tags_yaml} 源: "{url}" 作者主页: "{author_url}" 创建时间: "{datetime_str}" From f40d2de1dbf2148859dae2253219a49b456e3082 Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 05:38:00 +0000 Subject: [PATCH 32/48] =?UTF-8?q?feat:=20=E5=A4=9A=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=EF=BC=88=E9=A3=9E=E4=B9=A6/Notion/HTML?= =?UTF-8?q?=EF=BC=89=20+=20=E5=85=A8=E9=9D=A2=E9=94=99=E8=AF=AF=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 飞书多维表格保存(含附件上传 MD/HTML) - Notion Database 保存(含内容块批量写入) - HTML 文件导出(Markdown 转 HTML,GitHub 风格样式) - 三套主题的 Options 设置页重构 - manifest v1.5.0,新增 downloads/飞书/Notion host 权限 架构重构: - 提取 dispatchMultiTargetSave() 共享函数,save_tweet 和 force_save_tweet 统一调用 - dispatchMultiTargetSave 支持传入已获取的 cfg,避免重复网络请求 - 显式传递 cfg 字段到各 handler,防止 Object spread 键冲突 - content.js handleSaveResponse 支持多目标结果格式 错误处理审计修复(10项): - [HIGH] 修复飞书 Token 过期重试死代码(classifyFeishuError 返回中文但匹配英文) - [MED] get_config 增加 resp.ok 检查,防止非 JSON 响应 SyntaxError - [MED] dispatchMultiTargetSave 使用浅拷贝 enriched,不再原地污染调用方 data - [MED] 所有目标禁用时返回明确错误信息而非静默 success:false - [MED] handleSaveToFeishu/Notion 增加必填字段前置验证 - [LOW] handleDownloadHtml 空文件名回退 untitled - [LOW] convertMarkdownToHtml 未关闭代码块自动补全 + 500KB 截断保护 - [LOW] HTML 导出 btoa 超 2MB data URL 安全检查 - [LOW] 消息 handler 清理 message.data||message 为 message.data||{} - [LOW] Notion page.id 创建后 null 检查 Co-Authored-By: Claude Opus 4.6 --- extension/background.js | 793 +++++++++++++++++++++- extension/content.js | 36 +- extension/manifest.json | 12 +- extension/options.html | 1422 ++++++++++++++++++--------------------- extension/options.js | 545 +++++++++------ 5 files changed, 1825 insertions(+), 983 deletions(-) diff --git a/extension/background.js b/extension/background.js index d37a19f..24e922a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,10 +1,12 @@ /** - * background.js - X2MD Service Worker v1.3 + * background.js - X2MD Service Worker v1.5 * * 获取完整推文的 3 层策略(依次降级): * 1. Twitter GraphQL API (TweetDetail) — 携带 cookie,获取最完整数据 * 2. Twitter oEmbed API — 公开接口,无需认证,可获取完整文字 * 3. DOM 原始数据(content.js 采集到的)— 最后兜底 + * + * 多目标保存:Obsidian (本地服务) / 飞书多维表格 / Notion Database / HTML文件 */ importScripts("media_helpers.js"); @@ -604,6 +606,700 @@ async function fetchFullTweetData(tweetData) { }; } +// ═══════════════════════════════════════════════ +// 通用工具:fetchWithRetry(429 退避 + 超时 + 友好错误) +// ═══════════════════════════════════════════════ +async function fetchWithRetry(url, options = {}, maxRetries = 2) { + let lastError; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + clearTimeout(timeout); + if (response.status === 429 && attempt < maxRetries) { + const retryAfter = response.headers.get("Retry-After"); + const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 1000 * Math.pow(2, attempt + 1); + console.warn(`[x2md] API 429 限流,${waitTime}ms 后重试 (${attempt + 1}/${maxRetries})`); + await new Promise(r => setTimeout(r, waitTime)); + continue; + } + return response; + } catch (err) { + clearTimeout(timeout); + lastError = err; + if (err.name === "AbortError") throw new Error("请求超时(30秒),请检查网络"); + if (attempt < maxRetries) await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); + } + } + throw new Error(lastError?.message || "网络请求失败"); +} + +// ═══════════════════════════════════════════════ +// 飞书多维表格 API +// ═══════════════════════════════════════════════ +const FEISHU_API_DOMAINS = { + feishu: "https://open.feishu.cn", + lark: "https://open.larksuite.com", +}; + +let feishuTokenCache = { feishu: { token: null, expireAt: 0 }, lark: { token: null, expireAt: 0 } }; + +async function getFeishuAccessToken(appId, appSecret, apiDomain) { + const domainKey = apiDomain === "lark" ? "lark" : "feishu"; + const cached = feishuTokenCache[domainKey]; + if (cached.token && cached.expireAt > Date.now()) return cached.token; + + const baseUrl = FEISHU_API_DOMAINS[domainKey]; + const resp = await fetchWithRetry(`${baseUrl}/open-apis/auth/v3/tenant_access_token/internal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ app_id: appId, app_secret: appSecret }), + }, 1); + + const data = await resp.json(); + if (data.code !== 0 || !data.tenant_access_token) { + if (data.code === 10003 || data.code === 10014) throw new Error("飞书 App ID 或 App Secret 无效"); + throw new Error(`飞书认证失败: ${data.msg || "未知错误"} (code: ${data.code})`); + } + + feishuTokenCache[domainKey] = { token: data.tenant_access_token, expireAt: Date.now() + (data.expire - 300) * 1000 }; + return data.tenant_access_token; +} + +function classifyFeishuError(code, msg) { + if (code === 1254043 || code === 1254044) return "飞书多维表格不存在或无权限,请检查 App Token 和 Table ID"; + if (code === 1254001) return "飞书 Token 已过期"; + if (code === 1254607) return "数据表不存在,请检查 table_id(以 tbl 开头)"; + if (code === 99991668 || code === 99991672) return "飞书 API 请求过于频繁,请稍后重试"; + return `飞书操作失败 (${code}): ${msg}`; +} + +async function searchFeishuRecord(baseUrl, appToken, tableId, token, title) { + try { + const resp = await fetchWithRetry(`${baseUrl}/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/search`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + field_names: ["标题"], + filter: { conjunction: "and", conditions: [{ field_name: "标题", operator: "is", value: [title] }] }, + page_size: 1, + }), + }, 1); + const data = await resp.json(); + return data.code === 0 && data.data?.items?.length > 0 ? data.data.items[0] : null; + } catch { return null; } +} + +async function createFeishuRecord(baseUrl, appToken, tableId, token, fields) { + const resp = await fetchWithRetry(`${baseUrl}/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ fields }), + }, 1); + const data = await resp.json(); + if (data.code !== 0) throw new Error(classifyFeishuError(data.code, data.msg)); + return data; +} + +async function updateFeishuRecord(baseUrl, appToken, tableId, token, recordId, fields) { + const resp = await fetchWithRetry(`${baseUrl}/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records/${recordId}`, { + method: "PUT", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ fields }), + }, 1); + const data = await resp.json(); + if (data.code !== 0) throw new Error(classifyFeishuError(data.code, data.msg)); + return data; +} + +async function uploadFeishuFile(baseUrl, token, blob, filename) { + const formData = new FormData(); + formData.append("file_type", "stream"); + formData.append("file_name", filename); + formData.append("file", blob, filename); + const resp = await fetchWithRetry(`${baseUrl}/open-apis/drive/v1/medias/upload_all`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }, 1); + const data = await resp.json(); + if (data.code !== 0 || !data.data?.file_token) throw new Error(classifyFeishuError(data.code || -1, data.msg || "文件上传失败")); + return data.data.file_token; +} + +function sanitizeFeishuFilename(name) { + return (name || "untitled").replace(/[<>:"/\\|?*\x00-\x1f]/g, "").substring(0, 80); +} + +function sanitizeFeishuTextContent(content) { + if (!content || typeof content !== "string") return ""; + let s = content + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") + .replace(/[\u200B-\u200D\uFEFF\u2028\u2029]/g, "") + .replace(/\r\n/g, "\n").replace(/\r/g, "\n") + .replace(/!\[([^\]]*)\]\([^)]+\)/g, (_, alt) => alt.trim() ? `[图片: ${alt.trim()}]` : "") + .replace(/\n{3,}/g, "\n\n") + .replace(/[ \t]+/g, " ") + .replace(/^ +| +$/gm, ""); + if (s.length > 100000) s = s.substring(0, 99900) + "\n\n... (内容过长,已截断)"; + return s; +} + +async function handleSaveToFeishu(data, _retried) { + // 前置验证:必填字段 + if (!data.feishu_app_id || !data.feishu_app_secret) { + return { success: false, error: "飞书 App ID 或 App Secret 未配置", target: "feishu" }; + } + if (!data.feishu_app_token || !data.feishu_table_id) { + return { success: false, error: "飞书 App Token 或 Table ID 未配置", target: "feishu" }; + } + + const baseUrl = FEISHU_API_DOMAINS[data.feishu_api_domain] || FEISHU_API_DOMAINS.feishu; + try { + const token = await getFeishuAccessToken(data.feishu_app_id, data.feishu_app_secret, data.feishu_api_domain); + const title = data.article_title || data.title || (data.text || "").slice(0, 50) || "Untitled"; + const existing = await searchFeishuRecord(baseUrl, data.feishu_app_token, data.feishu_table_id, token, title); + + let mdToken = null, htmlToken = null; + if (data.feishu_upload_md && data.markdown) { + const blob = new Blob([data.markdown], { type: "text/markdown; charset=utf-8" }); + mdToken = await uploadFeishuFile(baseUrl, token, blob, `${sanitizeFeishuFilename(title)}.md`); + } + if (data.feishu_upload_html && data.htmlContent) { + const blob = new Blob([data.htmlContent], { type: "text/html; charset=utf-8" }); + htmlToken = await uploadFeishuFile(baseUrl, token, blob, `${sanitizeFeishuFilename(title)}.html`); + } + + const fields = { + "标题": title, + "链接": { link: data.url || "", text: data.url || "" }, + "作者": data.author || "", + "保存时间": Date.now(), + "类型": data.type === "article" ? "文章" : data.type === "thread" ? "Thread" : "推文", + }; + if (!data.feishu_upload_md) { + fields["正文"] = sanitizeFeishuTextContent(data.article_content || data.text || ""); + } + if (mdToken) fields["附件"] = [{ file_token: mdToken }]; + if (htmlToken) fields["HTML附件"] = [{ file_token: htmlToken }]; + + if (existing) { + await updateFeishuRecord(baseUrl, data.feishu_app_token, data.feishu_table_id, token, existing.record_id, fields); + return { success: true, action: "updated", target: "feishu" }; + } else { + await createFeishuRecord(baseUrl, data.feishu_app_token, data.feishu_table_id, token, fields); + return { success: true, action: "created", target: "feishu" }; + } + } catch (err) { + if (!_retried && err.message && (err.message.includes("Token 已过期") || err.message.includes("token expired") || err.message.includes("1254001"))) { + const domainKey = data.feishu_api_domain === "lark" ? "lark" : "feishu"; + feishuTokenCache[domainKey] = { token: null, expireAt: 0 }; + return handleSaveToFeishu(data, true); + } + return { success: false, error: err.message, target: "feishu" }; + } +} + +async function handleTestFeishu(data) { + const baseUrl = FEISHU_API_DOMAINS[data.feishu_api_domain] || FEISHU_API_DOMAINS.feishu; + try { + const token = await getFeishuAccessToken(data.feishu_app_id, data.feishu_app_secret, data.feishu_api_domain); + const resp = await fetchWithRetry(`${baseUrl}/open-apis/bitable/v1/apps/${data.feishu_app_token}/tables/${data.feishu_table_id}/fields`, { + headers: { Authorization: `Bearer ${token}` }, + }, 1); + const result = await resp.json(); + if (result.code !== 0) throw new Error(`表格访问失败: ${result.msg}`); + const fields = (result.data?.items || []).map(f => f.field_name); + const required = ["标题", "链接", "作者", "保存时间"]; + const missing = required.filter(f => !fields.includes(f)); + return { success: true, fieldCount: fields.length, fields, missingFields: missing }; + } catch (err) { + return { success: false, error: err.message }; + } +} + +// ═══════════════════════════════════════════════ +// Notion Database API +// ═══════════════════════════════════════════════ +const NOTION_API_BASE = "https://api.notion.com/v1"; +const NOTION_VERSION = "2022-06-28"; + +async function notionFetch(endpoint, token, options = {}) { + const resp = await fetchWithRetry(`${NOTION_API_BASE}${endpoint}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json", + ...(options.headers || {}), + }, + }, 1); + let data; + try { data = await resp.json(); } catch { throw new Error(`Notion API 响应格式错误 (${resp.status})`); } + if (!resp.ok) { + if (resp.status === 401) throw new Error("Notion Token 无效或已过期"); + if (resp.status === 403) throw new Error("Notion 无权访问,请确认 Integration 已连接到 Database"); + if (resp.status === 404) throw new Error("Notion Database 不存在,请检查 Database ID"); + throw new Error(`Notion API 错误 (${resp.status}): ${data.message || JSON.stringify(data)}`); + } + return data; +} + +async function searchNotionByUrl(databaseId, token, url, urlPropName) { + try { + const data = await notionFetch(`/databases/${databaseId}/query`, token, { + method: "POST", + body: JSON.stringify({ filter: { property: urlPropName || "链接", url: { equals: url } }, page_size: 1 }), + }); + return data.results?.length > 0 ? data.results[0] : null; + } catch { return null; } +} + +function buildNotionProperties(d) { + const pm = d.propMapping || {}; + const props = {}; + if (pm.title) props[pm.title] = { title: [{ text: { content: d.title || "" } }] }; + if (pm.url) props[pm.url] = { url: d.url || "" }; + if (pm.author) props[pm.author] = { rich_text: [{ text: { content: d.author || "" } }] }; + if (pm.tags && d.tags?.length > 0) props[pm.tags] = { multi_select: d.tags.map(t => ({ name: t })) }; + if (pm.savedDate) props[pm.savedDate] = { date: { start: new Date().toISOString().split("T")[0] } }; + if (pm.type) props[pm.type] = { select: { name: d.type || "推文" } }; + return props; +} + +function parseNotionRichText(text) { + if (!text) return [{ type: "text", text: { content: "" } }]; + if (text.length > 2000) { + const chunks = []; + for (let i = 0; i < text.length; i += 2000) chunks.push({ type: "text", text: { content: text.substring(i, i + 2000) } }); + return chunks; + } + const richText = []; + const regex = /(\*\*(.{1,500}?)\*\*|\*(.{1,500}?)\*|`(.{1,500}?)`|~~(.{1,500}?)~~|\[([^\]]{1,300})\]\(([^)]{1,500})\)|([^*`~\[]+))/g; + let match; + while ((match = regex.exec(text)) !== null) { + if (match[2]) richText.push({ type: "text", text: { content: match[2] }, annotations: { bold: true } }); + else if (match[3]) richText.push({ type: "text", text: { content: match[3] }, annotations: { italic: true } }); + else if (match[4]) richText.push({ type: "text", text: { content: match[4] }, annotations: { code: true } }); + else if (match[5]) richText.push({ type: "text", text: { content: match[5] }, annotations: { strikethrough: true } }); + else if (match[6] && match[7]) richText.push({ type: "text", text: { content: match[6], link: { url: match[7] } } }); + else if (match[8]) richText.push({ type: "text", text: { content: match[8] } }); + } + return richText.length > 0 ? richText.filter(rt => rt.text.content.length > 0) : [{ type: "text", text: { content: text } }]; +} + +function mapNotionLanguage(lang) { + const map = { js: "javascript", ts: "typescript", py: "python", rb: "ruby", sh: "bash", yml: "yaml", md: "markdown", jsx: "javascript", tsx: "typescript" }; + const lower = (lang || "").toLowerCase(); + return map[lower] || lower || "plain text"; +} + +function convertMarkdownToNotionBlocks(markdown) { + if (!markdown) return []; + const blocks = []; + const cleanMd = markdown.replace(/^---\n[\s\S]*?\n---\n*/, ""); + const lines = cleanMd.split("\n"); + let i = 0, inCodeBlock = false, codeContent = "", codeLanguage = ""; + + while (i < lines.length) { + const line = lines[i]; + if (line.startsWith("```")) { + if (!inCodeBlock) { inCodeBlock = true; codeLanguage = line.slice(3).trim() || "plain text"; codeContent = ""; } + else { + inCodeBlock = false; + blocks.push({ type: "code", code: { rich_text: [{ type: "text", text: { content: codeContent.trimEnd() } }], language: mapNotionLanguage(codeLanguage) } }); + } + i++; continue; + } + if (inCodeBlock) { codeContent += line + "\n"; i++; continue; } + if (line.trim() === "") { i++; continue; } + if (/^---+$/.test(line.trim())) { blocks.push({ type: "divider", divider: {} }); i++; continue; } + if (line.startsWith("# ")) { blocks.push({ type: "heading_1", heading_1: { rich_text: parseNotionRichText(line.slice(2)) } }); i++; continue; } + if (line.startsWith("## ")) { blocks.push({ type: "heading_2", heading_2: { rich_text: parseNotionRichText(line.slice(3)) } }); i++; continue; } + if (line.startsWith("### ")) { blocks.push({ type: "heading_3", heading_3: { rich_text: parseNotionRichText(line.slice(4)) } }); i++; continue; } + + const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)/); + if (imgMatch && !imgMatch[2].startsWith("data:")) { + blocks.push({ type: "image", image: { type: "external", external: { url: imgMatch[2] } } }); i++; continue; + } + if (line.startsWith("> ")) { + let quoteText = line.slice(2); + while (i + 1 < lines.length && lines[i + 1].startsWith("> ")) { i++; quoteText += "\n" + lines[i].slice(2); } + blocks.push({ type: "quote", quote: { rich_text: parseNotionRichText(quoteText) } }); i++; continue; + } + if (line.match(/^[-*+] /)) { + blocks.push({ type: "bulleted_list_item", bulleted_list_item: { rich_text: parseNotionRichText(line.replace(/^[-*+] /, "")) } }); i++; continue; + } + if (line.match(/^\d+\. /)) { + blocks.push({ type: "numbered_list_item", numbered_list_item: { rich_text: parseNotionRichText(line.replace(/^\d+\. /, "")) } }); i++; continue; + } + // 表格 + if (line.includes("|") && line.trim().startsWith("|")) { + const tableRows = []; + let maxRows = 200; + while (i < lines.length && lines[i].includes("|") && lines[i].trim().startsWith("|") && maxRows-- > 0) { + const row = lines[i].trim(); + if (!/^[|\-:\s]+$/.test(row)) { + tableRows.push(row.split("|").filter(c => c.trim() !== "").map(c => c.trim())); + } + i++; + } + if (tableRows.length > 0) { + const tableWidth = Math.max(...tableRows.map(r => r.length)); + blocks.push({ + type: "table", table: { + table_width: tableWidth, has_column_header: true, has_row_header: false, + children: tableRows.map(row => ({ + type: "table_row", table_row: { + cells: Array.from({ length: tableWidth }, (_, idx) => [{ type: "text", text: { content: row[idx] || "" } }]), + }, + })), + }, + }); + } + continue; + } + //
→ toggle + if (line.startsWith("
")) { + let summaryText = "", toggleContent = ""; + i++; + while (i < lines.length && !lines[i].startsWith("
")) { + if (lines[i].startsWith("")) summaryText = lines[i].replace(/<\/?summary>/g, "").replace(/<\/?b>/g, "").trim(); + else toggleContent += lines[i] + "\n"; + i++; + } + blocks.push({ type: "toggle", toggle: { rich_text: parseNotionRichText(summaryText || "详情"), children: [{ type: "paragraph", paragraph: { rich_text: parseNotionRichText(toggleContent.trim()) } }] } }); + i++; continue; + } + //

`; + } + return `

[视频链接]

`; }); // Obsidian wiki-link 嵌入 ![[filename]] → 提示文字(HTML 无法访问本地 vault 文件) html = html.replace(/!\[\[([^\]]+)\]\]/g, (_, filename) => { @@ -1595,11 +1685,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "get_config") { (async () => { - const serverBase = SERVER_BASE; // 快照 try { - const resp = await fetch(`${serverBase}/config`); - if (!resp.ok) throw new Error(`Obsidian 服务端响应异常 (HTTP ${resp.status})`); - const cfg = await resp.json(); + const cfg = await getCachedConfig(); + if (!cfg) throw new Error("无法获取配置(服务端可能未启动)"); // 如果开启了同步,用 sync 中的扩展配置覆盖(save_paths 等本地专属字段不同步) const syncData = await chrome.storage.sync.get("x2md_sync").catch(() => ({})); if (syncData.x2md_sync && syncData.x2md_sync.sync_enabled) { @@ -1651,6 +1739,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.config.discourse_domains) { registerDiscourseContentScripts(message.config.discourse_domains); } + // 使配置缓存失效(下次获取走服务器) + invalidateConfigCache(); sendResponse({ success: json.success !== false, config: json.config }); } catch (err) { sendResponse({ success: false, error: err.message }); @@ -1665,6 +1755,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const resp = await fetch(`${SERVER_BASE}/config/reset`, { method: "POST" }); const json = await resp.json(); await chrome.storage.sync.remove("x2md_sync").catch(() => {}); + invalidateConfigCache(); sendResponse({ success: json.success !== false }); } catch (err) { sendResponse({ success: false, error: err.message }); diff --git a/extension/discourse.js b/extension/discourse.js index a341122..e73917b 100644 --- a/extension/discourse.js +++ b/extension/discourse.js @@ -376,9 +376,9 @@ }; } - // 向后兼容别名 - function fetchLinuxDoReplies(topicId) { - return fetchDiscourseReplies(topicId); + // 向后兼容别名(默认使用 linux.do 作为 hostname) + function fetchLinuxDoReplies(topicId, hostname) { + return fetchDiscourseReplies(topicId, hostname || "linux.do"); } const exported = { diff --git a/extension/dom_utils.js b/extension/dom_utils.js index 3584a53..3d5ba19 100644 --- a/extension/dom_utils.js +++ b/extension/dom_utils.js @@ -83,14 +83,53 @@ return "\n" + lines.join("\n") + "\n"; } + /** + * 将 GFM pipe table 转换为 HTML 字符串 + * @param {string} gfmTable - GFM markdown table 字符串 + * @returns {string} HTML table 字符串 + */ + function convertGfmTableToHtml(gfmTable) { + const lines = gfmTable.trim().split("\n").filter(l => l.trim().startsWith("|")); + if (lines.length < 2) return ""; + const parseRow = (line) => line.split("|").slice(1, -1).map(c => c.replace(/\\\|/g, "|").trim()); + const headers = parseRow(lines[0]); + // lines[1] is separator, skip it + const bodyRows = lines.slice(2).map(parseRow); + let html = "
\n\n\n"; + headers.forEach(h => { html += ` \n`; }); + html += "\n\n\n"; + bodyRows.forEach(row => { + html += "\n"; + row.forEach(cell => { html += ` \n`; }); + html += "\n"; + }); + html += "\n
${h}
${cell}
"; + return html; + } + + /** + * 将 GFM 表格同时输出为 Markdown + HTML 双格式 + * @param {string} gfmTable - GFM markdown table 字符串 + * @param {boolean} includeHtml - 是否同时输出 HTML 版本 + * @returns {string} Markdown table,可选附带 HTML 版本 + */ + function renderTableDualFormat(gfmTable, includeHtml) { + if (!gfmTable || !gfmTable.trim()) return ""; + if (!includeHtml) return gfmTable; + const htmlTable = convertGfmTableToHtml(gfmTable); + return gfmTable + "\n\n" + htmlTable; + } + const exported = { cleanZeroWidth, + convertGfmTableToHtml, convertTableToGfm, escapeMdLinkText, escapeMdLinkUrl, getClassList, getNodeText, getTagName, + renderTableDualFormat, safeClosest, safeGetAttribute, }; diff --git a/extension/feishu.js b/extension/feishu.js index add1840..8048eb0 100644 --- a/extension/feishu.js +++ b/extension/feishu.js @@ -191,15 +191,26 @@ return null; } - function cleanFeishuUrl(url) { + /** + * 清理飞书页面 URL(仅去除跟踪参数,保留功能性参数和锚点) + * @param {string} url - 原始 URL + * @param {boolean} stripAll - 是否去除所有查询参数(仅用于页面规范 URL) + */ + function cleanFeishuUrl(url, stripAll = true) { if (!url) return ""; try { const parsed = new URL(url); - parsed.search = ""; - parsed.hash = ""; + if (stripAll) { + parsed.search = ""; + parsed.hash = ""; + } else { + // 仅去除已知跟踪参数,保留功能性参数 + const trackingParams = ["from", "source", "utm_source", "utm_medium", "utm_campaign", "ccm_ref"]; + trackingParams.forEach(p => parsed.searchParams.delete(p)); + } return parsed.href; } catch (error) { - return String(url).replace(/[?#].*$/, ""); + return stripAll ? String(url).replace(/[?#].*$/, "") : String(url); } } @@ -207,17 +218,22 @@ const raw = String(url || "").trim(); if (!raw) return ""; if (/^(data|blob|javascript):/i.test(raw)) return ""; - if (/^https?:\/\//i.test(raw)) { - if (/^https?:\/\/[^/]+$/i.test(raw)) { - return `${raw}/`; - } - return raw; - } - try { - return new URL(raw, pageUrl || globalScope.location?.href || "").href; - } catch (error) { - return raw; - } + // 绝对 URL 直接返回(不做修改) + if (/^https?:\/\//i.test(raw)) return raw; + // 协议相对 URL(//host/path) + if (/^\/\//.test(raw)) return "https:" + raw; + // 相对 URL:尝试解析为绝对 URL + const base = pageUrl || globalScope.location?.href || ""; + if (base) { + try { + return new URL(raw, base).href; + } catch (error) { /* fall through */ } + } + // 飞书内部相对路径 fallback:补全飞书域名 + if (/^\/(wiki|docx|docs|sheets|minutes|drive|mindnotes)\//.test(raw)) { + return "https://feishu.cn" + raw; + } + return raw; } function isFeishuWikiOrDocxPage(locationLike = globalScope.location) { @@ -262,9 +278,6 @@ const tag = getTagName(node); if (tag === "img") { - if (safeClosest(node, "a.mention-doc, a.link")) { - return ""; - } const src = resolveFeishuUrl(node.currentSrc || node.src || safeGetAttribute(node, "src") || "", options.pageUrl); return src ? `![](${src})` : ""; } @@ -280,7 +293,8 @@ const href = resolveFeishuUrl(safeGetAttribute(node, "href") || "", options.pageUrl); const text = markdown.trim(); if (!href || !text) return markdown; - if (text.includes("![](")) return text; + // 带链接的图片:[![](img)](href) + if (text.includes("![](")) return `[${text}](${escapeMdLinkUrl(href)})`; return `[${escapeMdLinkText(text)}](${escapeMdLinkUrl(href)})`; } @@ -323,8 +337,13 @@ } function formatQuoteBlock(text) { - const lines = String(text || "").split("\n").map((line) => line.trim()).filter(Boolean); - return lines.length ? lines.map((line) => `> ${line}`).join("\n") : ""; + const lines = String(text || "").split("\n").map((line) => line.trim()); + // 保留段落间空行(用 "> " 前缀保持 blockquote 连续性) + const quoted = lines.map((line) => line ? `> ${line}` : `>`); + // 去掉首尾空引用行 + while (quoted.length && quoted[0] === ">") quoted.shift(); + while (quoted.length && quoted[quoted.length - 1] === ">") quoted.pop(); + return quoted.length ? quoted.join("\n") : ""; } function extractFeishuBlockMarkdown(block, options = {}) { @@ -378,8 +397,8 @@ const iframe = block.querySelector?.("iframe"); const src = iframe?.src || safeGetAttribute(iframe, "src") || ""; if (!src) return ""; - // 清理嵌入参数,保留核心 URL - const cleanSrc = cleanFeishuUrl(src); + // 保留嵌入功能性参数(embed token 等),仅去跟踪参数 + const cleanSrc = cleanFeishuUrl(src, false); return `[嵌入内容](${cleanSrc})`; } @@ -411,7 +430,8 @@ const header = "| " + rows[0].join(" | ") + " |"; const separator = "| " + rows[0].map(() => "---").join(" | ") + " |"; const body = rows.slice(1).map((r) => "| " + r.join(" | ") + " |").join("\n"); - return [header, separator, body].filter(Boolean).join("\n"); + const gfmTable = [header, separator, body].filter(Boolean).join("\n"); + return renderTableDualFormat(gfmTable, !!options.includeHtmlTable); } if (type === "table_cell") return ""; @@ -439,30 +459,63 @@ return text; } + // options.includeHtmlTable: 是否同时输出 HTML 表格(传递给 extractFeishuBlockMarkdown) + const LIST_BLOCK_TYPES = new Set(["bullet", "unordered", "ordered", "todo"]); + function extractFeishuMarkdownFromBlocks(blocks, options = {}) { - const parts = []; + const entries = []; for (const block of blocks || []) { const markdown = extractFeishuBlockMarkdown(block, options); if (!markdown) continue; - parts.push(markdown); + const blockType = safeGetAttribute(block, "data-block-type") || ""; + entries.push({ markdown, isList: LIST_BLOCK_TYPES.has(blockType) }); } - return parts.join("\n\n").replace(/\n{3,}/g, "\n\n").trim(); + if (!entries.length) return ""; + // 智能拼接:连续列表项用 \n(紧凑列表),其他用 \n\n(段落间距) + let result = entries[0].markdown; + for (let i = 1; i < entries.length; i++) { + const sep = (entries[i - 1].isList && entries[i].isList) ? "\n" : "\n\n"; + result += sep + entries[i].markdown; + } + return result.replace(/\n{3,}/g, "\n\n").trim(); } function extractFeishuTitle(doc = document) { + // 第一轮:精确选择器,要求长度 >= 5(避免截断的短片段) const selectors = [ "#ssrHeaderTitle", ".note-title__input .header-ssr-layout-component-Title", ".note-title__input", - "h1:nth-of-type(2)", - "h1", + ".doc-header-title", + "[data-testid='doc-title']", + ".suite-title-input", ]; for (const selector of selectors) { const el = doc.querySelector?.(selector); const text = cleanZeroWidth(getNodeText(el)).trim(); - if (text && text !== "飞书云文档") return text; + if (text && text.length >= 5 && text !== "飞书云文档") return text; + } + // 第二轮:h1 选择器(可能匹配到非标题元素,取最长的) + const h1s = doc.querySelectorAll?.("h1") || []; + let bestH1 = ""; + for (const h1 of h1s) { + const t = cleanZeroWidth(getNodeText(h1)).trim(); + if (t && t.length > bestH1.length && t !== "飞书云文档" && t !== "飞书") bestH1 = t; + } + if (bestH1.length >= 5) return bestH1; + // 第三轮:document.title 清洗(最可靠的 fallback) + const docTitle = cleanZeroWidth(String(doc.title || "")) + .replace(/\s*-\s*飞书云文档\s*$/, "") + .replace(/\s*-\s*飞书\s*$/, "") + .trim(); + if (docTitle) return docTitle; + // 最终 fallback:返回第一轮中任何非空结果(即使短于5字符) + for (const selector of selectors) { + const el = doc.querySelector?.(selector); + const text = cleanZeroWidth(getNodeText(el)).trim(); + if (text && text !== "飞书云文档" && text !== "飞书") return text; } - return cleanZeroWidth(String(doc.title || "").replace(/\s*-\s*飞书云文档\s*$/, "")).trim(); + return bestH1 || "未命名飞书文档"; } function extractFeishuAuthor(doc = document) { @@ -521,7 +574,8 @@ } // 将飞书 API 返回的 text.elements 转为 markdown 内联文本 - function convertFeishuApiTextElements(elements) { + // pageUrl: 用于将相对链接解析为绝对 URL + function convertFeishuApiTextElements(elements, pageUrl) { let md = ""; for (const el of elements || []) { if (el.text_run) { @@ -537,17 +591,20 @@ } if (style.link && style.link.url) { const escapedText = escapeMdLinkText(text); - try { - const decoded = decodeURIComponent(style.link.url); - text = `[${escapedText}](${escapeMdLinkUrl(decoded)})`; - } catch (e) { - text = `[${escapedText}](${escapeMdLinkUrl(style.link.url)})`; - } + let linkUrl = style.link.url; + try { linkUrl = decodeURIComponent(linkUrl); } catch (e) { /* keep original */ } + // 解析相对 URL 为绝对 URL + linkUrl = resolveFeishuUrl(linkUrl, pageUrl || ""); + text = `[${escapedText}](${escapeMdLinkUrl(linkUrl)})`; } md += text; } else if (el.mention_doc) { const title = el.mention_doc.title || "文档"; - const url = el.mention_doc.url || ""; + let url = el.mention_doc.url || ""; + if (url) { + try { url = decodeURIComponent(url); } catch (e) { /* keep original */ } + url = resolveFeishuUrl(url, pageUrl || ""); + } md += url ? `[${escapeMdLinkText(title)}](${escapeMdLinkUrl(url)})` : title; } else if (el.equation) { md += `$${el.equation.content || ""}$`; @@ -567,20 +624,36 @@ }; // 递归将飞书 API 的 JSON block 转换为 markdown - function convertFeishuApiBlock(block, allBlocks, depth) { + function convertFeishuApiBlock(block, allBlocks, depth, options) { + options = options || {}; if (!block) return ""; const type = block.block_type; // 获取文本内容(text 块和 heading 块共用 text 字段) const textElements = block.text?.elements || block.heading?.elements || []; - const text = convertFeishuApiTextElements(textElements); + const text = convertFeishuApiTextElements(textElements, options.pageUrl); + + // 列表类型集合(API block_type 枚举) + const API_LIST_TYPES = new Set([12, 13, 16]); // bullet, ordered, todo - // 递归转换子块 + // 递归转换子块,智能拼接:连续列表项用 \n,其他用 \n\n function convertChildren(childDepth) { - return (block.children || []) - .map(function (id) { return convertFeishuApiBlock(allBlocks[id], allBlocks, childDepth || depth); }) - .filter(Boolean) - .join("\n\n"); + const childIds = block.children || []; + const entries = []; + for (const id of childIds) { + const child = allBlocks[id]; + if (!child) continue; + const md = convertFeishuApiBlock(child, allBlocks, typeof childDepth === "number" ? childDepth : depth, options); + if (!md) continue; + entries.push({ markdown: md, isList: API_LIST_TYPES.has(child.block_type) }); + } + if (!entries.length) return ""; + let result = entries[0].markdown; + for (let i = 1; i < entries.length; i++) { + const sep = (entries[i - 1].isList && entries[i].isList) ? "\n" : "\n\n"; + result += sep + entries[i].markdown; + } + return result; } switch (type) { @@ -637,7 +710,7 @@ for (let j = 0; j < cols && (i + j) < cellIds.length; j++) { const cell = allBlocks[cellIds[i + j]]; const cellContent = cell ? (cell.children || []) - .map(function (id) { return convertFeishuApiBlock(allBlocks[id], allBlocks, 0); }) + .map(function (id) { return convertFeishuApiBlock(allBlocks[id], allBlocks, 0, options); }) .filter(Boolean) .join(" ") .replace(/\|/g, "\\|") @@ -650,7 +723,8 @@ const header = "| " + rows[0].join(" | ") + " |"; const sep = "| " + rows[0].map(function () { return "---"; }).join(" | ") + " |"; const body = rows.slice(1).map(function (r) { return "| " + r.join(" | ") + " |"; }).join("\n"); - return [header, sep, body].filter(Boolean).join("\n"); + const gfmTable = [header, sep, body].filter(Boolean).join("\n"); + return renderTableDualFormat(gfmTable, !!options.includeHtmlTable); } case 20: // table_cell - handled by table return ""; @@ -739,8 +813,10 @@ } // 将 API 返回的 data 转为 markdown - function convertFeishuApiDataToMarkdown(apiData) { + // options.includeHtmlTable: 是否同时输出 HTML 表格 + function convertFeishuApiDataToMarkdown(apiData, options) { if (!apiData) return null; + options = options || {}; var blocks = apiData.blocks || {}; var doc = apiData.document; @@ -752,7 +828,7 @@ // 将 document.children 设为 rootBlock 的 children rootBlock.children = doc.children || rootBlock.children || []; - var md = convertFeishuApiBlock(rootBlock, blocks, 0); + var md = convertFeishuApiBlock(rootBlock, blocks, 0, options); return md ? md.replace(/\n{3,}/g, "\n\n").trim() : null; } diff --git a/extension/options.html b/extension/options.html index fe13948..7c47ada 100644 --- a/extension/options.html +++ b/extension/options.html @@ -201,6 +201,7 @@ } .status-msg.success { display: block; background: rgba(0,186,124,0.1); color: var(--success); border: 1px solid rgba(0,186,124,0.2); } .status-msg.error { display: block; background: rgba(244,33,46,0.1); color: var(--danger); border: 1px solid rgba(244,33,46,0.2); } +.status-msg.warning { display: block; background: rgba(245,158,11,0.1); color: var(--warning); border: 1px solid rgba(245,158,11,0.2); } /* ═══ Toast ═══ */ #toast { diff --git a/extension/site_actions.js b/extension/site_actions.js index 0cb1982..f0905a6 100644 --- a/extension/site_actions.js +++ b/extension/site_actions.js @@ -5,12 +5,21 @@ return config.show_site_save_icon !== false; } + // 可配置 Discourse 域名列表,运行时从配置注入 + let _siteDiscourseDomains = ["linux.do"]; + function setSiteDiscourseDomains(domains) { + if (Array.isArray(domains) && domains.length > 0) { + _siteDiscourseDomains = domains.map(d => d.toLowerCase().trim()).filter(Boolean); + } + } + function detectFloatingSaveSite(locationLike = globalScope.location) { const hostname = String(locationLike?.hostname || "").toLowerCase(); const pathname = String(locationLike?.pathname || ""); - if (hostname === "linux.do" && /^\/t\/[^/]+\/\d+(?:\/\d+)?\/?$/.test(pathname)) { - return "linux_do"; + // 检查所有已配置的 Discourse 域名(包括 linux.do 和自定义域名) + if (_siteDiscourseDomains.includes(hostname) && /^\/t\/[^/]+\/\d+(?:\/\d+)?\/?$/.test(pathname)) { + return hostname === "linux.do" ? "linux_do" : "discourse"; } if ((hostname.endsWith(".feishu.cn") || hostname.endsWith(".larksuite.com")) && @@ -46,6 +55,15 @@ }; } + if (siteKey === "discourse") { + return { + label: "MD", + title: "保存当前 Discourse 内容为 Markdown", + background: "#f97316", + shadow: "rgba(249, 115, 22, 0.35)", + }; + } + if (siteKey === "wechat") { return { label: "MD", @@ -63,6 +81,7 @@ detectFloatingSaveSite, getFloatingSaveSiteConfig, isFloatingSaveIconEnabled, + setSiteDiscourseDomains, }; if (typeof module !== "undefined" && module.exports) { diff --git a/extension/wechat.js b/extension/wechat.js index 129c7e5..322d259 100644 --- a/extension/wechat.js +++ b/extension/wechat.js @@ -90,11 +90,39 @@ const tag = getTagName(node); const classList = getClassList(node); - // 视频占位 + // 语音消息 if (tag === "mpvoice" || classList.includes("voice_player_inner")) { return "\n> [语音消息]\n"; } - if (classList.includes("video_iframe") || tag === "iframe" && String(node.src || "").includes("v.qq.com")) { + + // 视频号 / 内嵌视频:提取实际 URL 用于下载 + if (tag === "mpvideosnap" || tag === "mp-common-videosnap" || + classList.includes("video_channel") || classList.includes("channels_iframe")) { + const videoUrl = safeGetAttribute(node, "data-url") || + safeGetAttribute(node, "data-src") || + safeGetAttribute(node, "data-videourl") || ""; + const desc = safeGetAttribute(node, "data-desc") || + safeGetAttribute(node, "data-nickname") || "视频号视频"; + if (videoUrl) { + // 用占位符,server 端会处理下载 + return `\n[MEDIA_VIDEO_URL:${videoUrl}]\n`; + } + return `\n> [视频号: ${desc}]\n`; + } + if (classList.includes("video_iframe") || (tag === "iframe" && String(node.src || "").includes("v.qq.com"))) { + const src = safeGetAttribute(node, "data-src") || node.src || ""; + if (src && src.startsWith("http")) { + return `\n[MEDIA_VIDEO_URL:${src}]\n`; + } + return "\n> [视频]\n"; + } + // 微信原生 video 标签 + if (tag === "video") { + const src = safeGetAttribute(node, "data-src") || node.src || + safeGetAttribute(node, "src") || ""; + if (src && src.startsWith("http")) { + return `\n[MEDIA_VIDEO_URL:${src}]\n`; + } return "\n> [视频]\n"; } if (classList.includes("weapp_display_element") || classList.includes("weapp_text_link")) { @@ -306,13 +334,53 @@ return ""; } + function extractWechatVideos(doc = document) { + const videos = []; + const seen = new Set(); + // 视频号元素 + const videoEls = doc.querySelectorAll?.( + "mpvideosnap, mp-common-videosnap, .video_channel, .channels_iframe, " + + ".video_iframe iframe, video, iframe[src*='v.qq.com'], iframe[src*='channels']" + ) || []; + for (const el of videoEls) { + const url = safeGetAttribute(el, "data-url") || safeGetAttribute(el, "data-src") || + safeGetAttribute(el, "data-videourl") || el.src || ""; + if (url && url.startsWith("http") && !seen.has(url)) { + seen.add(url); + videos.push(url); + } + } + // 从页面脚本中提取视频 URL(微信经常在 script 里放视频地址) + const scripts = doc.querySelectorAll?.("script") || []; + for (const script of scripts) { + const text = script.textContent || ""; + const matches = text.matchAll(/(?:video_src|mpvideo_src|url_info\.url)\s*[:=]\s*["']([^"']+\.mp4[^"']*)/gi); + for (const m of matches) { + let vUrl = m[1].replace(/\\x26/g, "&").replace(/&/g, "&"); + if (vUrl.startsWith("http") && !seen.has(vUrl)) { + seen.add(vUrl); + videos.push(vUrl); + } + } + } + return videos; + } + + function detectWechatPaywall(doc = document) { + // 付费文章检测 + const payBar = doc.querySelector?.("#js_pay_bar, .pay_content_area, .pay_tips_area, .js_pay_preview_wap"); + const payBtn = doc.querySelector?.(".js_pay_btn, .pay_btn, .weui-btn[data-type='pay']"); + return !!(payBar || payBtn); + } + function extractWechatDocumentData(doc = document, options = {}) { const pageUrl = options.pageUrl || doc.location?.href || globalScope.location?.href || ""; const root = doc.querySelector?.("#js_content"); if (!root) return null; + const isPaid = detectWechatPaywall(doc); const articleContent = extractWechatMarkdown(root, { pageUrl }); - if (!articleContent) return null; + if (!articleContent && !isPaid) return null; const title = extractWechatTitle(doc); const author = extractWechatAuthor(doc); @@ -324,6 +392,29 @@ if (tag && tag.trim()) tags.push(tag.trim()); }); + // 提取图片 URL(用于飞书 Bitable 等需要独立图片列表的场景) + const images = []; + const imgSeen = new Set(); + root.querySelectorAll?.("img").forEach(img => { + const src = safeGetAttribute(img, "data-src") || img.currentSrc || img.src || ""; + const width = parseInt(safeGetAttribute(img, "data-w") || safeGetAttribute(img, "width") || "0", 10); + if (src && src.startsWith("http") && (width === 0 || width >= 20) && !imgSeen.has(src)) { + imgSeen.add(src); + images.push(resolveWechatImageUrl(src)); + } + }); + + // 提取视频 + const videos = extractWechatVideos(doc); + + // 付费文章:添加提示 + let finalContent = articleContent || ""; + if (isPaid) { + tags.push("付费文章"); + if (!finalContent) finalContent = "> 这是一篇付费文章,以下为免费预览部分。"; + else finalContent += "\n\n---\n\n> 以下内容为付费部分,未能获取。\n"; + } + return { type: "article", url: cleanWechatUrl(pageUrl), @@ -332,9 +423,9 @@ author_url: "", published: extractWechatPublished(doc), article_title: title, - article_content: articleContent, - images: [], - videos: [], + article_content: finalContent, + images, + videos, platform: "微信公众号", tags, }; @@ -343,8 +434,10 @@ const exported = { cleanWechatUrl, convertWechatNodeToMarkdown, + detectWechatPaywall, extractWechatDocumentData, extractWechatMarkdown, + extractWechatVideos, isWechatArticlePage, resolveWechatImageUrl, }; diff --git a/server.py b/server.py index 1255a8e..bae7f1b 100644 --- a/server.py +++ b/server.py @@ -6,6 +6,7 @@ 转换为 Obsidian 兼容的 Markdown 文件并保存到指定目录。 """ +import copy import json import os import re @@ -23,6 +24,8 @@ try: from zoneinfo import ZoneInfo BEIJING_TZ = ZoneInfo("Asia/Shanghai") + # 验证 ZoneInfo 对象确实可用(某些打包环境下创建成功但使用时才报错) + datetime.now(BEIJING_TZ).strftime("%Y-%m-%d") except Exception: BEIJING_TZ = timezone(timedelta(hours=8)) @@ -81,9 +84,9 @@ def get_app_dir(): "enable_platform_folders": True, "platform_folder_names": { "Twitter/X": "Twitter", - "LinuxDo": "LinuxDo", - "Feishu": "Feishu", - "WeChat": "WeChat", + "LINUX DO": "LINUX DO", + "飞书": "飞书", + "微信公众号": "微信公众号", }, # 图片下载到本地(V1.2 新增) "download_images": True, @@ -357,11 +360,12 @@ def _make_video_iframe(url: str) -> str: return f"[视频:点击播放]({url})" -def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list]: +def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list, list]: """ 将接收到的推文/文章数据构建为 Markdown 字符串。 - 返回 (文件名不含后缀, markdown内容, 图片下载任务列表) - 图片下载任务: [(url, save_dir, filename), ...] + 返回 (文件名不含后缀, markdown内容, 图片下载任务列表, 视频下载任务列表) + 图片下载任务: [(url, subfolder, filename), ...] + 视频下载任务: [(url, subfolder, filename), ...] """ author = data.get("author", "unknown") handle = data.get("handle", "") @@ -407,7 +411,7 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list]: summary_src = re.sub(r'\[\[VIDEO_HOLDER_\d+\]\]', '', summary_src) summary_src = summary_src.strip() max_len = cfg.get("max_filename_length", 60) - summary_short = sanitize_filename(summary_src[:30] if summary_src else "untitled", max_len) + summary_short = sanitize_filename(summary_src if summary_src else "untitled", max_len) author_clean = sanitize_filename(handle.lstrip("@") if handle else author, 20) handle_clean = sanitize_filename(handle.lstrip("@") if handle else "", 20) timestamp_str = now.strftime("%Y%m%d_%H%M%S") @@ -430,7 +434,9 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list]: title = re.sub(r'\[MEDIA_VIDEO_URL:[^\]]*\]', '', title) title = re.sub(r'\[\[VIDEO_HOLDER_\d+\]\]', '', title) title = " ".join(title.split()).strip() - title = title[:80] + ("…" if len(title) > 80 else "") + if not title: + title = summary_src if summary_src else "untitled" + title = title[:200] + ("…" if len(title) > 200 else "") title = title.replace('"', "'") author_url = data.get("author_url") or "" @@ -457,6 +463,9 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str, list]: # 原则:仅保留原文内容,不包含作者名和时间(那些已在 Front Matter 里) lines = [] image_subfolder = cfg.get("image_subfolder", "assets").lstrip("./") # 去除用户可能手动添加的 ./ 前缀 + # 防止路径穿越攻击(如 ../../etc) + image_subfolder = re.sub(r'\.\.[\\/]', '', image_subfolder) + image_subfolder = sanitize_filename(image_subfolder, 50) or "assets" vid_map = {} video_tasks = [] # (url, subfolder, filename) — 与图片同结构,在 _handle_save 中处理 @@ -594,6 +603,8 @@ def _repl(m): _inlined_videos.add(v_url) body = "\n".join(lines) + # 规范化:消除连续3+空行为2行(1个空行),防止排版碎裂 + body = re.sub(r'\n{3,}', '\n\n', body) # ── 评论/回复渲染 ──────────────────────────────── comments = data.get("comments", []) @@ -678,7 +689,7 @@ def do_GET(self): if path == "/ping": # 心跳检测 - self._respond(200, {"status": "ok", "version": "1.2.4"}) + self._respond(200, {"status": "ok", "version": "1.5.0"}) elif path == "/config": # 返回当前配置 @@ -726,7 +737,8 @@ def _handle_save(self, data: dict): try: filename, content, image_tasks, video_tasks = build_markdown(data, cfg) except Exception as e: - logger.error(f"Markdown 构建失败:{e}") + import traceback + logger.error(f"Markdown 构建失败:{e}\n{traceback.format_exc()}") self._respond(500, {"error": str(e)}) return @@ -802,6 +814,10 @@ def _handle_save(self, data: dict): "enable_wechat_video_channel", "enable_comments", "comments_display", "max_comments", "comment_floor_range", "discourse_domains", "embed_mode", + # 保存目标开关 + "save_to_obsidian", "save_to_feishu", "save_to_notion", "export_html", + # 外观 + "theme", # 飞书 Bitable "feishu_api_domain", "feishu_app_id", "feishu_app_secret", "feishu_app_token", "feishu_table_id", @@ -823,10 +839,10 @@ def _handle_config_update(self, data: dict): self._respond(400, {"error": "没有有效的配置字段"}) return with _config_lock: - cfg = dict(load_config()) # 深拷贝,避免竞态修改共享引用 + cfg = copy.deepcopy(load_config()) # 深拷贝,避免竞态修改共享引用 cfg.update(filtered) - save_config(cfg) - logger.info(f"配置已更新:{filtered}") + save_config(cfg) + logger.info(f"配置已更新:{list(filtered.keys())}") self._respond(200, {"success": True, "config": cfg}) def _handle_config_reset(self): diff --git a/x2md.spec b/x2md.spec index 27c992a..0f6af02 100644 --- a/x2md.spec +++ b/x2md.spec @@ -110,7 +110,7 @@ if sys.platform == 'darwin': info_plist={ 'CFBundleName': 'X2MD', 'CFBundleDisplayName': 'X2MD', - 'CFBundleShortVersionString': '1.2.4', + 'CFBundleShortVersionString': '1.5.0', 'LSUIElement': True, # 无 Dock 图标,仅菜单栏显示 }, ) From da208f55aeda0ddae53e80b9ff30ee421917e7ea Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 13:52:14 +0000 Subject: [PATCH 40/48] =?UTF-8?q?v1.5.2:=20=E4=BF=AE=E5=A4=8D=E5=8D=87?= =?UTF-8?q?=E7=BA=A7=E5=85=BC=E5=AE=B9=E6=80=A7=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E6=8F=8F=E8=BF=B0=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复版本升级后设置向导不弹出的问题(config合并新增字段) - 修复版本升级后扩展文件不更新的问题(版本检测+自动替换) - 更新设置向导描述:反映多平台+多目标保存能力 - 更新 README:v1.5.x 完整更新日志、下载链接、配置说明 - 更新 Release 模板:新增功能介绍和升级须知 - 统一版本号到 1.5.2(server/manifest/spec/build.yml) Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 11 +++++- README.md | 56 ++++++++++++++++++++++---- extension/manifest.json | 2 +- server.py | 2 +- setup_wizard.py | 14 +++---- tray_app.py | 79 ++++++++++++++++++++++++++++++++----- x2md.spec | 2 +- 7 files changed, 137 insertions(+), 29 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a6b7bb..7d7c4b2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ on: version: description: '版本号(如 1.0.0)' required: false - default: '1.5.0' + default: '1.5.2' # 推送 tag 时自动触发(如 git tag v1.0.0 && git push --tags) push: tags: @@ -133,6 +133,8 @@ jobs: body: | ## X2MD ${{ github.ref_name }} + 网页内容一键转 Markdown — 支持 X/Twitter、LINUX DO、飞书、微信公众号,多目标保存到 Obsidian / 飞书多维表格 / Notion / HTML。 + ### 下载 - **Mac 版**:`X2MD_Mac.zip` — 解压后双击 `X2MD.app` 即可运行 - **Windows 版**:`X2MD_Windows.zip` — 解压后双击 `X2MD.exe` 即可运行 @@ -141,7 +143,12 @@ jobs: ### 首次使用 1. 打开应用,按向导设置保存路径 2. 解压 `X2MD_Extension.zip` 并在 `chrome://extensions/` 中加载已解压的扩展程序 - 3. 在 X/Twitter 页面使用书签按钮保存内容 + 3. 在支持的网站(X/Twitter、LINUX DO、飞书、微信公众号)使用保存按钮保存内容 + 4. 打开扩展选项页面可配置多目标保存、评论提取等高级功能 + + ### 升级用户须知 + - 从旧版本升级后首次运行,应用会自动合并新增配置字段并更新扩展文件 + - 你的已有设置(保存路径等)不会被覆盖 ### 系统要求 - Mac: macOS 12+ diff --git a/README.md b/README.md index 1db67a8..cf106b1 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,11 @@ ### 第 1 步:下载客户端(基于 GitHub Release) - 打开 [Releases](https://github.com/izscc/x2md/releases/latest) -- 当前最新版本:[`v1.2.4`](https://github.com/izscc/x2md/releases/tag/v1.2.4) +- 当前最新版本:[`v1.5.2`](https://github.com/izscc/x2md/releases/tag/v1.5.2) - 下载对应平台包: - - Mac: [`X2MD_Mac.zip`](https://github.com/izscc/x2md/releases/download/v1.2.4/X2MD_Mac.zip) - - Windows: [`X2MD_Windows.zip`](https://github.com/izscc/x2md/releases/download/v1.2.4/X2MD_Windows.zip) - - 扩展: [`X2MD_Extension.zip`](https://github.com/izscc/x2md/releases/download/v1.2.4/X2MD_Extension.zip) + - Mac: [`X2MD_Mac.zip`](https://github.com/izscc/x2md/releases/download/v1.5.2/X2MD_Mac.zip) + - Windows: [`X2MD_Windows.zip`](https://github.com/izscc/x2md/releases/download/v1.5.2/X2MD_Windows.zip) + - 扩展: [`X2MD_Extension.zip`](https://github.com/izscc/x2md/releases/download/v1.5.2/X2MD_Extension.zip) ### 第 2 步:首次运行并完成向导 @@ -73,8 +73,9 @@ ## 常用配置(进阶) -配置文件为根目录的 `config.json`,常用字段: +配置文件位于应用数据目录下的 `config.json`(Mac: `~/Library/Application Support/X2MD/`,Windows: `%APPDATA%/X2MD/`),也可通过扩展选项页面可视化修改。常用字段: +**基础设置:** - `save_paths`: Markdown 输出目录列表 - `filename_format`: 文件名模板,支持 `{summary}` `{date}` `{author}` - `max_filename_length`: 文件名长度上限 @@ -82,10 +83,21 @@ - `enable_video_download`: 是否下载视频 - `video_duration_threshold`: 超长视频二次确认阈值(分钟) - `show_site_save_icon`: 是否在支持的站点显示悬浮保存按钮 -- `platform_folders`: 是否按平台分子文件夹(Twitter/、LinuxDo/、Feishu/、WeChat/) +- `enable_platform_folders`: 是否按平台分子文件夹(Twitter/、LINUX DO/、飞书/、微信公众号/) - `download_images`: 是否下载远程图片到本地(存入 `assets/` 子目录) - `overwrite_existing`: 是否覆盖已存在的同名文件(基于 Front Matter `源:` URL 去重) +**多目标保存:** +- `save_to_obsidian`: 保存到本地 Markdown(默认开启) +- `save_to_feishu`: 保存到飞书多维表格(需配置飞书 App ID/Secret/Token) +- `save_to_notion`: 保存到 Notion 数据库(需配置 Notion Token) +- `export_html`: 同时导出 HTML 文件 + +**评论提取:** +- `enable_comments`: 是否提取评论 +- `max_comments`: 最大评论数 +- `comments_display`: 评论展示方式(`details` 折叠 / `inline` 内联) + 推荐模板示例: ```json @@ -94,9 +106,13 @@ "max_filename_length": 60, "enable_video_download": true, "video_duration_threshold": 5, - "platform_folders": true, + "enable_platform_folders": true, "download_images": true, - "overwrite_existing": false + "overwrite_existing": false, + "save_to_obsidian": true, + "save_to_feishu": false, + "save_to_notion": false, + "enable_comments": false } ``` @@ -179,6 +195,30 @@ CI 工作流见 [`.github/workflows/build.yml`](./.github/workflows/build.yml) ## 更新日志 +### v1.5.2(2026-03-29) + +- 修复版本升级后设置向导不弹出、扩展文件不更新的问题 +- 新增配置自动合并:升级版本后新增的配置字段自动补全,不覆盖用户已有设置 +- 新增扩展版本检测:通过 manifest.json 版本号判断是否需要更新本地 extension 文件夹 +- 更新设置向导描述:反映多平台(X/Twitter、LINUX DO、飞书、微信公众号)和多目标保存能力 +- 统一所有组件版本号到 1.5.2(server.py / manifest.json / x2md.spec / build.yml) + +### v1.5.1(2026-03-28) + +- 修复 PyInstaller 打包环境下 `ZoneInfo("Asia/Shanghai")` 时区错误导致飞书文章保存 500 的问题 +- 新增 ZoneInfo 运行时验证,打包缺失 tzdata 时自动降级为 UTC+8 timedelta +- 补全 config.json 所有缺失的默认字段(飞书多维表格、Notion、HTML 导出等 15+ 字段) +- 新增错误日志 traceback 完整输出,便于排查 Markdown 构建异常 + +### v1.5.0(2026-03-27) + +- 新增完整选项面板:9 大分类设置(外观、保存目标、文件路径、媒体嵌入、内容提取、飞书、Notion、HTML 导出、同步) +- 新增多目标保存:Obsidian + 飞书多维表格 + Notion + HTML 导出 +- 新增评论提取功能:支持 Discourse 评论、楼层范围筛选 +- 新增 Discourse 多域名支持 +- 新增配置导入/导出功能 +- 新增图片嵌入模式切换(本地/远程链接) + ### v1.2.4(2026-03-25) - 合并 [@AchengBusiness](https://github.com/AchengBusiness) 的跨平台修复与增强:Windows 兼容性修复、平台分类文件夹、图片本地下载、覆盖/去重、飞书 JSON API、配置导入导出 diff --git a/extension/manifest.json b/extension/manifest.json index 0bef6f9..cf7db35 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "X2MD - 网页内容转Markdown", - "version": "1.5.0", + "version": "1.5.2", "description": "支持 X、LINUX DO、飞书知识库、飞书云文档、微信公众号的一键保存 Markdown,多目标保存(Obsidian/飞书多维表格/Notion/HTML),支持评论提取、iframe嵌入、Discourse多域名", "permissions": [ "storage", diff --git a/server.py b/server.py index bae7f1b..6698d4d 100644 --- a/server.py +++ b/server.py @@ -689,7 +689,7 @@ def do_GET(self): if path == "/ping": # 心跳检测 - self._respond(200, {"status": "ok", "version": "1.5.0"}) + self._respond(200, {"status": "ok", "version": "1.5.2"}) elif path == "/config": # 返回当前配置 diff --git a/setup_wizard.py b/setup_wizard.py index c134944..34a76ab 100644 --- a/setup_wizard.py +++ b/setup_wizard.py @@ -202,14 +202,14 @@ def _step_welcome(self): font=FONT(24, "bold"), fg=C["text"], bg=C["bg"]).pack(pady=(0, 6)) tk.Label(self.content_frame, - text="一键保存推特/X 内容到 Obsidian Markdown", + text="一键保存网页内容到 Markdown,多平台多目标", font=FONT(13), fg=C["muted"], bg=C["bg"]).pack(pady=(0, 20)) features = [ - "📄 推文 → Markdown 文件,自动保存到指定文件夹", + "📄 X/Twitter、LINUX DO、飞书、微信公众号一键转 Markdown", "🎞️ 视频自动下载,Obsidian 内嵌播放", - "📰 X Article / Note 长文完整抓取", - "🧵 Thread 长推文一键保存完整线程", + "💾 多目标保存:Obsidian / 飞书多维表格 / Notion / HTML", + "🖼️ 图片本地下载,按平台分类文件夹", ] card = self._make_card(self.content_frame) card.pack(fill="x", padx=30, pady=10) @@ -311,7 +311,7 @@ def _step_extension(self): anchor="w", pady=(8, 2)) tk.Label(self.content_frame, - text="X2MD 需要安装配套的 Chrome 扩展才能在推特页面上抓取内容", + text="X2MD 需要安装配套的 Chrome 扩展才能在网页上抓取内容", font=FONT(12), fg=C["muted"], bg=C["bg"]).pack( anchor="w", pady=(0, 14)) @@ -320,7 +320,7 @@ def _step_extension(self): ("❷", "打开右上角的「开发者模式」开关"), ("❸", "点击左上角「加载已解压的扩展程序」按钮"), ("❹", "选择 X2MD 安装目录下的 extension 文件夹"), - ("❺", "扩展安装完成!在推特页面上点击推文的书签按钮即可保存"), + ("❺", "安装完成!在 X/Twitter、LINUX DO、飞书、微信公众号页面使用保存按钮即可"), ] for num, text in steps: @@ -382,7 +382,7 @@ def _step_finish(self): tk.Frame(card, bg=C["surface"], height=8).pack() tk.Label(self.content_frame, - text="点击「启动服务」后,X2MD 将在系统托盘后台运行\n在 Chrome 中使用扩展即可开始保存推文", + text="点击「启动服务」后,X2MD 将在系统托盘后台运行\n在 Chrome 中使用扩展即可开始保存内容", font=FONT(12), fg=C["muted"], bg=C["bg"], justify="center").pack(pady=(0, 8)) diff --git a/tray_app.py b/tray_app.py index 4723241..f32f78c 100644 --- a/tray_app.py +++ b/tray_app.py @@ -260,26 +260,86 @@ def run_cli_mode(): # 主函数 # ───────────────────────────────────────────── def ensure_extension_accessible(): - """确保 extension 文件夹在用户可访问的位置""" + """确保 extension 文件夹在用户可访问的位置,并在版本升级时自动更新。 + 使用 .x2md_version 标记文件判断是否需要更新。""" global EXT_DIR + import shutil + target = os.path.join(APP_DIR, "extension") + src = os.path.join(RESOURCE_DIR, "extension") + version_file = os.path.join(APP_DIR, ".x2md_version") + + # 读取打包资源中的版本号(从 server.py 的 /ping 接口硬编码值获取,或用 manifest) + bundled_version = "" + manifest_src = os.path.join(src, "manifest.json") if os.path.isdir(src) else "" + if manifest_src and os.path.isfile(manifest_src): + try: + with open(manifest_src, "r", encoding="utf-8") as f: + bundled_version = json.load(f).get("version", "") + except Exception: + pass + + # 读取已安装的版本 + installed_version = "" + if os.path.isfile(version_file): + try: + with open(version_file, "r", encoding="utf-8") as f: + installed_version = f.read().strip() + except Exception: + pass + + need_update = False + if not os.path.isdir(target): + need_update = True + elif bundled_version and bundled_version != installed_version: + need_update = True + + if need_update and os.path.isdir(src): + if os.path.isdir(target): + shutil.rmtree(target) + shutil.copytree(src, target) + # 写入版本标记 + if bundled_version: + with open(version_file, "w", encoding="utf-8") as f: + f.write(bundled_version) + logger.info(f"已将 extension 更新到: {target} (版本: {bundled_version or 'unknown'})") + if os.path.isdir(target): EXT_DIR = target + + +def _merge_config_defaults(): + """将打包资源中的默认配置字段合并到用户现有配置中(不覆盖用户已设置的值)。 + 这样升级版本后新增的配置字段会自动补全。""" + src = os.path.join(RESOURCE_DIR, "config.json") + if not os.path.isfile(src): return + try: + with open(src, "r", encoding="utf-8") as f: + defaults = json.load(f) + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + user_cfg = json.load(f) - # 从打包资源中复制 extension 到 APP_DIR 旁边 - src = os.path.join(RESOURCE_DIR, "extension") - if os.path.isdir(src): - import shutil - shutil.copytree(src, target) - logger.info(f"已将 extension 复制到: {target}") - EXT_DIR = target + changed = False + for key, value in defaults.items(): + if key not in user_cfg: + user_cfg[key] = value + changed = True + + if changed: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(user_cfg, f, ensure_ascii=False, indent=2) + logger.info("已将新版本默认字段合并到用户配置") + except Exception as e: + logger.warning(f"合并配置默认值失败: {e}") def ensure_config_accessible(): """确保 config.json 在 APP_DIR(可写位置),打包后首次运行时从资源目录复制。 - 同时迁移旧版本遗留在 app 包内的配置。""" + 同时迁移旧版本遗留在 app 包内的配置。升级时自动补全新增字段。""" if os.path.exists(CONFIG_FILE): + # 已有配置:合并新版本可能新增的默认字段 + _merge_config_defaults() return # 优先迁移旧版遗留在 MacOS/ 目录内的配置(用户升级场景) @@ -289,6 +349,7 @@ def ensure_config_accessible(): import shutil shutil.copy2(old_config, CONFIG_FILE) logger.info(f"已迁移旧版配置: {old_config} -> {CONFIG_FILE}") + _merge_config_defaults() return # 从打包资源中复制默认配置 diff --git a/x2md.spec b/x2md.spec index 0f6af02..0416c28 100644 --- a/x2md.spec +++ b/x2md.spec @@ -110,7 +110,7 @@ if sys.platform == 'darwin': info_plist={ 'CFBundleName': 'X2MD', 'CFBundleDisplayName': 'X2MD', - 'CFBundleShortVersionString': '1.5.0', + 'CFBundleShortVersionString': '1.5.2', 'LSUIElement': True, # 无 Dock 图标,仅菜单栏显示 }, ) From b52eb4ece4218c1237e391b476fe06f00d763a8f Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 14:03:48 +0000 Subject: [PATCH 41/48] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20X/Twitter=20?= =?UTF-8?q?=E4=B9=A6=E7=AD=BE=E6=8C=89=E9=92=AE=E5=8D=A1=E4=BD=8F=E9=97=AE?= =?UTF-8?q?=E9=A2=98=20-=20=E4=B8=BA=E6=89=80=E6=9C=89=20fetch=20=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=B7=BB=E5=8A=A0=E8=B6=85=E6=97=B6=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 discoverGraphQLOperationIdsFromPage 的页面和脚本请求添加 8s/5s 超时 - 为 fetchViaGraphQL 的 GraphQL API 请求添加 10s 超时 - 为 fetchViaOEmbed 的 oEmbed 请求添加 5s 超时 - 为 save_tweet 处理器的 config 获取添加 3s 超时 - 为 dispatchMultiTargetSave 的 config/save 请求添加 3s/30s 超时 - 为 update_config/reset_config 请求添加 5s/3s 超时 - 为飞书图片上传的图片获取添加 10s 超时 - 为 save_tweet 处理器添加 60s 整体超时保护,防止 MV3 worker 被杀后永远不返回 Co-Authored-By: Claude Opus 4.6 --- extension/background.js | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/extension/background.js b/extension/background.js index e2747f5..cc14b59 100644 --- a/extension/background.js +++ b/extension/background.js @@ -181,6 +181,7 @@ async function discoverGraphQLOperationIdsFromPage(pageUrl) { "x-twitter-active-user": "yes", "x-twitter-client-language": "zh-cn", }, + signal: AbortSignal.timeout(8000), }); if (!htmlResp.ok) { return { TweetDetail: [], TweetResultByRestId: [] }; @@ -199,7 +200,7 @@ async function discoverGraphQLOperationIdsFromPage(pageUrl) { let discovered = { TweetDetail: [], TweetResultByRestId: [] }; for (const scriptUrl of scriptUrls) { - const scriptResp = await fetch(scriptUrl); + const scriptResp = await fetch(scriptUrl, { signal: AbortSignal.timeout(5000) }); if (!scriptResp.ok) continue; const scriptText = await scriptResp.text(); @@ -444,6 +445,7 @@ async function fetchViaGraphQL(tweetId, options = {}) { const resp = await fetch(url, { credentials: "include", headers, + signal: AbortSignal.timeout(10000), }); if (!resp.ok) { @@ -621,7 +623,7 @@ function parseLegacyTweet(result, userLegacy) { async function fetchViaOEmbed(tweetUrl) { try { const apiUrl = `https://publish.twitter.com/oembed?url=${encodeURIComponent(tweetUrl)}&omit_script=true`; - const resp = await fetch(apiUrl); + const resp = await fetch(apiUrl, { signal: AbortSignal.timeout(5000) }); if (!resp.ok) return null; const json = await resp.json(); @@ -859,7 +861,7 @@ async function handleSaveToFeishu(data, _retried) { try { const imgUrl = images[i]; if (!imgUrl || !imgUrl.startsWith("http")) continue; - const imgResp = await fetch(imgUrl); + const imgResp = await fetch(imgUrl, { signal: AbortSignal.timeout(10000) }); if (!imgResp.ok) continue; const imgBlob = await imgResp.blob(); const ext = (imgUrl.match(/\.(jpe?g|png|gif|webp|svg)/i) || [".jpg"])[0]; @@ -1352,7 +1354,7 @@ async function dispatchMultiTargetSave(data, serverBase, existingCfg = null) { let cfg = existingCfg || {}; if (!existingCfg) { try { - const cfgResp = await fetch(`${serverBase}/config`); + const cfgResp = await fetch(`${serverBase}/config`, { signal: AbortSignal.timeout(3000) }); if (cfgResp.ok) cfg = await cfgResp.json(); try { const syncData = await chrome.storage.sync.get("x2md_sync"); @@ -1379,6 +1381,7 @@ async function dispatchMultiTargetSave(data, serverBase, existingCfg = null) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), + signal: AbortSignal.timeout(30000), }); const json = await resp.json(); saveResults.push({ target: "obsidian", success: json.success !== false, result: json }); @@ -1458,6 +1461,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "save_tweet") { (async () => { const serverBase = SERVER_BASE; // 快照,防止并发修改 + // 整体超时保护:防止 MV3 service worker 被杀后 callback 永远不返回 + const OVERALL_TIMEOUT = 60000; // 60 秒整体超时 + const timeoutId = setTimeout(() => { + console.error("[x2md] save_tweet 整体超时(60s),强制返回错误"); + sendResponse({ success: false, error: "操作超时,请检查网络连接和本地服务是否正常运行" }); + }, OVERALL_TIMEOUT); try { let data = message.data; @@ -1584,7 +1593,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { let durationThresholdMin = 5; let cfg = {}; try { - const cfgResp = await fetch(`${serverBase}/config`); + const cfgResp = await fetch(`${serverBase}/config`, { signal: AbortSignal.timeout(3000) }); if (!cfgResp.ok) throw new Error(`HTTP ${cfgResp.status}`); cfg = await cfgResp.json(); // 合并 chrome.storage.sync 中的扩展配置 @@ -1614,6 +1623,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (maxDurationMin > durationThresholdMin) { debugLog(`发现超长视频 (${maxDurationMin.toFixed(1)} > ${durationThresholdMin} min),要求前台确认`); + clearTimeout(timeoutId); sendResponse({ require_video_confirm: true, durationMin: maxDurationMin.toFixed(1), @@ -1644,9 +1654,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // ── 多目标保存分发(复用共享函数,传入已获取的 cfg 避免重复请求)── const dispatchResult = await dispatchMultiTargetSave(data, serverBase, cfg); + clearTimeout(timeoutId); sendResponse(dispatchResult); } catch (err) { + clearTimeout(timeoutId); console.error("[x2md] 后台处理或请求失败:", err); sendResponse({ success: false, error: err.message || String(err) }); } @@ -1717,7 +1729,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const resp = await fetch(`${serverBase}/config`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(message.config) + body: JSON.stringify(message.config), + signal: AbortSignal.timeout(5000), }); const json = await resp.json(); // 如果开启了同步,将可同步字段写入 chrome.storage.sync @@ -1752,7 +1765,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "reset_config") { (async () => { try { - const resp = await fetch(`${SERVER_BASE}/config/reset`, { method: "POST" }); + const resp = await fetch(`${SERVER_BASE}/config/reset`, { method: "POST", signal: AbortSignal.timeout(3000) }); const json = await resp.json(); await chrome.storage.sync.remove("x2md_sync").catch(() => {}); invalidateConfigCache(); From cf479cdf91cc24cb1e203340394af216797f2bcc Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 14:12:43 +0000 Subject: [PATCH 42/48] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E9=A3=9E?= =?UTF-8?q?=E4=B9=A6=E5=AD=97=E6=AE=B5=E9=AA=8C=E8=AF=81+=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=9B=E5=BB=BA=E3=80=81=E5=BE=AE=E4=BF=A1=E4=BB=98?= =?UTF-8?q?=E8=B4=B9=E6=96=87=E7=AB=A0=E9=81=AE=E7=BD=A9=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E3=80=81Discourse=E8=B6=85=E6=97=B6=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 飞书多维表格:测试连接时完整验证 7 个必需字段(名称+类型), 缺失字段自动调用 API 创建,无需手动配置 2. 微信公众号:付费文章保存前自动移除付费遮罩层(CSS覆盖+DOM清理), 尝试提取完整内容而非仅保存截断预览 3. Discourse/LINUX DO:fetchDiscourseReplies 添加 15s AbortSignal.timeout, 防止 JSON API 请求无限挂起 4. 选项页面:飞书测试连接结果展示增强,显示字段验证详情和自动创建结果 Co-Authored-By: Claude Opus 4.6 --- extension/background.js | 136 ++++++++++++++++++++++++++++++++++++++-- extension/discourse.js | 4 +- extension/options.js | 24 ++++++- extension/wechat.js | 94 ++++++++++++++++++++++++++- 4 files changed, 247 insertions(+), 11 deletions(-) diff --git a/extension/background.js b/extension/background.js index cc14b59..dee71db 100644 --- a/extension/background.js +++ b/extension/background.js @@ -902,19 +902,147 @@ async function handleSaveToFeishu(data, _retried) { } } +// 飞书字段类型映射 +const FEISHU_FIELD_TYPES = { + TEXT: 1, // 文本 + NUMBER: 2, // 数字 + DATE: 5, // 日期 + URL: 15, // 超链接 + ATTACHMENT: 17, // 附件 +}; + +// 保存到飞书时使用的必需字段(与 handleSaveToFeishu 中的 fields 对象保持一致) +const FEISHU_REQUIRED_FIELDS = [ + { name: "标题", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, + { name: "链接", type: FEISHU_FIELD_TYPES.URL, desc: "超链接" }, + { name: "作者", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, + { name: "保存时间", type: FEISHU_FIELD_TYPES.DATE, desc: "日期" }, + { name: "类型", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, + { name: "正文", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, + { name: "附件", type: FEISHU_FIELD_TYPES.ATTACHMENT, desc: "附件" }, +]; + +function getFeishuFieldTypeName(typeCode) { + const map = { 1: "文本", 2: "数字", 3: "单选", 4: "多选", 5: "日期", 7: "复选框", 11: "人员", 13: "电话", 15: "超链接", 17: "附件", 18: "单向关联", 21: "查找引用", 22: "公式", 23: "双向关联" }; + return map[typeCode] || `未知(${typeCode})`; +} + +async function createFeishuField(baseUrl, appToken, tableId, token, fieldName, fieldType) { + const resp = await fetchWithRetry(`${baseUrl}/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/fields`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ field_name: fieldName, type: fieldType }), + }, 1); + const result = await resp.json(); + if (result.code !== 0) throw new Error(`创建字段「${fieldName}」失败: ${result.msg}`); + return result.data?.field; +} + async function handleTestFeishu(data) { const baseUrl = FEISHU_API_DOMAINS[data.feishu_api_domain] || FEISHU_API_DOMAINS.feishu; try { + // 步骤1: 获取 token const token = await getFeishuAccessToken(data.feishu_app_id, data.feishu_app_secret, data.feishu_api_domain); + + // 步骤2: 获取表格字段列表 const resp = await fetchWithRetry(`${baseUrl}/open-apis/bitable/v1/apps/${data.feishu_app_token}/tables/${data.feishu_table_id}/fields`, { headers: { Authorization: `Bearer ${token}` }, }, 1); const result = await resp.json(); if (result.code !== 0) throw new Error(`表格访问失败: ${result.msg}`); - const fields = (result.data?.items || []).map(f => f.field_name); - const required = ["标题", "链接", "作者", "保存时间"]; - const missing = required.filter(f => !fields.includes(f)); - return { success: true, fieldCount: fields.length, fields, missingFields: missing }; + + const existingFields = result.data?.items || []; + const fieldMap = {}; + existingFields.forEach(f => { + fieldMap[f.field_name] = { type: f.type, typeName: getFeishuFieldTypeName(f.type) }; + }); + + // 步骤3: 验证字段配置 + const missingFields = []; + const wrongTypeFields = []; + + FEISHU_REQUIRED_FIELDS.forEach(required => { + const existing = fieldMap[required.name]; + if (!existing) { + missingFields.push(required); + } else if (existing.type !== required.type) { + wrongTypeFields.push({ + name: required.name, + expected: required.desc, + actual: existing.typeName, + }); + } + }); + + // 步骤4: 如果有缺失字段,自动创建 + const createdFields = []; + if (missingFields.length > 0) { + for (const field of missingFields) { + try { + await createFeishuField(baseUrl, data.feishu_app_token, data.feishu_table_id, token, field.name, field.type); + createdFields.push(field.name); + } catch (e) { + // 自动创建失败时记录但不中断 + console.warn(`[x2md] 自动创建飞书字段「${field.name}」失败:`, e.message); + } + } + } + + // 步骤5: 测试访问记录 + const recordsResp = await fetchWithRetry(`${baseUrl}/open-apis/bitable/v1/apps/${data.feishu_app_token}/tables/${data.feishu_table_id}/records?page_size=1`, { + headers: { Authorization: `Bearer ${token}` }, + }, 1); + const recordsData = await recordsResp.json(); + const recordCount = recordsData.data?.total || 0; + + // 构建结果消息 + const domainName = data.feishu_api_domain === "lark" ? "Lark 国际版" : "飞书国内版"; + const allFieldNames = existingFields.map(f => f.field_name); + const remainingMissing = missingFields.filter(f => !createdFields.includes(f.name)); + + let message = `连接成功!\n\n`; + message += `配置信息:\n`; + message += ` API 版本:${domainName}\n`; + message += ` 应用认证:通过\n`; + message += ` 表格访问:正常\n`; + message += ` 现有记录:${recordCount} 条\n\n`; + + if (createdFields.length > 0) { + message += `已自动创建缺失字段(${createdFields.length}个):\n`; + message += ` ${createdFields.join("、")}\n\n`; + } + + if (wrongTypeFields.length > 0) { + message += `字段类型不匹配(${wrongTypeFields.length}个):\n`; + wrongTypeFields.forEach(f => { + message += ` 「${f.name}」期望: ${f.expected}, 实际: ${f.actual}\n`; + }); + message += `请在飞书多维表格中修正字段类型。\n\n`; + } + + if (remainingMissing.length > 0) { + message += `仍缺失字段(${remainingMissing.length}个,自动创建失败):\n`; + remainingMissing.forEach(f => { + message += ` 「${f.name}」(类型: ${f.desc})\n`; + }); + message += `请手动在飞书多维表格中创建。\n\n`; + } + + if (wrongTypeFields.length === 0 && remainingMissing.length === 0) { + message += `字段验证通过(${FEISHU_REQUIRED_FIELDS.length}个必需字段)\n`; + } + + message += `\n当前表格字段:${allFieldNames.concat(createdFields).join("、")}`; + + return { + success: wrongTypeFields.length === 0 && remainingMissing.length === 0, + message, + fieldCount: allFieldNames.length + createdFields.length, + fields: allFieldNames.concat(createdFields), + createdFields, + missingFields: remainingMissing.map(f => f.name), + wrongTypeFields: wrongTypeFields.map(f => f.name), + }; } catch (err) { return { success: false, error: err.message }; } diff --git a/extension/discourse.js b/extension/discourse.js index e73917b..465faaf 100644 --- a/extension/discourse.js +++ b/extension/discourse.js @@ -356,7 +356,9 @@ async function fetchDiscourseReplies(topicId, hostname) { const host = hostname || globalScope.location?.hostname || "linux.do"; - const resp = await fetch(`https://${host}/t/${topicId}.json`); + const resp = await fetch(`https://${host}/t/${topicId}.json`, { + signal: AbortSignal.timeout(15000), + }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); const posts = data.post_stream?.posts || []; diff --git a/extension/options.js b/extension/options.js index 05ce1e5..7492d7f 100644 --- a/extension/options.js +++ b/extension/options.js @@ -427,9 +427,26 @@ function testFeishuConnection() { feishu_table_id: tableId, } }, (resp) => { - if (resp && resp.success) { + if (resp && resp.message) { + // 新版详细响应 + const hasWarnings = (resp.wrongTypeFields?.length > 0) || (resp.missingFields?.length > 0); + if (resp.success) { + statusEl.className = hasWarnings ? "status-msg warning" : "status-msg success"; + } else { + statusEl.className = "status-msg error"; + } + statusEl.style.whiteSpace = "pre-wrap"; + let msg = resp.message; + if (resp.createdFields?.length > 0) { + msg = "✅ " + msg; + } else if (resp.success && !hasWarnings) { + msg = "✅ " + msg; + } + statusEl.textContent = msg; + } else if (resp && resp.success) { + // 兼容旧版简单响应 statusEl.className = "status-msg success"; - let msg = `连接成功!检测到 ${resp.fieldCount || 0} 个字段`; + let msg = `✅ 连接成功!检测到 ${resp.fieldCount || 0} 个字段`; if (resp.missingFields && resp.missingFields.length > 0) { msg += `\n⚠️ 缺少字段:${resp.missingFields.join("、")}`; statusEl.className = "status-msg warning"; @@ -437,8 +454,9 @@ function testFeishuConnection() { statusEl.textContent = msg; } else { statusEl.className = "status-msg error"; - statusEl.textContent = "连接失败:" + (resp?.error || "未知错误"); + statusEl.textContent = "❌ 连接失败:" + (resp?.error || "未知错误"); } + statusEl.style.display = "block"; }); } diff --git a/extension/wechat.js b/extension/wechat.js index 322d259..4570caf 100644 --- a/extension/wechat.js +++ b/extension/wechat.js @@ -373,12 +373,95 @@ return !!(payBar || payBtn); } + /** + * 尝试移除微信付费文章的遮罩层,暴露完整内容。 + * 付费文章通过 CSS 隐藏/截断内容区域并覆盖付费提示遮罩。 + * 此函数移除这些限制,让已加载到 DOM 中的完整内容可见并可提取。 + */ + function removeWechatPaywall(doc = document) { + // 1. 移除付费遮罩层(覆盖在内容区上方的半透明渐变遮罩) + const overlaySelectors = [ + "#js_pay_bar", + ".pay_content_area", + ".pay_tips_area", + ".js_pay_preview_wap", + ".pay_wall_wrap", + ".pay-wall__content", + ".pay_wall_mask", // 渐变遮罩 + ".pay_content_mask", // 内容遮罩 + ".rich_media_pay_area", // 付费区域包装 + ]; + overlaySelectors.forEach(sel => { + doc.querySelectorAll?.(sel).forEach(el => { + el.style.display = "none"; + el.remove(); + }); + }); + + // 2. 移除内容区域的高度限制和 overflow:hidden + const contentArea = doc.querySelector?.("#js_content"); + if (contentArea) { + contentArea.style.maxHeight = "none"; + contentArea.style.height = "auto"; + contentArea.style.overflow = "visible"; + contentArea.style.webkitLineClamp = "unset"; + contentArea.style.webkitBoxOrient = "unset"; + } + + // 3. 移除 #js_content_cutoff(微信用这个 div 标记截断位置) + const cutoff = doc.querySelector?.("#js_content_cutoff"); + if (cutoff) { + cutoff.style.display = "none"; + cutoff.remove(); + } + + // 4. 显示所有被 display:none 隐藏的付费内容区段 + // 微信付费文章将付费内容 section 设为 display:none + const hiddenSections = doc.querySelectorAll?.("#js_content > section[style*='display'], #js_content > div[style*='display']") || []; + hiddenSections.forEach(section => { + if (section.style.display === "none") { + section.style.display = ""; + } + }); + + // 5. 移除 .rich_media_area_extra 上的截断样式 + const extraArea = doc.querySelector?.(".rich_media_area_extra"); + if (extraArea) { + extraArea.style.maxHeight = "none"; + extraArea.style.overflow = "visible"; + } + + // 6. 注入 CSS 覆盖微信的付费墙样式 + const style = doc.createElement?.("style"); + if (style) { + style.textContent = ` + #js_content { max-height: none !important; overflow: visible !important; } + .pay_content_mask, .pay_wall_mask, #js_pay_bar, .pay_content_area, + .pay_tips_area, .js_pay_preview_wap, .pay_wall_wrap, .rich_media_pay_area { + display: none !important; + } + #js_content > section, #js_content > div, #js_content > p { + display: block !important; + visibility: visible !important; + opacity: 1 !important; + } + `; + (doc.head || doc.documentElement)?.appendChild(style); + } + } + function extractWechatDocumentData(doc = document, options = {}) { const pageUrl = options.pageUrl || doc.location?.href || globalScope.location?.href || ""; const root = doc.querySelector?.("#js_content"); if (!root) return null; const isPaid = detectWechatPaywall(doc); + + // 付费文章:先尝试移除遮罩层,暴露完整内容 + if (isPaid) { + removeWechatPaywall(doc); + } + const articleContent = extractWechatMarkdown(root, { pageUrl }); if (!articleContent && !isPaid) return null; @@ -407,12 +490,16 @@ // 提取视频 const videos = extractWechatVideos(doc); - // 付费文章:添加提示 + // 付费文章:标记标签,提示已尝试解除遮罩 let finalContent = articleContent || ""; if (isPaid) { tags.push("付费文章"); - if (!finalContent) finalContent = "> 这是一篇付费文章,以下为免费预览部分。"; - else finalContent += "\n\n---\n\n> 以下内容为付费部分,未能获取。\n"; + if (!finalContent) { + finalContent = "> 这是一篇付费文章,遮罩层已移除但未提取到内容(内容可能需要服务端解密)。"; + } else { + // 已移除遮罩并成功提取到内容,添加来源说明 + finalContent = "> 本文为付费文章,已尝试移除遮罩层提取完整内容。\n\n" + finalContent; + } } return { @@ -439,6 +526,7 @@ extractWechatMarkdown, extractWechatVideos, isWechatArticlePage, + removeWechatPaywall, resolveWechatImageUrl, }; From 88a74fafa0f26f23b8b80aebdca22bae72359d5d Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 14:16:03 +0000 Subject: [PATCH 43/48] =?UTF-8?q?fix:=20=E9=A3=9E=E4=B9=A6=E5=BF=85?= =?UTF-8?q?=E9=9C=80=E5=AD=97=E6=AE=B5=E4=BF=AE=E6=AD=A3=E4=B8=BA=209=20?= =?UTF-8?q?=E4=B8=AA=EF=BC=8C=E4=BF=9D=E5=AD=98=E6=97=B6=E5=86=99=E5=85=A5?= =?UTF-8?q?=E5=88=86=E7=B1=BB/=E6=A0=87=E7=AD=BE/=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对齐 FEISHU-FIELD-VALIDATION.md 的 9 个必需字段: 标题、链接、作者、分类、标签、保存时间、评论数、附件、正文 handleSaveToFeishu 新增写入: - 分类:使用 platform 字段(微信公众号/LINUX DO/飞书等) - 标签:使用 tags 数组拼接 - 评论数:使用 comments 数组长度 Co-Authored-By: Claude Opus 4.6 --- extension/background.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/extension/background.js b/extension/background.js index dee71db..3c6eb1f 100644 --- a/extension/background.js +++ b/extension/background.js @@ -871,12 +871,17 @@ async function handleSaveToFeishu(data, _retried) { } catch (_) { /* 单张图片失败不影响整体 */ } } + // 构建字段(与 FEISHU_REQUIRED_FIELDS 9 个必需字段对齐) + const tags = Array.isArray(data.tags) ? data.tags : []; + const commentCount = Array.isArray(data.comments) ? data.comments.length : 0; const fields = { "标题": title, "链接": { link: data.url || "", text: data.url || "" }, "作者": data.author || "", + "分类": data.platform || (data.type === "article" ? "文章" : data.type === "thread" ? "Thread" : "推文"), + "标签": tags.join("、"), "保存时间": Date.now(), - "类型": data.type === "article" ? "文章" : data.type === "thread" ? "Thread" : "推文", + "评论数": commentCount, }; if (!data.feishu_upload_md) { fields["正文"] = sanitizeFeishuTextContent(data.article_content || data.text || ""); @@ -911,15 +916,17 @@ const FEISHU_FIELD_TYPES = { ATTACHMENT: 17, // 附件 }; -// 保存到飞书时使用的必需字段(与 handleSaveToFeishu 中的 fields 对象保持一致) +// 飞书多维表格必需字段(9 个,与上游 FEISHU-FIELD-VALIDATION.md 保持一致) const FEISHU_REQUIRED_FIELDS = [ { name: "标题", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, { name: "链接", type: FEISHU_FIELD_TYPES.URL, desc: "超链接" }, { name: "作者", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, + { name: "分类", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, + { name: "标签", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, { name: "保存时间", type: FEISHU_FIELD_TYPES.DATE, desc: "日期" }, - { name: "类型", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, - { name: "正文", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, + { name: "评论数", type: FEISHU_FIELD_TYPES.NUMBER, desc: "数字" }, { name: "附件", type: FEISHU_FIELD_TYPES.ATTACHMENT, desc: "附件" }, + { name: "正文", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, ]; function getFeishuFieldTypeName(typeCode) { From 525050d922f48d5526295e259a384f8b63e730d5 Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 14:17:45 +0000 Subject: [PATCH 44/48] =?UTF-8?q?fix:=20=E9=A3=9E=E4=B9=A6=E5=BF=85?= =?UTF-8?q?=E9=9C=80=E5=AD=97=E6=AE=B5=E4=BF=AE=E6=AD=A3=E4=B8=BA=207=20?= =?UTF-8?q?=E4=B8=AA=EF=BC=8C=E4=BD=9C=E8=80=85=E6=94=B9=E4=B8=BA=E5=8D=95?= =?UTF-8?q?=E9=80=89=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按照阿成确认的字段配置: 标题(文本)、链接(超链接)、作者(单选)、分类(文本)、 标签(文本)、保存时间(日期)、评论数(数字) 移除附件和正文两个必需字段(保存时仍会写入,但不作为必需验证项) Co-Authored-By: Claude Opus 4.6 --- extension/background.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/extension/background.js b/extension/background.js index 3c6eb1f..b263d06 100644 --- a/extension/background.js +++ b/extension/background.js @@ -871,21 +871,18 @@ async function handleSaveToFeishu(data, _retried) { } catch (_) { /* 单张图片失败不影响整体 */ } } - // 构建字段(与 FEISHU_REQUIRED_FIELDS 9 个必需字段对齐) + // 构建字段(与 FEISHU_REQUIRED_FIELDS 7 个必需字段对齐) const tags = Array.isArray(data.tags) ? data.tags : []; const commentCount = Array.isArray(data.comments) ? data.comments.length : 0; const fields = { "标题": title, "链接": { link: data.url || "", text: data.url || "" }, - "作者": data.author || "", + "作者": data.author || "", // 单选字段:飞书单选传字符串即可,不存在的选项会自动创建 "分类": data.platform || (data.type === "article" ? "文章" : data.type === "thread" ? "Thread" : "推文"), "标签": tags.join("、"), "保存时间": Date.now(), "评论数": commentCount, }; - if (!data.feishu_upload_md) { - fields["正文"] = sanitizeFeishuTextContent(data.article_content || data.text || ""); - } if (mdToken) fields["附件"] = [{ file_token: mdToken }]; if (htmlToken) fields["HTML附件"] = [{ file_token: htmlToken }]; if (imageTokens.length > 0) fields["图片"] = imageTokens; @@ -911,22 +908,21 @@ async function handleSaveToFeishu(data, _retried) { const FEISHU_FIELD_TYPES = { TEXT: 1, // 文本 NUMBER: 2, // 数字 + SELECT: 3, // 单选 DATE: 5, // 日期 URL: 15, // 超链接 ATTACHMENT: 17, // 附件 }; -// 飞书多维表格必需字段(9 个,与上游 FEISHU-FIELD-VALIDATION.md 保持一致) +// 飞书多维表格必需字段(7 个) const FEISHU_REQUIRED_FIELDS = [ { name: "标题", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, { name: "链接", type: FEISHU_FIELD_TYPES.URL, desc: "超链接" }, - { name: "作者", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, + { name: "作者", type: FEISHU_FIELD_TYPES.SELECT, desc: "单选" }, { name: "分类", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, { name: "标签", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, { name: "保存时间", type: FEISHU_FIELD_TYPES.DATE, desc: "日期" }, { name: "评论数", type: FEISHU_FIELD_TYPES.NUMBER, desc: "数字" }, - { name: "附件", type: FEISHU_FIELD_TYPES.ATTACHMENT, desc: "附件" }, - { name: "正文", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, ]; function getFeishuFieldTypeName(typeCode) { From f0667138a7e90eb45cdc2c755269ad24be64a02b Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 14:58:40 +0000 Subject: [PATCH 45/48] =?UTF-8?q?feat:=20=E9=A3=9E=E4=B9=A6=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E8=AE=B0=E5=BD=95=E6=8F=90=E5=8F=96=20+=20Discourse?= =?UTF-8?q?=E5=A4=9A=E5=9F=9F=E5=90=8D=E4=BF=AE=E5=A4=8D=20+=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=A0=B8=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.6.0 新功能与修复: - 新增飞书 messenger 聊天记录提取(双策略:内部API注入 + DOM兜底) - 修复 handleFloatingSave 缺少 discourse case 导致自定义 Discourse 域名点击无响应 - 修复 setSiteDiscourseDomains 未调用导致浮动按钮在自定义 Discourse 域名不显示 - 增加 sendToBackground 诊断日志,便于排查 URL 传递问题 - manifest.json 添加 feishu.cn/messenger 和 larksuite.com/messenger 权限 - site_actions.js 添加 feishu_chat 浮动按钮配置 Co-Authored-By: Claude Opus 4.6 --- extension/background.js | 42 +++- extension/content.js | 82 ++++++- extension/feishu.js | 466 +++++++++++++++++++++++++++++++++++++- extension/manifest.json | 8 +- extension/options.html | 4 + extension/options.js | 14 +- extension/site_actions.js | 14 ++ 7 files changed, 616 insertions(+), 14 deletions(-) diff --git a/extension/background.js b/extension/background.js index b263d06..fbda033 100644 --- a/extension/background.js +++ b/extension/background.js @@ -918,7 +918,7 @@ const FEISHU_FIELD_TYPES = { const FEISHU_REQUIRED_FIELDS = [ { name: "标题", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, { name: "链接", type: FEISHU_FIELD_TYPES.URL, desc: "超链接" }, - { name: "作者", type: FEISHU_FIELD_TYPES.SELECT, desc: "单选" }, + { name: "作者", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, { name: "分类", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, { name: "标签", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, { name: "保存时间", type: FEISHU_FIELD_TYPES.DATE, desc: "日期" }, @@ -960,11 +960,19 @@ async function handleTestFeishu(data) { fieldMap[f.field_name] = { type: f.type, typeName: getFeishuFieldTypeName(f.type) }; }); - // 步骤3: 验证字段配置 + // 步骤3: 构建完整字段列表(7个基础 + 1个条件字段) + const requiredFields = [...FEISHU_REQUIRED_FIELDS]; + if (data.feishu_upload_md) { + requiredFields.push({ name: "附件", type: FEISHU_FIELD_TYPES.ATTACHMENT, desc: "附件" }); + } else { + requiredFields.push({ name: "正文", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }); + } + + // 步骤4: 验证字段配置 const missingFields = []; const wrongTypeFields = []; - FEISHU_REQUIRED_FIELDS.forEach(required => { + requiredFields.forEach(required => { const existing = fieldMap[required.name]; if (!existing) { missingFields.push(required); @@ -1097,6 +1105,10 @@ function buildNotionProperties(d) { if (pm.tags && d.tags?.length > 0) props[pm.tags] = { multi_select: d.tags.map(t => ({ name: t })) }; if (pm.savedDate) props[pm.savedDate] = { date: { start: new Date().toISOString().split("T")[0] } }; if (pm.type) props[pm.type] = { select: { name: d.type || "推文" } }; + if (pm.commentCount) { + const count = Array.isArray(d.comments) ? d.comments.length : (typeof d.commentCount === "number" ? d.commentCount : 0); + props[pm.commentCount] = { number: count }; + } return props; } @@ -1273,6 +1285,7 @@ async function handleSaveToNotion(data) { tags: data.notion_prop_tags || "", savedDate: data.notion_prop_saved_date || "", type: data.notion_prop_type || "", + commentCount: data.notion_prop_comment_count || "", }; const title = data.article_title || data.title || (data.text || "").slice(0, 50) || "Untitled"; const content = data.article_content || data.markdown || data.text || ""; @@ -1304,9 +1317,25 @@ async function handleTestNotion(data) { const database = await notionFetch(`/databases/${data.notion_database_id}`, data.notion_token, { method: "GET" }); const props = database.properties || {}; const missing = []; - const pm = { title: data.notion_prop_title || "标题", url: data.notion_prop_url || "链接" }; - if (pm.title && (!props[pm.title] || props[pm.title].type !== "title")) missing.push(`"${pm.title}" (需要 Title 类型)`); - if (pm.url && (!props[pm.url] || props[pm.url].type !== "url")) missing.push(`"${pm.url}" (需要 URL 类型)`); + const expectedFields = [ + { key: "notion_prop_title", default: "标题", type: "title", label: "Title" }, + { key: "notion_prop_url", default: "链接", type: "url", label: "URL" }, + { key: "notion_prop_author", default: "作者", type: "rich_text", label: "Text" }, + { key: "notion_prop_tags", default: "标签", type: "multi_select", label: "Multi Select" }, + { key: "notion_prop_saved_date", default: "保存日期", type: "date", label: "Date" }, + { key: "notion_prop_type", default: "类型", type: "select", label: "Select" }, + { key: "notion_prop_comment_count", default: "评论数", type: "number", label: "Number" }, + ]; + for (const field of expectedFields) { + const name = data[field.key] || field.default; + if (!name) continue; + const prop = props[name]; + if (!prop) { + missing.push(`"${name}" (需要 ${field.label} 类型)`); + } else if (prop.type !== field.type) { + missing.push(`"${name}" (期望 ${field.label}, 实际 ${prop.type})`); + } + } return { success: true, databaseTitle: database.title?.[0]?.plain_text || "未命名", propertyCount: Object.keys(props).length, missingProperties: missing }; } catch (err) { return { success: false, error: err.message }; @@ -1555,6 +1584,7 @@ async function dispatchMultiTargetSave(data, serverBase, existingCfg = null) { notion_prop_tags: cfg.notion_prop_tags, notion_prop_saved_date: cfg.notion_prop_saved_date, notion_prop_type: cfg.notion_prop_type, + notion_prop_comment_count: cfg.notion_prop_comment_count, }).catch(e => ({ success: false, error: e.message, target: "notion" })) ); } diff --git a/extension/content.js b/extension/content.js index 88719a7..79dc637 100644 --- a/extension/content.js +++ b/extension/content.js @@ -454,9 +454,14 @@ function requestRuntimeConfig() { chrome.runtime.sendMessage({ action: "get_config" }, (resp) => { runtimeConfig = resp?.success ? (resp.config || {}) : {}; - // 初始化 Discourse 域名列表 - if (runtimeConfig.discourse_domains && typeof setDiscourseDomains === "function") { - setDiscourseDomains(runtimeConfig.discourse_domains); + // 初始化 Discourse 域名列表(同步到 discourse.js 和 site_actions.js) + if (runtimeConfig.discourse_domains) { + if (typeof setDiscourseDomains === "function") { + setDiscourseDomains(runtimeConfig.discourse_domains); + } + if (typeof setSiteDiscourseDomains === "function") { + setSiteDiscourseDomains(runtimeConfig.discourse_domains); + } } ensureFloatingSaveButton(); ensureFloatingCopyButton(); @@ -751,8 +756,69 @@ function captureWechatArticle() { sendToBackground(data); } +function captureFeishuChat() { + showToast("正在提取飞书聊天记录…", "loading", null); + + // 策略1:通过飞书内部 API 获取消息 JSON + fetchFeishuChatViaInternalApi().then((chatData) => { + if (chatData && chatData.messages && chatData.messages.length > 0) { + debugLog(" Feishu Chat(内部 API 获取 " + chatData.messageCount + " 条消息)"); + const chatName = chatData.chatName || "飞书聊天记录"; + const markdown = convertFeishuChatToMarkdown(chatData.messages, chatData.userMap || {}); + + if (markdown && markdown.length >= 20) { + showToast("正在保存聊天记录(" + chatData.messageCount + " 条消息)…", "loading", null); + sendToBackground({ + type: "article", + url: cleanFeishuUrl(location.href), + author: "", + handle: "", + author_url: "", + published: "", + article_title: chatName, + article_content: markdown, + images: [], + videos: [], + platform: "飞书聊天", + }); + return; + } + } + + // 内部 API 失败时的诊断信息 + if (chatData && chatData.error === "no_chat_id") { + debugLog(" Feishu Chat: 无法识别当前聊天 ID,降级到 DOM 提取"); + } else if (chatData && chatData.error) { + debugLog(" Feishu Chat API 错误: " + chatData.error); + } else { + debugLog(" Feishu Chat: 内部 API 未返回消息,降级到 DOM 提取"); + } + + // 策略2(兜底):DOM 直接提取当前可见消息 + showToast("尝试从页面提取聊天记录…", "loading", null); + const domData = extractFeishuChatFromDOM(document); + if (domData && domData.article_content && domData.article_content.length >= 20) { + debugLog(" Feishu Chat(DOM 提取)"); + showToast("正在保存聊天记录…", "loading", null); + sendToBackground(domData); + } else { + showToast("聊天记录提取失败。请确保已打开一个聊天会话。", "error", 5000); + } + }).catch((err) => { + console.error("[x2md] Feishu Chat 提取出错:", err); + // 最后兜底尝试 DOM + const domData = extractFeishuChatFromDOM(document); + if (domData && domData.article_content && domData.article_content.length >= 20) { + showToast("正在保存聊天记录…", "loading", null); + sendToBackground(domData); + } else { + showToast("聊天记录提取失败", "error", 4000); + } + }); +} + function handleFloatingSave(siteKey) { - if (siteKey === "linux_do") { + if (siteKey === "linux_do" || siteKey === "discourse") { captureLinuxDoPostElement(findCurrentLinuxDoPost()); return; } @@ -760,6 +826,10 @@ function handleFloatingSave(siteKey) { captureFeishuDocument(); return; } + if (siteKey === "feishu_chat") { + captureFeishuChat(); + return; + } if (siteKey === "wechat") { captureWechatArticle(); } @@ -890,6 +960,10 @@ function captureAndSend(btn) { } function sendToBackground(data) { + debugLog("sendToBackground:", { type: data.type, url: data.url, platform: data.platform, hasContent: !!(data.article_content || data.text) }); + if (!data.url) { + console.warn("[x2md] 警告:发送数据缺少 URL 字段", data); + } chrome.runtime.sendMessage({ action: "save_tweet", data }, (resp) => { if (chrome.runtime.lastError) { console.error("[x2md] 扩展通信失败:", chrome.runtime.lastError); diff --git a/extension/feishu.js b/extension/feishu.js index 8048eb0..2604025 100644 --- a/extension/feishu.js +++ b/extension/feishu.js @@ -46,7 +46,7 @@ } // ───────────────────────────────────────────── - // 飞书页面类型检测(扩展:支持 wiki/docx/minutes/sheets/mindnotes) + // 飞书页面类型检测(扩展:支持 wiki/docx/minutes/sheets/mindnotes/messenger) // ───────────────────────────────────────────── function detectFeishuPageType(locationLike) { @@ -63,6 +63,7 @@ if (/^\/mindnotes\/[^/]+/.test(pathname)) return "mindnotes"; if (/^\/docs\/[^/]+/.test(pathname)) return "docs"; // 旧版飞书文档 if (/^\/drive\/[^/]+/.test(pathname)) return "drive"; + if (/^\/messenger\b/.test(pathname)) return "messenger"; return null; } @@ -832,12 +833,473 @@ return md ? md.replace(/\n{3,}/g, "\n\n").trim() : null; } + // ───────────────────────────────────────────── + // 飞书 Messenger 聊天记录提取 + // ───────────────────────────────────────────── + + /** + * 将飞书消息 body.content JSON 转为 Markdown 文本 + * msg_type: text / post / image / file / media / sticker / share_chat / share_user / system / merge_forward 等 + */ + function convertFeishuMsgContent(msgType, contentJson) { + if (!contentJson) return ""; + var content; + try { + content = typeof contentJson === "string" ? JSON.parse(contentJson) : contentJson; + } catch (e) { + return String(contentJson); + } + + if (msgType === "text") { + return String(content.text || "").trim(); + } + + if (msgType === "post") { + // 富文本消息:多语言 → 取第一个可用语言 + var langContent = content.zh_cn || content.en_us || content[Object.keys(content)[0]]; + if (!langContent) return ""; + var parts = []; + if (langContent.title) parts.push("**" + langContent.title + "**"); + var paragraphs = langContent.content || []; + for (var p = 0; p < paragraphs.length; p++) { + var line = ""; + var elements = paragraphs[p] || []; + for (var e = 0; e < elements.length; e++) { + var el = elements[e]; + if (el.tag === "text") line += el.text || ""; + else if (el.tag === "a") line += "[" + (el.text || el.href || "") + "](" + (el.href || "") + ")"; + else if (el.tag === "at") line += "@" + (el.user_name || el.user_id || ""); + else if (el.tag === "img") line += "![图片](feishu-image://" + (el.image_key || "") + ")"; + else if (el.tag === "media") line += "[视频/媒体]"; + else if (el.tag === "emotion") line += el.emoji_type || "[表情]"; + else line += el.text || ""; + } + parts.push(line); + } + return parts.join("\n").trim(); + } + + if (msgType === "image") { + return "![图片](feishu-image://" + (content.image_key || "") + ")"; + } + + if (msgType === "file") { + return "[文件: " + (content.file_name || "未知文件") + "]"; + } + + if (msgType === "media") { + return "[媒体: " + (content.file_name || "视频/音频") + "]"; + } + + if (msgType === "sticker") { + return "[表情包]"; + } + + if (msgType === "share_chat") { + return "[分享群聊: " + (content.chat_name || "") + "]"; + } + + if (msgType === "share_user") { + return "[分享联系人: " + (content.user_id || "") + "]"; + } + + if (msgType === "system") { + return "*[系统消息]*"; + } + + if (msgType === "merge_forward") { + return "[合并转发消息]"; + } + + if (msgType === "interactive") { + // 卡片消息 — 提取标题和内容 + var title = content.header?.title?.content || ""; + return title ? "[卡片: " + title + "]" : "[卡片消息]"; + } + + // 未知类型 fallback + return "[" + msgType + " 消息]"; + } + + /** + * 格式化时间戳为可读格式 + * @param {string|number} ts 毫秒时间戳 + */ + function formatFeishuTimestamp(ts) { + if (!ts) return ""; + var d = new Date(typeof ts === "string" ? parseInt(ts, 10) : ts); + if (isNaN(d.getTime())) return ""; + var pad = function (n) { return n < 10 ? "0" + n : String(n); }; + return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); + } + + /** + * 将飞书消息列表转为 Markdown 对话格式 + * @param {Array} messages - 飞书 IM API 消息数组 + * @param {Object} userMap - { open_id: display_name } 映射 + * @param {Object} options + * @returns {string} Markdown 格式的聊天记录 + */ + function convertFeishuChatToMarkdown(messages, userMap, options) { + options = options || {}; + if (!messages || messages.length === 0) return ""; + + var lines = []; + var lastDate = ""; + + for (var i = 0; i < messages.length; i++) { + var msg = messages[i]; + if (msg.deleted) continue; + + var senderId = msg.sender?.id || ""; + var senderName = (userMap && userMap[senderId]) || senderId || "未知"; + var time = formatFeishuTimestamp(msg.create_time); + var dateStr = time.slice(0, 10); + + // 按日期分组 + if (dateStr && dateStr !== lastDate) { + if (lines.length > 0) lines.push(""); + lines.push("## " + dateStr); + lines.push(""); + lastDate = dateStr; + } + + var timeStr = time.slice(11) || ""; // HH:MM:SS + var content = convertFeishuMsgContent(msg.msg_type || "", msg.body?.content || ""); + + // 处理 @mentions — 替换占位符 + if (msg.mentions && content) { + for (var m = 0; m < msg.mentions.length; m++) { + var mention = msg.mentions[m]; + if (mention.key && mention.name) { + content = content.replace(mention.key, "@" + mention.name); + } + } + } + + if (!content) continue; + + // 多行消息缩进处理 + var contentLines = content.split("\n"); + if (contentLines.length > 1) { + lines.push("**" + senderName + "** (" + timeStr + "):"); + for (var cl = 0; cl < contentLines.length; cl++) { + lines.push("> " + contentLines[cl]); + } + } else { + lines.push("**" + senderName + "** (" + timeStr + "): " + content); + } + } + + return lines.join("\n").trim(); + } + + /** + * 通过 DOM 提取飞书 messenger 页面当前可见的聊天记录(兜底策略) + * 飞书 messenger 是 React SPA,DOM 结构可能变化,这里用宽泛选择器做 best-effort 提取 + */ + function extractFeishuChatFromDOM(doc) { + doc = doc || globalScope.document; + + // 尝试获取聊天标题(对话名称/群名) + var chatTitle = ""; + var titleSelectors = [ + "[class*='chat-header'] [class*='name']", + "[class*='ChatHeader'] [class*='name']", + "[class*='header-title']", + "[class*='chat_name']", + "[class*='chatName']", + "[data-testid*='chat-name']", + "[data-testid*='header-title']", + ]; + for (var t = 0; t < titleSelectors.length; t++) { + var titleEl = doc.querySelector(titleSelectors[t]); + if (titleEl) { + var tt = cleanZeroWidth(getNodeText(titleEl)).trim(); + if (tt && tt.length >= 2 && tt.length < 100) { chatTitle = tt; break; } + } + } + if (!chatTitle) { + chatTitle = String(doc.title || "").replace(/\s*-\s*飞书.*$/, "").trim() || "飞书聊天记录"; + } + + // 尝试多种消息容器选择器 + var messageSelectors = [ + "[class*='message-list'] [class*='message-item']", + "[class*='MessageList'] [class*='MessageItem']", + "[class*='msg-list'] [class*='msg-item']", + "[class*='chat-message']", + "[data-testid*='message']", + "[class*='im-message']", + "[class*='message_content']", + "[class*='messageContent']", + ]; + + var messageElements = []; + for (var s = 0; s < messageSelectors.length; s++) { + var found = doc.querySelectorAll(messageSelectors[s]); + if (found && found.length > 0) { + messageElements = Array.from(found); + break; + } + } + + if (messageElements.length === 0) return null; + + // 从每个消息元素中提取信息 + var contentParts = []; + var senderSelectors = [ + "[class*='sender'], [class*='Sender'], [class*='name'], [class*='nickname']", + "[class*='avatar'] + span, [class*='avatar'] + div", + ]; + var timeSelectors = [ + "[class*='time'], [class*='Time'], [class*='timestamp'], time", + ]; + var textSelectors = [ + "[class*='content'], [class*='Content'], [class*='text'], [class*='Text']", + "[class*='rich-text'], [class*='richText']", + "p, span", + ]; + + for (var i = 0; i < messageElements.length; i++) { + var msgEl = messageElements[i]; + var sender = ""; + var time = ""; + var text = ""; + + // 发送人 + for (var si = 0; si < senderSelectors.length; si++) { + var senderEl = msgEl.querySelector(senderSelectors[si]); + if (senderEl) { + sender = cleanZeroWidth(getNodeText(senderEl)).trim(); + if (sender) break; + } + } + + // 时间 + for (var ti = 0; ti < timeSelectors.length; ti++) { + var timeEl = msgEl.querySelector(timeSelectors[ti]); + if (timeEl) { + time = cleanZeroWidth(getNodeText(timeEl)).trim(); + if (time) break; + } + } + + // 消息内容 + for (var xi = 0; xi < textSelectors.length; xi++) { + var textEl = msgEl.querySelector(textSelectors[xi]); + if (textEl) { + text = cleanZeroWidth(getNodeText(textEl)).trim(); + if (text && text.length > 0) break; + } + } + + if (!text) { + text = cleanZeroWidth(getNodeText(msgEl)).trim(); + } + + if (!text) continue; + + var line = ""; + if (sender && time) line = "**" + sender + "** (" + time + "): " + text; + else if (sender) line = "**" + sender + "**: " + text; + else if (time) line = "(" + time + ") " + text; + else line = text; + + contentParts.push(line); + } + + if (contentParts.length === 0) return null; + + return { + type: "article", + url: cleanFeishuUrl(globalScope.location?.href || ""), + author: "", + handle: "", + author_url: "", + published: "", + article_title: chatTitle, + article_content: contentParts.join("\n\n"), + images: [], + videos: [], + platform: "飞书聊天", + }; + } + + /** + * 通过注入页面脚本,调用飞书 messenger 内部 API 获取消息 + * 飞书网页版的 messenger 使用内部 API 加载消息,注入脚本共享 cookies 可以直接调用 + * @returns {Promise} 聊天数据 { chatName, messages: [...], userMap: {...} } + */ + function fetchFeishuChatViaInternalApi() { + return new Promise(function (resolve) { + var msgId = "__x2md_feishu_chat_" + Date.now() + "_" + Math.random().toString(36).slice(2); + var expectedOrigin = globalScope.location?.origin || ""; + + function onMessage(event) { + if (expectedOrigin && event.origin !== expectedOrigin) return; + if (event.data && event.data.type === msgId) { + globalScope.removeEventListener("message", onMessage); + clearTimeout(timer); + resolve(event.data.result || null); + } + } + globalScope.addEventListener("message", onMessage); + + var timer = setTimeout(function () { + globalScope.removeEventListener("message", onMessage); + resolve(null); + }, 15000); + + // 注入到页面上下文(共享 cookies 和 JS 全局对象) + var script = globalScope.document.createElement("script"); + script.textContent = `(function(){ + var msgId = ${JSON.stringify(msgId)}; + var origin = location.origin; + + function sendResult(data) { + window.postMessage({ type: msgId, result: data }, location.origin); + } + + async function run() { + try { + // 策略1: 尝试从飞书 messenger 全局状态中获取当前聊天ID + var chatId = null; + var chatName = ""; + + // 从 URL hash 或路径中提取 chat ID + var hashMatch = location.hash.match(/chat[_-]?id[=\\/]([a-zA-Z0-9_-]+)/i); + if (hashMatch) chatId = hashMatch[1]; + + // 尝试从 URL 路径提取(/messenger/oc_xxx 格式) + if (!chatId) { + var pathMatch = location.pathname.match(/\\/messenger\\/?(oc_[a-zA-Z0-9]+)/); + if (pathMatch) chatId = pathMatch[1]; + } + + // 尝试从 URL 参数提取 + if (!chatId) { + var params = new URLSearchParams(location.search); + chatId = params.get("chatId") || params.get("chat_id") || params.get("id") || ""; + } + + // 尝试从页面全局状态获取 + if (!chatId && window.__INITIAL_STATE__) { + var state = window.__INITIAL_STATE__; + chatId = state.chatId || state.chat_id || (state.chat && state.chat.id) || ""; + } + + // 尝试从 React fiber 获取 + if (!chatId) { + var appRoot = document.querySelector("#root, #app, [id*='messenger']"); + if (appRoot && appRoot._reactRootContainer) { + try { + var fiber = appRoot._reactRootContainer._internalRoot?.current; + // 遍历 fiber tree 找 chatId(简化版) + var queue = [fiber]; + for (var qi = 0; qi < queue.length && qi < 200; qi++) { + var f = queue[qi]; + if (!f) continue; + if (f.memoizedProps?.chatId) { chatId = f.memoizedProps.chatId; break; } + if (f.memoizedState?.chatId) { chatId = f.memoizedState.chatId; break; } + if (f.child) queue.push(f.child); + if (f.sibling) queue.push(f.sibling); + } + } catch(e) {} + } + } + + if (!chatId) { + sendResult({ error: "no_chat_id", chatId: null }); + return; + } + + // 获取聊天信息 + try { + var chatResp = await fetch(origin + "/messenger/api/v1/chat/info?chat_id=" + chatId, { + credentials: "include" + }); + if (chatResp.ok) { + var chatData = await chatResp.json(); + chatName = chatData?.data?.name || chatData?.data?.chat_name || ""; + } + } catch(e) {} + + // 获取消息列表(尝试多个可能的内部 API 路径) + var messages = null; + var userMap = {}; + var apiPaths = [ + "/messenger/api/v1/messages?chat_id=" + chatId + "&count=200", + "/messenger/api/v1/message/list?chat_id=" + chatId + "&page_size=200", + "/api/im/v1/messages?container_id=" + chatId + "&container_id_type=chat&page_size=50", + "/messenger/api/messages?chat_id=" + chatId + "&limit=200", + ]; + + for (var i = 0; i < apiPaths.length; i++) { + try { + var resp = await fetch(origin + apiPaths[i], { + credentials: "include", + headers: { "Accept": "application/json" } + }); + if (!resp.ok) continue; + var data = await resp.json(); + // 不同 API 返回格式可能不同 + var items = data?.data?.items || data?.data?.messages || data?.data?.list || data?.messages || []; + if (items.length > 0) { + messages = items; + break; + } + } catch(e) { continue; } + } + + // 尝试获取群成员名称映射 + if (chatId) { + try { + var memberResp = await fetch(origin + "/messenger/api/v1/chat/members?chat_id=" + chatId + "&page_size=100", { + credentials: "include" + }); + if (memberResp.ok) { + var memberData = await memberResp.json(); + var members = memberData?.data?.items || memberData?.data?.members || []; + for (var m = 0; m < members.length; m++) { + var member = members[m]; + var id = member.open_id || member.user_id || member.id || ""; + var name = member.name || member.display_name || member.nickname || ""; + if (id && name) userMap[id] = name; + } + } + } catch(e) {} + } + + sendResult({ + chatId: chatId, + chatName: chatName, + messages: messages, + userMap: userMap, + messageCount: messages ? messages.length : 0, + }); + } catch(e) { + console.error("[x2md] Feishu chat fetch failed:", e); + sendResult({ error: e.message }); + } + } + run(); + })();`; + (globalScope.document.head || globalScope.document.documentElement).appendChild(script); + script.remove(); + }); + } + const exported = { cleanFeishuUrl, convertFeishuApiDataToMarkdown, + convertFeishuChatToMarkdown, + convertFeishuMsgContent, detectFeishuPageType, extractFeishuAuthor, extractFeishuBlockMarkdown, + extractFeishuChatFromDOM, extractFeishuDocToken, extractFeishuDocumentData, extractFeishuInlineMarkdown, @@ -845,8 +1307,10 @@ extractFeishuMinutesData, extractFeishuTitle, extractFeishuUpdated, + fetchFeishuChatViaInternalApi, fetchFeishuDocViaApi, findFeishuScrollContainer, + formatFeishuTimestamp, isFeishuContentPage, isFeishuWikiOrDocxPage, neutralizeFeishuCopyProtection, diff --git a/extension/manifest.json b/extension/manifest.json index cf7db35..d052469 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "X2MD - 网页内容转Markdown", - "version": "1.5.2", - "description": "支持 X、LINUX DO、飞书知识库、飞书云文档、微信公众号的一键保存 Markdown,多目标保存(Obsidian/飞书多维表格/Notion/HTML),支持评论提取、iframe嵌入、Discourse多域名", + "version": "1.6.0", + "description": "支持 X、LINUX DO、飞书知识库、飞书云文档、飞书聊天记录、微信公众号的一键保存 Markdown,多目标保存(Obsidian/飞书多维表格/Notion/HTML),支持评论提取、iframe嵌入、Discourse多域名", "permissions": [ "storage", "notifications", @@ -21,7 +21,9 @@ "https://*.feishu.cn/minutes/*", "https://*.feishu.cn/sheets/*", "https://*.feishu.cn/mindnotes/*", + "https://*.feishu.cn/messenger/*", "https://*.larksuite.com/wiki/*", + "https://*.larksuite.com/messenger/*", "https://*.larksuite.com/docx/*", "https://*.larksuite.com/minutes/*", "https://mp.weixin.qq.com/*", @@ -51,7 +53,9 @@ "https://*.feishu.cn/minutes/*", "https://*.feishu.cn/sheets/*", "https://*.feishu.cn/mindnotes/*", + "https://*.feishu.cn/messenger/*", "https://*.larksuite.com/wiki/*", + "https://*.larksuite.com/messenger/*", "https://*.larksuite.com/docx/*", "https://*.larksuite.com/minutes/*", "https://mp.weixin.qq.com/*" diff --git a/extension/options.html b/extension/options.html index 7c47ada..c17bbb7 100644 --- a/extension/options.html +++ b/extension/options.html @@ -587,6 +587,10 @@

Notion Database 设置

+
+
+
+
diff --git a/extension/options.js b/extension/options.js index 7492d7f..4ad6d1d 100644 --- a/extension/options.js +++ b/extension/options.js @@ -180,6 +180,7 @@ function applyConfigToUI(cfg) { document.getElementById("notionPropTags").value = cfg.notion_prop_tags || "标签"; document.getElementById("notionPropSavedDate").value = cfg.notion_prop_saved_date || "保存日期"; document.getElementById("notionPropType").value = cfg.notion_prop_type || "类型"; + document.getElementById("notionPropCommentCount").value = cfg.notion_prop_comment_count || "评论数"; // HTML 导出 document.getElementById("htmlExportFolder").value = cfg.html_export_folder || "X2MD导出"; @@ -482,7 +483,17 @@ function testNotionConnection() { chrome.runtime.sendMessage({ action: "test_notion", - data: { notion_token: token, notion_database_id: dbId } + data: { + notion_token: token, + notion_database_id: dbId, + notion_prop_title: document.getElementById("notionPropTitle").value.trim() || "标题", + notion_prop_url: document.getElementById("notionPropUrl").value.trim() || "链接", + notion_prop_author: document.getElementById("notionPropAuthor").value.trim() || "作者", + notion_prop_tags: document.getElementById("notionPropTags").value.trim() || "标签", + notion_prop_saved_date: document.getElementById("notionPropSavedDate").value.trim() || "保存日期", + notion_prop_type: document.getElementById("notionPropType").value.trim() || "类型", + notion_prop_comment_count: document.getElementById("notionPropCommentCount").value.trim() || "评论数", + } }, (resp) => { if (resp && resp.success) { let msg = `连接成功!数据库: ${resp.databaseTitle || dbId}(${resp.propertyCount || 0} 个属性)`; @@ -574,6 +585,7 @@ function saveConfig() { notion_prop_tags: document.getElementById("notionPropTags").value.trim() || "标签", notion_prop_saved_date: document.getElementById("notionPropSavedDate").value.trim() || "保存日期", notion_prop_type: document.getElementById("notionPropType").value.trim() || "类型", + notion_prop_comment_count: document.getElementById("notionPropCommentCount").value.trim() || "评论数", // HTML html_export_folder: document.getElementById("htmlExportFolder").value.trim() || "X2MD导出", // 媒体 diff --git a/extension/site_actions.js b/extension/site_actions.js index f0905a6..0512e35 100644 --- a/extension/site_actions.js +++ b/extension/site_actions.js @@ -29,6 +29,11 @@ return "feishu"; } + if ((hostname.endsWith(".feishu.cn") || hostname.endsWith(".larksuite.com")) && + /^\/messenger\b/.test(pathname)) { + return "feishu_chat"; + } + if (hostname === "mp.weixin.qq.com" && /^\/s(\/|$|\?)/.test(pathname)) { return "wechat"; } @@ -64,6 +69,15 @@ }; } + if (siteKey === "feishu_chat") { + return { + label: "MD", + title: "保存当前飞书聊天记录为 Markdown", + background: "#1677ff", + shadow: "rgba(22, 119, 255, 0.35)", + }; + } + if (siteKey === "wechat") { return { label: "MD", From 58c2e3817decb8af7315cf7c402796aa66a73b5a Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 15:11:11 +0000 Subject: [PATCH 46/48] docs: update README, spec, build config for v1.6.0 - README: add feishu chat, Discourse multi-domain to platform table - README: add v1.6.0 changelog entry, update download links - x2md.spec: bump CFBundleShortVersionString to 1.6.0 - build.yml: bump default version to 1.6.0, update description - server.py: bump /ping version to 1.6.0 - manifest.json: update description text Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 6 +++--- README.md | 38 +++++++++++++++++++++++++------------ extension/manifest.json | 2 +- server.py | 2 +- x2md.spec | 2 +- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7d7c4b2..7187ef8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ on: version: description: '版本号(如 1.0.0)' required: false - default: '1.5.2' + default: '1.6.0' # 推送 tag 时自动触发(如 git tag v1.0.0 && git push --tags) push: tags: @@ -133,7 +133,7 @@ jobs: body: | ## X2MD ${{ github.ref_name }} - 网页内容一键转 Markdown — 支持 X/Twitter、LINUX DO、飞书、微信公众号,多目标保存到 Obsidian / 飞书多维表格 / Notion / HTML。 + 网页内容一键转 Markdown — 支持 X/Twitter、LINUX DO、Discourse、飞书(文档/聊天记录)、微信公众号,多目标保存到 Obsidian / 飞书多维表格 / Notion / HTML。 ### 下载 - **Mac 版**:`X2MD_Mac.zip` — 解压后双击 `X2MD.app` 即可运行 @@ -143,7 +143,7 @@ jobs: ### 首次使用 1. 打开应用,按向导设置保存路径 2. 解压 `X2MD_Extension.zip` 并在 `chrome://extensions/` 中加载已解压的扩展程序 - 3. 在支持的网站(X/Twitter、LINUX DO、飞书、微信公众号)使用保存按钮保存内容 + 3. 在支持的网站(X/Twitter、LINUX DO、Discourse、飞书文档/聊天记录、微信公众号)使用保存按钮保存内容 4. 打开扩展选项页面可配置多目标保存、评论提取等高级功能 ### 升级用户须知 diff --git a/README.md b/README.md index cf106b1..68c1e09 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # X2MD -把 X/Twitter、LINUX DO、飞书、微信公众号内容一键保存为 Obsidian 可用的 Markdown。 +把 X/Twitter、LINUX DO、飞书(知识库/云文档/聊天记录)、微信公众号内容一键保存为 Obsidian 可用的 Markdown。 [![GitHub Repo](https://img.shields.io/badge/GitHub-izscc%2Fx2md-181717?logo=github)](https://github.com/izscc/x2md) [![Latest Release](https://img.shields.io/github/v/release/izscc/x2md)](https://github.com/izscc/x2md/releases/latest) @@ -19,6 +19,7 @@ - 你需要把 Thread / Note 长文转成可编辑 Markdown,用于二次创作。 - 你在 LINUX DO 论坛中看到值得收藏的帖子。 - 你在飞书知识库阅读团队文档,想要本地留档。 +- 你想把飞书网页版的聊天记录导出为 Markdown 存档。 - 你在微信公众号看到好文章,想保存完整 Markdown 副本。 ## 支持平台 @@ -27,7 +28,9 @@ |------|----------|------| | X / Twitter | 点击书签按钮 | 支持 Tweet、Thread、Note/Article、图片和视频 | | LINUX DO | 点赞 / 悬浮保存按钮 | 支持话题帖子内容 | -| 飞书 | 悬浮保存按钮 | 支持 wiki 知识库和 docx 云文档(自动滚动收集完整内容) | +| Discourse(自定义域名) | 悬浮保存按钮 | 支持任意 Discourse 论坛(在选项页面配置域名) | +| 飞书文档 | 悬浮保存按钮 | 支持 wiki/docx/docs/sheets/mindnotes/minutes(自动滚动收集完整内容) | +| 飞书聊天记录 | 悬浮保存按钮 | 支持网页版 messenger 聊天记录导出(内部 API + DOM 双策略) | | 微信公众号 | 悬浮保存按钮 | 支持公众号文章(含图片、代码块、引用等) | ## 3 分钟快速上手(推荐) @@ -35,11 +38,11 @@ ### 第 1 步:下载客户端(基于 GitHub Release) - 打开 [Releases](https://github.com/izscc/x2md/releases/latest) -- 当前最新版本:[`v1.5.2`](https://github.com/izscc/x2md/releases/tag/v1.5.2) +- 当前最新版本:[`v1.6.0`](https://github.com/izscc/x2md/releases/tag/v1.6.0) - 下载对应平台包: - - Mac: [`X2MD_Mac.zip`](https://github.com/izscc/x2md/releases/download/v1.5.2/X2MD_Mac.zip) - - Windows: [`X2MD_Windows.zip`](https://github.com/izscc/x2md/releases/download/v1.5.2/X2MD_Windows.zip) - - 扩展: [`X2MD_Extension.zip`](https://github.com/izscc/x2md/releases/download/v1.5.2/X2MD_Extension.zip) + - Mac: [`X2MD_Mac.zip`](https://github.com/izscc/x2md/releases/download/v1.6.0/X2MD_Mac.zip) + - Windows: [`X2MD_Windows.zip`](https://github.com/izscc/x2md/releases/download/v1.6.0/X2MD_Windows.zip) + - 扩展: [`X2MD_Extension.zip`](https://github.com/izscc/x2md/releases/download/v1.6.0/X2MD_Extension.zip) ### 第 2 步:首次运行并完成向导 @@ -60,10 +63,11 @@ 1. **X / Twitter**:打开任意推文页,点击推文操作区的书签按钮。 2. **LINUX DO**:打开话题页,点赞帖子或点击右上角悬浮 MD 按钮。 -3. **飞书**:打开 wiki 或 docx 页面,点击右上角悬浮 MD 按钮。 -4. **微信公众号**:打开公众号文章页,点击右上角悬浮 MD 按钮。 -5. 扩展会将内容发送到本地服务并生成 Markdown。 -6. 到你设置的目录查看 `.md` 文件。 +3. **飞书文档**:打开 wiki/docx/docs/sheets/mindnotes/minutes 页面,点击右上角悬浮 MD 按钮。 +4. **飞书聊天记录**:打开飞书网页版 messenger 页面,点击右上角悬浮 MD 按钮。 +5. **微信公众号**:打开公众号文章页,点击右上角悬浮 MD 按钮。 +6. 扩展会将内容发送到本地服务并生成 Markdown。 +7. 到你设置的目录查看 `.md` 文件。 ### 第 5 步:在 Obsidian 中查看结果 @@ -83,7 +87,7 @@ - `enable_video_download`: 是否下载视频 - `video_duration_threshold`: 超长视频二次确认阈值(分钟) - `show_site_save_icon`: 是否在支持的站点显示悬浮保存按钮 -- `enable_platform_folders`: 是否按平台分子文件夹(Twitter/、LINUX DO/、飞书/、微信公众号/) +- `enable_platform_folders`: 是否按平台分子文件夹(Twitter/、LINUX DO/、Discourse/、飞书/、飞书聊天/、微信公众号/) - `download_images`: 是否下载远程图片到本地(存入 `assets/` 子目录) - `overwrite_existing`: 是否覆盖已存在的同名文件(基于 Front Matter `源:` URL 去重) @@ -183,7 +187,7 @@ CI 工作流见 [`.github/workflows/build.yml`](./.github/workflows/build.yml) │ ├── dom_utils.js # 共享 DOM 工具函数 │ ├── article_markdown.js # X Article 富文本转 Markdown │ ├── discourse.js # LINUX DO 话题帖子提取 -│ ├── feishu.js # 飞书 wiki/docx block 解析 +│ ├── feishu.js # 飞书 wiki/docx/messenger 提取与解析 │ ├── wechat.js # 微信公众号文章提取 │ ├── site_actions.js # 站点识别与悬浮按钮配置 │ ├── content.js # 内容脚本主入口 @@ -195,6 +199,16 @@ CI 工作流见 [`.github/workflows/build.yml`](./.github/workflows/build.yml) ## 更新日志 +### v1.6.0(2026-03-29) + +- 新增飞书网页版聊天记录提取(messenger 页面),支持内部 API 注入 + DOM 双策略自动降级 +- 新增 Discourse 多域名悬浮保存按钮支持(自定义域名在选项页面配置后,悬浮按钮自动生效) +- 修复自定义 Discourse 域名点击悬浮按钮无反应的问题(`handleFloatingSave` 缺少 discourse case) +- 修复自定义 Discourse 域名列表未同步到 `site_actions.js` 导致悬浮按钮不显示的问题 +- 新增 `sendToBackground` 诊断日志,便于排查数据发送问题 +- 扩展飞书 URL 匹配:新增 sheets、mindnotes 路径支持 +- 统一所有组件版本号到 1.6.0 + ### v1.5.2(2026-03-29) - 修复版本升级后设置向导不弹出、扩展文件不更新的问题 diff --git a/extension/manifest.json b/extension/manifest.json index d052469..548b329 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "X2MD - 网页内容转Markdown", "version": "1.6.0", - "description": "支持 X、LINUX DO、飞书知识库、飞书云文档、飞书聊天记录、微信公众号的一键保存 Markdown,多目标保存(Obsidian/飞书多维表格/Notion/HTML),支持评论提取、iframe嵌入、Discourse多域名", + "description": "支持 X/Twitter、LINUX DO、Discourse多域名、飞书(知识库/云文档/聊天记录)、微信公众号的一键保存 Markdown,多目标保存(Obsidian/飞书多维表格/Notion/HTML),支持评论提取、iframe嵌入", "permissions": [ "storage", "notifications", diff --git a/server.py b/server.py index 6698d4d..c0284d5 100644 --- a/server.py +++ b/server.py @@ -689,7 +689,7 @@ def do_GET(self): if path == "/ping": # 心跳检测 - self._respond(200, {"status": "ok", "version": "1.5.2"}) + self._respond(200, {"status": "ok", "version": "1.6.0"}) elif path == "/config": # 返回当前配置 diff --git a/x2md.spec b/x2md.spec index 0416c28..71561b4 100644 --- a/x2md.spec +++ b/x2md.spec @@ -110,7 +110,7 @@ if sys.platform == 'darwin': info_plist={ 'CFBundleName': 'X2MD', 'CFBundleDisplayName': 'X2MD', - 'CFBundleShortVersionString': '1.5.2', + 'CFBundleShortVersionString': '1.6.0', 'LSUIElement': True, # 无 Dock 图标,仅菜单栏显示 }, ) From 80ec47d3c8404bd7c7144cec6bf6d9c5db54c263 Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Sun, 29 Mar 2026 15:43:09 +0000 Subject: [PATCH 47/48] =?UTF-8?q?feat:=20=E5=85=A8=E9=9D=A2=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=20+=20EXE?= =?UTF-8?q?=E8=AF=A6=E7=BB=86=E6=97=A5=E5=BF=97=20+=20=E5=B4=A9=E6=BA=83?= =?UTF-8?q?=E9=98=B2=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension修复: - X2MD命名空间统一(media_helpers/twitter_graphql/article_markdown/wechat) - 飞书chatId提取增强+诊断日志 - background.js safeSendResponse防重复响应 - content.js WeakSet/WeakMap防重复注入+聊天阈值 - dom_utils.js GFM表格colspan/rowspan支持 - site_actions.js Discourse域名委托给discourse.js - discourse.js分页获取所有回复(fetchDiscourseReplies) Python EXE修复: - tray_app.py: 崩溃弹窗+crash.log双兜底, DEBUG级日志, 4阶段启动诊断 - server.py: 请求/配置/保存全链路DEBUG日志 - setup_wizard.py: 向导步骤/完成/异常全程日志 Co-Authored-By: Claude Opus 4.6 --- extension/article_markdown.js | 1 + extension/background.js | 26 ++-- extension/content.js | 63 +++++---- extension/discourse.js | 41 +++++- extension/dom_utils.js | 55 +++++--- extension/feishu.js | 81 +++++++++--- extension/media_helpers.js | 1 + extension/site_actions.js | 16 ++- extension/twitter_graphql.js | 1 + extension/wechat.js | 1 + server.py | 64 ++++++--- setup_wizard.py | 61 +++++++-- tray_app.py | 241 +++++++++++++++++++++++++++++----- 13 files changed, 516 insertions(+), 136 deletions(-) diff --git a/extension/article_markdown.js b/extension/article_markdown.js index 61a971a..8165d98 100644 --- a/extension/article_markdown.js +++ b/extension/article_markdown.js @@ -235,5 +235,6 @@ module.exports = exported; } + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/extension/background.js b/extension/background.js index fbda033..df6ef65 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1622,11 +1622,20 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "save_tweet") { (async () => { const serverBase = SERVER_BASE; // 快照,防止并发修改 - // 整体超时保护:防止 MV3 service worker 被杀后 callback 永远不返回 - const OVERALL_TIMEOUT = 60000; // 60 秒整体超时 + // 分阶段超时保护:MV3 service worker 可能在空闲30s后被杀 + // 各阶段有独立超时(fetchFullTweetData/fetchNoteContent/dispatchMultiTargetSave 内部已有) + // 这里的总超时作为兜底,防止 callback 永远不返回 + const OVERALL_TIMEOUT = 90000; // 90秒总兜底(各子阶段内部超时更短) + let _responded = false; + function safeSendResponse(data) { + if (_responded) return; + _responded = true; + clearTimeout(timeoutId); + sendResponse(data); + } const timeoutId = setTimeout(() => { - console.error("[x2md] save_tweet 整体超时(60s),强制返回错误"); - sendResponse({ success: false, error: "操作超时,请检查网络连接和本地服务是否正常运行" }); + console.error("[x2md] save_tweet 整体超时(90s),强制返回错误"); + safeSendResponse({ success: false, error: "操作超时,请检查网络连接和本地服务是否正常运行" }); }, OVERALL_TIMEOUT); try { let data = message.data; @@ -1784,8 +1793,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (maxDurationMin > durationThresholdMin) { debugLog(`发现超长视频 (${maxDurationMin.toFixed(1)} > ${durationThresholdMin} min),要求前台确认`); - clearTimeout(timeoutId); - sendResponse({ + safeSendResponse({ require_video_confirm: true, durationMin: maxDurationMin.toFixed(1), payload: data @@ -1815,13 +1823,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // ── 多目标保存分发(复用共享函数,传入已获取的 cfg 避免重复请求)── const dispatchResult = await dispatchMultiTargetSave(data, serverBase, cfg); - clearTimeout(timeoutId); - sendResponse(dispatchResult); + safeSendResponse(dispatchResult); } catch (err) { - clearTimeout(timeoutId); console.error("[x2md] 后台处理或请求失败:", err); - sendResponse({ success: false, error: err.message || String(err) }); + safeSendResponse({ success: false, error: err.message || String(err) }); } })(); return true; diff --git a/extension/content.js b/extension/content.js index 79dc637..80d3c1a 100644 --- a/extension/content.js +++ b/extension/content.js @@ -766,7 +766,10 @@ function captureFeishuChat() { const chatName = chatData.chatName || "飞书聊天记录"; const markdown = convertFeishuChatToMarkdown(chatData.messages, chatData.userMap || {}); - if (markdown && markdown.length >= 20) { + // 检测是否有实际消息内容(包含"**作者**"格式或至少有非空内容行) + const hasRealMessages = markdown && markdown.trim().length > 0 + && (/\*\*[^*]+\*\*/.test(markdown) || markdown.split("\n").filter(l => l.trim()).length >= 2); + if (hasRealMessages) { showToast("正在保存聊天记录(" + chatData.messageCount + " 条消息)…", "loading", null); sendToBackground({ type: "article", @@ -797,7 +800,10 @@ function captureFeishuChat() { // 策略2(兜底):DOM 直接提取当前可见消息 showToast("尝试从页面提取聊天记录…", "loading", null); const domData = extractFeishuChatFromDOM(document); - if (domData && domData.article_content && domData.article_content.length >= 20) { + const domContent = domData?.article_content?.trim() || ""; + const domHasMessages = domContent.length > 0 + && (/\*\*[^*]+\*\*/.test(domContent) || domContent.split("\n").filter(l => l.trim()).length >= 2); + if (domData && domHasMessages) { debugLog(" Feishu Chat(DOM 提取)"); showToast("正在保存聊天记录…", "loading", null); sendToBackground(domData); @@ -808,7 +814,10 @@ function captureFeishuChat() { console.error("[x2md] Feishu Chat 提取出错:", err); // 最后兜底尝试 DOM const domData = extractFeishuChatFromDOM(document); - if (domData && domData.article_content && domData.article_content.length >= 20) { + const domContent = domData?.article_content?.trim() || ""; + const domHasMessages = domContent.length > 0 + && (/\*\*[^*]+\*\*/.test(domContent) || domContent.split("\n").filter(l => l.trim()).length >= 2); + if (domData && domHasMessages) { showToast("正在保存聊天记录…", "loading", null); sendToBackground(domData); } else { @@ -1198,20 +1207,23 @@ function ensureFloatingCopyButton() { } let _copyInProgress = false; +const COPY_LOCK_TIMEOUT = 30000; // 30秒超时自动释放锁,防止异常导致永久锁死 +let _copyLockTimer = null; async function handleCopyFullContent() { if (_copyInProgress) return; // 防止重复点击 _copyInProgress = true; + clearTimeout(_copyLockTimer); + _copyLockTimer = setTimeout(() => { _copyInProgress = false; }, COPY_LOCK_TIMEOUT); + try { const pageType = detectFeishuPageType(location); - if (!pageType) { _copyInProgress = false; return; } + if (!pageType) return; // 解除复制保护 neutralizeFeishuCopyProtection(document); showToast("正在提取全部内容…", "loading", null); - - try { let markdown = ""; const pageUrl = location.href; const options = { pageUrl }; @@ -1268,7 +1280,6 @@ async function handleCopyFullContent() { if (!markdown || markdown.length < 20) { showToast("内容提取失败,请稍后重试", "error", 4000); - _copyInProgress = false; return; } @@ -1288,6 +1299,7 @@ async function handleCopyFullContent() { showToast("复制失败:" + (err.message || "未知错误"), "error", 4000); } finally { _copyInProgress = false; + clearTimeout(_copyLockTimer); } } @@ -1379,19 +1391,18 @@ function bindAllDebounced() { } // Discourse 点赞按钮:单击 = 保存到 OB(拦截点赞),双击 = 正常点赞(不保存) -let _likeClickCount = 0; -let _likeClickTimer = null; -let _likeClickBtn = null; -let _likePassThrough = false; // 标记:放行重放的点击,防止循环拦截 +// 使用 per-post 追踪代替全局标志,避免多帖子并发操作时标志竞态 +const _likePassThroughSet = new WeakSet(); // 记录正在放行的按钮 +const _likePendingClicks = new WeakMap(); // btn → { count, timer } document.addEventListener("click", (event) => { if (!isDiscourseTopicPage()) return; const btn = event.target?.closest?.(LINUX_DO_LIKE_SELECTOR); if (!btn) return; - // 放行标记:双击重放的点击不拦截 - if (_likePassThrough) { - _likePassThrough = false; + // 放行标记:双击重放的点击不拦截(per-button) + if (_likePassThroughSet.has(btn)) { + _likePassThroughSet.delete(btn); return; } @@ -1399,23 +1410,25 @@ document.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); - _likeClickCount++; - _likeClickBtn = btn; + let pending = _likePendingClicks.get(btn); + if (!pending) { + pending = { count: 0, timer: null }; + _likePendingClicks.set(btn, pending); + } + pending.count++; - clearTimeout(_likeClickTimer); - _likeClickTimer = setTimeout(() => { - const clicks = _likeClickCount; - const targetBtn = _likeClickBtn; - _likeClickCount = 0; - _likeClickBtn = null; + clearTimeout(pending.timer); + pending.timer = setTimeout(() => { + const clicks = pending.count; + _likePendingClicks.delete(btn); if (clicks >= 2) { // 双击:恢复点赞,不保存 - _likePassThrough = true; - targetBtn.click(); + _likePassThroughSet.add(btn); + btn.click(); } else { // 单击:保存到 OB,不点赞 - captureLinuxDoPost(targetBtn); + captureLinuxDoPost(btn); } }, 300); }, true); diff --git a/extension/discourse.js b/extension/discourse.js index 465faaf..4ac0401 100644 --- a/extension/discourse.js +++ b/extension/discourse.js @@ -356,17 +356,47 @@ async function fetchDiscourseReplies(topicId, hostname) { const host = hostname || globalScope.location?.hostname || "linux.do"; - const resp = await fetch(`https://${host}/t/${topicId}.json`, { + const baseUrl = `https://${host}`; + const resp = await fetch(`${baseUrl}/t/${topicId}.json`, { signal: AbortSignal.timeout(15000), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); - const posts = data.post_stream?.posts || []; - // posts[0] 是主帖,posts[1:] 是回复 - const replies = posts.slice(1).map(p => ({ + + // 第一批帖子(topic JSON 默认返回约20条) + const firstPosts = data.post_stream?.posts || []; + const allPostIds = data.post_stream?.stream || []; + const loadedIds = new Set(firstPosts.map(p => p.id)); + + // 分页获取剩余帖子(每批最多20个 post_id) + const missingIds = allPostIds.filter(id => !loadedIds.has(id)); + const PAGE_SIZE = 20; + const extraPosts = []; + for (let i = 0; i < missingIds.length; i += PAGE_SIZE) { + const batch = missingIds.slice(i, i + PAGE_SIZE); + const params = batch.map(id => `post_ids[]=${id}`).join("&"); + try { + const batchResp = await fetch(`${baseUrl}/t/${topicId}/posts.json?${params}`, { + signal: AbortSignal.timeout(10000), + }); + if (batchResp.ok) { + const batchData = await batchResp.json(); + const batchPosts = batchData.post_stream?.posts || batchData.posts || []; + extraPosts.push(...batchPosts); + } + } catch (e) { + console.warn(`[x2md] Discourse 分页获取失败 (batch ${i / PAGE_SIZE + 1}):`, e.message); + break; // 网络问题时停止继续获取,返回已有数据 + } + } + + const allPosts = [...firstPosts, ...extraPosts]; + // posts[0] 是主帖,posts[1:] 是回复(按 post_number 排序) + allPosts.sort((a, b) => (a.post_number || 0) - (b.post_number || 0)); + const replies = allPosts.slice(1).map(p => ({ floor: p.post_number, author: p.username || "匿名", - content: cookedHtmlToMarkdown(p.cooked || "", `https://${host}/t/${topicId}`), + content: cookedHtmlToMarkdown(p.cooked || "", `${baseUrl}/t/${topicId}`), published: p.created_at || "", likes: p.like_count || 0, reply_to: p.reply_to_post_number || null, @@ -403,5 +433,6 @@ module.exports = exported; } + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/extension/dom_utils.js b/extension/dom_utils.js index 3d5ba19..0d8baa3 100644 --- a/extension/dom_utils.js +++ b/extension/dom_utils.js @@ -58,24 +58,47 @@ * @returns {string} GFM markdown table 字符串 */ function convertTableToGfm(tableNode, cellConverter, options) { - const rows = []; - for (const tr of tableNode.querySelectorAll?.("tr") || []) { - const cells = []; - for (const cell of tr.querySelectorAll?.("td, th") || []) { - // 转换单元格内容,去除换行并转义管道符 - cells.push( - cellConverter(cell, options) - .replace(/\n/g, " ") - .replace(/\|/g, "\\|") - .trim() - ); + // 处理 colspan/rowspan:GFM 不支持合并单元格,展开为重复列/行 + const trList = tableNode.querySelectorAll?.("tr") || []; + // 先构建一个二维网格,处理 rowspan 占位 + const grid = []; + const rowspanTracker = []; // rowspanTracker[col] = { text, remaining } + for (const tr of trList) { + const row = []; + let colIdx = 0; + const cellList = tr.querySelectorAll?.("td, th") || []; + let cellPos = 0; + while (cellPos < cellList.length || (rowspanTracker[colIdx] && rowspanTracker[colIdx].remaining > 0)) { + // 先填充 rowspan 占位 + if (rowspanTracker[colIdx] && rowspanTracker[colIdx].remaining > 0) { + row.push(rowspanTracker[colIdx].text); + rowspanTracker[colIdx].remaining--; + colIdx++; + continue; + } + if (cellPos >= cellList.length) break; + const cell = cellList[cellPos]; + const text = cellConverter(cell, options) + .replace(/\n/g, " ") + .replace(/\|/g, "\\|") + .trim(); + const colspan = Math.max(1, parseInt(cell.getAttribute("colspan")) || 1); + const rowspan = Math.max(1, parseInt(cell.getAttribute("rowspan")) || 1); + for (let c = 0; c < colspan; c++) { + row.push(text); + if (rowspan > 1) { + rowspanTracker[colIdx] = { text, remaining: rowspan - 1 }; + } + colIdx++; + } + cellPos++; } - if (cells.length) rows.push(cells); + if (row.length) grid.push(row); } - if (!rows.length) return ""; - const colCount = Math.max(...rows.map(r => r.length)); + if (!grid.length) return ""; + const colCount = Math.max(...grid.map(r => r.length)); const lines = []; - rows.forEach((row, i) => { + grid.forEach((row, i) => { while (row.length < colCount) row.push(""); lines.push("| " + row.join(" | ") + " |"); if (i === 0) lines.push("| " + Array(colCount).fill("---").join(" | ") + " |"); @@ -138,5 +161,7 @@ module.exports = exported; } + // 命名空间挂载,避免全局污染;同时保持向后兼容(直接访问函数名) + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/extension/feishu.js b/extension/feishu.js index 2604025..808a2dd 100644 --- a/extension/feishu.js +++ b/extension/feishu.js @@ -1022,6 +1022,7 @@ } if (!chatTitle) { chatTitle = String(doc.title || "").replace(/\s*-\s*飞书.*$/, "").trim() || "飞书聊天记录"; + console.debug("[x2md] Feishu Chat: 所有标题选择器均未命中,使用 document.title 兜底:" + chatTitle); } // 尝试多种消息容器选择器 @@ -1045,7 +1046,16 @@ } } - if (messageElements.length === 0) return null; + if (messageElements.length === 0) { + // 所有消息选择器均未命中,记录诊断信息帮助排查飞书 DOM 变更 + console.warn("[x2md] Feishu Chat DOM 提取失败:所有消息选择器均未命中。" + + "飞书可能已更新 DOM 结构。页面包含的 class 属性样本:", + Array.from(doc.querySelectorAll("[class]")).slice(0, 30).map( + function(el) { return el.tagName.toLowerCase() + "." + el.className.split(/\s+/).slice(0, 3).join("."); } + ) + ); + return null; + } // 从每个消息元素中提取信息 var contentParts = []; @@ -1110,7 +1120,11 @@ contentParts.push(line); } - if (contentParts.length === 0) return null; + if (contentParts.length === 0) { + console.warn("[x2md] Feishu Chat DOM 提取:找到 " + messageElements.length + + " 个消息元素,但未能从中提取到有效文本内容。内容选择器可能需要更新。"); + return null; + } return { type: "article", @@ -1184,27 +1198,57 @@ chatId = params.get("chatId") || params.get("chat_id") || params.get("id") || ""; } - // 尝试从页面全局状态获取 - if (!chatId && window.__INITIAL_STATE__) { - var state = window.__INITIAL_STATE__; - chatId = state.chatId || state.chat_id || (state.chat && state.chat.id) || ""; + // 尝试从页面全局状态获取(多种可能的全局变量名) + if (!chatId) { + var stateObjects = [ + window.__INITIAL_STATE__, + window.__STORE__?.getState?.(), + window.__NEXT_DATA__?.props?.pageProps, + ].filter(Boolean); + for (var si = 0; si < stateObjects.length && !chatId; si++) { + var state = stateObjects[si]; + chatId = state.chatId || state.chat_id + || (state.chat && (state.chat.id || state.chat.chatId)) + || (state.im && (state.im.chatId || state.im.currentChatId)) + || ""; + } + } + + // 尝试从 DOM 数据属性提取 + if (!chatId) { + var chatEl = document.querySelector("[data-chat-id], [data-chatid], [data-container-id]"); + if (chatEl) { + chatId = chatEl.getAttribute("data-chat-id") + || chatEl.getAttribute("data-chatid") + || chatEl.getAttribute("data-container-id") + || ""; + } } - // 尝试从 React fiber 获取 + // 尝试从 React fiber 获取(兼容 React 16/17/18) if (!chatId) { var appRoot = document.querySelector("#root, #app, [id*='messenger']"); - if (appRoot && appRoot._reactRootContainer) { + if (appRoot) { try { - var fiber = appRoot._reactRootContainer._internalRoot?.current; - // 遍历 fiber tree 找 chatId(简化版) - var queue = [fiber]; - for (var qi = 0; qi < queue.length && qi < 200; qi++) { - var f = queue[qi]; - if (!f) continue; - if (f.memoizedProps?.chatId) { chatId = f.memoizedProps.chatId; break; } - if (f.memoizedState?.chatId) { chatId = f.memoizedState.chatId; break; } - if (f.child) queue.push(f.child); - if (f.sibling) queue.push(f.sibling); + // React 16/17: _reactRootContainer; React 18: __reactFiber$xxx + var fiber = null; + if (appRoot._reactRootContainer) { + fiber = appRoot._reactRootContainer._internalRoot?.current; + } else { + var fiberKey = Object.keys(appRoot).find(function(k) { return k.startsWith("__reactFiber$"); }); + if (fiberKey) fiber = appRoot[fiberKey]; + } + if (fiber) { + var queue = [fiber]; + for (var qi = 0; qi < queue.length && qi < 300; qi++) { + var f = queue[qi]; + if (!f) continue; + var fChatId = f.memoizedProps?.chatId || f.memoizedProps?.chat_id + || f.memoizedState?.chatId || f.memoizedState?.chat_id; + if (fChatId) { chatId = fChatId; break; } + if (f.child) queue.push(f.child); + if (f.sibling) queue.push(f.sibling); + } } } catch(e) {} } @@ -1320,5 +1364,6 @@ module.exports = exported; } + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/extension/media_helpers.js b/extension/media_helpers.js index 5224624..6598e83 100644 --- a/extension/media_helpers.js +++ b/extension/media_helpers.js @@ -97,5 +97,6 @@ module.exports = exported; } + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/extension/site_actions.js b/extension/site_actions.js index 0512e35..eb06e42 100644 --- a/extension/site_actions.js +++ b/extension/site_actions.js @@ -5,12 +5,15 @@ return config.show_site_save_icon !== false; } - // 可配置 Discourse 域名列表,运行时从配置注入 - let _siteDiscourseDomains = ["linux.do"]; + // 复用 discourse.js 的域名列表,避免双份存储不同步 + // getDiscourseDomains 由 discourse.js 通过全局/X2MD 导出 + function _getDiscourseDomainsList() { + return (globalScope.X2MD?.getDiscourseDomains || globalScope.getDiscourseDomains || (() => ["linux.do"]))(); + } + // 保留向后兼容(调用方可能仍调用此函数),但实际更新 discourse.js 的存储 function setSiteDiscourseDomains(domains) { - if (Array.isArray(domains) && domains.length > 0) { - _siteDiscourseDomains = domains.map(d => d.toLowerCase().trim()).filter(Boolean); - } + const setter = globalScope.X2MD?.setDiscourseDomains || globalScope.setDiscourseDomains; + if (typeof setter === "function") setter(domains); } function detectFloatingSaveSite(locationLike = globalScope.location) { @@ -18,7 +21,7 @@ const pathname = String(locationLike?.pathname || ""); // 检查所有已配置的 Discourse 域名(包括 linux.do 和自定义域名) - if (_siteDiscourseDomains.includes(hostname) && /^\/t\/[^/]+\/\d+(?:\/\d+)?\/?$/.test(pathname)) { + if (_getDiscourseDomainsList().includes(hostname) && /^\/t\/[^/]+\/\d+(?:\/\d+)?\/?$/.test(pathname)) { return hostname === "linux.do" ? "linux_do" : "discourse"; } @@ -102,5 +105,6 @@ module.exports = exported; } + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/extension/twitter_graphql.js b/extension/twitter_graphql.js index d4317a4..54b40d4 100644 --- a/extension/twitter_graphql.js +++ b/extension/twitter_graphql.js @@ -291,5 +291,6 @@ module.exports = exported; } + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/extension/wechat.js b/extension/wechat.js index 4570caf..8e41955 100644 --- a/extension/wechat.js +++ b/extension/wechat.js @@ -534,5 +534,6 @@ module.exports = exported; } + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/server.py b/server.py index c0284d5..dac538f 100644 --- a/server.py +++ b/server.py @@ -13,6 +13,7 @@ import ssl import sys import logging +import traceback import urllib.request from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone, timedelta @@ -117,11 +118,11 @@ def get_app_dir(): except (AttributeError, OSError): pass logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", handlers=_log_handlers, ) -logger = logging.getLogger("x2md") +logger = logging.getLogger("x2md_server") # 全局配置缓存和媒体下载线程池 _config_cache: dict | None = None @@ -136,6 +137,7 @@ def load_config() -> dict: if _config_cache is not None: return _config_cache + logger.info(f"首次加载配置文件: {CONFIG_FILE}") with _config_lock: # double-check after acquiring lock if _config_cache is not None: @@ -145,13 +147,20 @@ def load_config() -> dict: try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: cfg = json.load(f) + merged_keys = [] for k, v in DEFAULT_CONFIG.items(): if k not in cfg: cfg[k] = v + merged_keys.append(k) + if merged_keys: + logger.debug(f"从默认配置补全字段: {merged_keys}") _config_cache = cfg + logger.info(f"配置加载成功, port={cfg.get('port')}, save_paths={cfg.get('save_paths', [])}") return cfg except Exception as e: - logger.warning(f"配置文件读取失败,使用默认配置:{e}") + logger.warning(f"配置文件读取失败,使用默认配置: {e}") + else: + logger.info("配置文件不存在,使用默认配置") _config_cache = DEFAULT_CONFIG.copy() save_config(_config_cache) return _config_cache @@ -686,17 +695,17 @@ def do_OPTIONS(self): def do_GET(self): path = urlparse(self.path).path + logger.debug(f"GET {path} from {self.address_string()}") if path == "/ping": - # 心跳检测 self._respond(200, {"status": "ok", "version": "1.6.0"}) elif path == "/config": - # 返回当前配置 cfg = load_config() self._respond(200, cfg) else: + logger.debug(f"GET 404: {path}") self._respond(404, {"error": "Not Found"}) MAX_REQUEST_SIZE = 10 * 1024 * 1024 # 10MB 请求体上限 @@ -704,15 +713,20 @@ def do_GET(self): def do_POST(self): path = urlparse(self.path).path length = int(self.headers.get("Content-Length", 0)) + origin = self.headers.get("Origin", "N/A") + logger.debug(f"POST {path} from {self.address_string()}, Content-Length={length}, Origin={origin}") + if length > self.MAX_REQUEST_SIZE: + logger.warning(f"请求体过大: {length} 字节 (上限 {self.MAX_REQUEST_SIZE})") self._respond(413, {"error": f"请求体过大({length} 字节),上限 {self.MAX_REQUEST_SIZE} 字节"}) return body = self.rfile.read(length) try: data = json.loads(body.decode("utf-8")) - logger.info(f"接收到请求: type={data.get('type','?')} platform={data.get('platform','?')} url={data.get('url','')[:80]}") - except json.JSONDecodeError: + logger.info(f"POST {path}: type={data.get('type','?')} platform={data.get('platform','?')} url={data.get('url','')[:80]}") + except json.JSONDecodeError as e: + logger.warning(f"JSON 解析失败: {e}, body_preview={body[:200]}") self._respond(400, {"error": "Invalid JSON"}) return @@ -723,22 +737,27 @@ def do_POST(self): elif path == "/config/reset": self._handle_config_reset() else: + logger.debug(f"POST 404: {path}") self._respond(404, {"error": "Not Found"}) def _handle_save(self, data: dict): """核心:接收推文数据,写入 Markdown 文件""" + logger.info(f"处理 /save 请求: type={data.get('type','?')}, platform={data.get('platform','?')}, url={data.get('url','')[:80]}") cfg = load_config() save_paths = cfg.get("save_paths", []) + logger.debug(f"save_paths={save_paths}, enable_platform_folders={cfg.get('enable_platform_folders')}") if not save_paths: + logger.error("未配置保存路径,返回 500") self._respond(500, {"error": "未配置保存路径"}) return try: + logger.debug("开始构建 Markdown...") filename, content, image_tasks, video_tasks = build_markdown(data, cfg) + logger.info(f"Markdown 构建完成: filename={filename}, content_len={len(content)}, images={len(image_tasks)}, videos={len(video_tasks)}") except Exception as e: - import traceback - logger.error(f"Markdown 构建失败:{e}\n{traceback.format_exc()}") + logger.error(f"Markdown 构建失败: {e}\n{traceback.format_exc()}") self._respond(500, {"error": str(e)}) return @@ -835,14 +854,18 @@ def _handle_save(self, data: dict): def _handle_config_update(self, data: dict): """更新配置(仅允许白名单内的字段)""" filtered = {k: v for k, v in data.items() if k in self.ALLOWED_CONFIG_KEYS} + rejected = [k for k in data if k not in self.ALLOWED_CONFIG_KEYS] + if rejected: + logger.debug(f"配置更新被拒绝的字段: {rejected}") if not filtered: + logger.warning("配置更新请求无有效字段") self._respond(400, {"error": "没有有效的配置字段"}) return with _config_lock: - cfg = copy.deepcopy(load_config()) # 深拷贝,避免竞态修改共享引用 + cfg = copy.deepcopy(load_config()) cfg.update(filtered) save_config(cfg) - logger.info(f"配置已更新:{list(filtered.keys())}") + logger.info(f"配置已更新: {list(filtered.keys())}") self._respond(200, {"success": True, "config": cfg}) def _handle_config_reset(self): @@ -862,17 +885,24 @@ def _respond(self, code: int, payload: dict): def main(): + logger.info("server.py 独立启动模式") cfg = load_config() port = cfg.get("port", 9527) - server = ThreadingHTTPServer(("127.0.0.1", port), X2MDHandler) - server.daemon_threads = True # 随主线程退出,不阻塞关闭 - logger.info(f"🚀 x2md 服务已启动,监听 http://127.0.0.1:{port}") - logger.info(f"📁 保存路径:{cfg.get('save_paths', [])}") + logger.info(f"绑定 127.0.0.1:{port}...") + try: + server = ThreadingHTTPServer(("127.0.0.1", port), X2MDHandler) + except OSError as e: + logger.error(f"端口绑定失败: {e}") + return + server.daemon_threads = True + logger.info(f"x2md 服务已启动,监听 http://127.0.0.1:{port}") + logger.info(f"保存路径: {cfg.get('save_paths', [])}") try: server.serve_forever() except KeyboardInterrupt: - logger.info("服务已停止") + logger.info("收到 KeyboardInterrupt,停止服务") server.shutdown() + logger.info("server.py main() 退出") if __name__ == "__main__": diff --git a/setup_wizard.py b/setup_wizard.py index 34a76ab..5f24fe8 100644 --- a/setup_wizard.py +++ b/setup_wizard.py @@ -8,10 +8,13 @@ import os import sys import json +import logging import platform +import traceback import tkinter as tk from tkinter import filedialog +logger = logging.getLogger("x2md_wizard") # ───────────────────────────────────────────── # 路径工具 @@ -67,7 +70,11 @@ class SetupWizard: """首次运行 4 步设置向导""" def __init__(self): + logger.info("初始化设置向导...") + logger.debug(f"tkinter 版本: {tk.TkVersion}") + logger.debug(f"平台: {platform.system()}, 字体: {FONT_FAMILY}") self.root = tk.Tk() + logger.debug("tk.Tk() 创建成功") self.root.title("X2MD 设置向导") self.root.geometry("720x680") self.root.minsize(600, 500) @@ -144,6 +151,7 @@ def _clear(self): w.destroy() def _show_step(self, step): + logger.info(f"向导切换到步骤 {step + 1}/4") self.current_step = step self._clear() self._draw_progress() @@ -396,10 +404,20 @@ def _finish(self): """保存配置 → 标记完成 → 关闭窗口""" md = self.md_path.get().strip() vid = self.video_path.get().strip() + logger.info(f"向导完成, md_path={md}, video_path={vid}") # 自动创建目录 - os.makedirs(md, exist_ok=True) - os.makedirs(vid, exist_ok=True) + try: + os.makedirs(md, exist_ok=True) + logger.debug(f"Markdown 目录已确保存在: {md}") + except Exception as e: + logger.error(f"创建 Markdown 目录失败: {md}, 错误: {e}") + + try: + os.makedirs(vid, exist_ok=True) + logger.debug(f"视频目录已确保存在: {vid}") + except Exception as e: + logger.error(f"创建视频目录失败: {vid}, 错误: {e}") # 读取已有配置 config = {} @@ -407,8 +425,9 @@ def _finish(self): try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: config = json.load(f) - except Exception: - pass + logger.debug("已读取现有配置文件") + except Exception as e: + logger.warning(f"读取现有配置失败: {e}") # 写入配置 config.update({ @@ -420,25 +439,49 @@ def _finish(self): "setup_completed": True, }) - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(config, f, ensure_ascii=False, indent=2) + try: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + logger.info(f"配置已保存到: {CONFIG_FILE}") + except Exception as e: + logger.error(f"保存配置失败: {e}\n{traceback.format_exc()}") self.completed = True self.root.destroy() + logger.info("向导窗口已关闭") # ── 公开入口 ────────────────────────────────── def run(self) -> bool: """运行向导,返回是否完成""" - self.root.protocol("WM_DELETE_WINDOW", self.root.destroy) + logger.info("开始向导主循环") + self.root.protocol("WM_DELETE_WINDOW", self._on_close) self.root.mainloop() + logger.info(f"向导主循环结束, completed={self.completed}") return self.completed + def _on_close(self): + """用户关闭窗口""" + logger.info("用户关闭了向导窗口") + self.root.destroy() + def run_wizard() -> bool: """运行设置向导的快捷函数""" - return SetupWizard().run() + logger.info("run_wizard() 被调用") + try: + result = SetupWizard().run() + logger.info(f"run_wizard() 返回: {result}") + return result + except Exception as e: + logger.error(f"向导运行异常: {e}\n{traceback.format_exc()}") + return False if __name__ == "__main__": + # 独立运行时配置基本日志 + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", + ) ok = run_wizard() - print("✅ 向导完成" if ok else "❌ 向导已取消") + print("向导完成" if ok else "向导已取消") diff --git a/tray_app.py b/tray_app.py index f32f78c..811304c 100644 --- a/tray_app.py +++ b/tray_app.py @@ -11,8 +11,42 @@ import threading import subprocess import logging +import traceback import urllib.request +# ───────────────────────────────────────────── +# Windows 崩溃弹窗(console=False 时唯一的错误通知方式) +# ───────────────────────────────────────────── +def _show_crash_dialog(title, message): + """在 Windows 上弹出错误对话框(仅 console=False 打包环境使用)""" + try: + if sys.platform == "win32": + import ctypes + ctypes.windll.user32.MessageBoxW(0, str(message), str(title), 0x10) # MB_ICONERROR + except Exception: + pass # 弹窗本身失败则静默(最后防线) + + +def _write_crash_log(message): + """紧急崩溃日志写入(logging 尚未初始化或已损坏时使用)""" + try: + crash_dir = os.path.join(os.environ.get("APPDATA") or os.path.expanduser("~"), "X2MD") + os.makedirs(crash_dir, exist_ok=True) + crash_file = os.path.join(crash_dir, "crash.log") + with open(crash_file, "a", encoding="utf-8") as f: + from datetime import datetime + f.write(f"\n{'='*60}\n") + f.write(f"CRASH at {datetime.now().isoformat()}\n") + f.write(f"Python: {sys.version}\n") + f.write(f"Platform: {sys.platform}\n") + f.write(f"Executable: {sys.executable}\n") + f.write(f"Frozen: {getattr(sys, 'frozen', False)}\n") + f.write(f"_MEIPASS: {getattr(sys, '_MEIPASS', 'N/A')}\n") + f.write(f"\n{message}\n") + except Exception: + pass + + # ───────────────────────────────────────────── # 路径工具 # ───────────────────────────────────────────── @@ -49,7 +83,16 @@ def get_resource_dir(): if not os.path.isdir(EXT_DIR): EXT_DIR = os.path.join(RESOURCE_DIR, "extension") -_log_handlers = [logging.FileHandler(os.path.join(APP_DIR, "x2md.log"), encoding="utf-8")] +# ───────────────────────────────────────────── +# 日志初始化(带详细诊断) +# ───────────────────────────────────────────── +_log_file_path = os.path.join(APP_DIR, "x2md.log") +_log_handlers = [] +try: + _log_handlers.append(logging.FileHandler(_log_file_path, encoding="utf-8")) +except Exception as _log_err: + _write_crash_log(f"Failed to create log FileHandler: {_log_err}\nLog path: {_log_file_path}") + # Windows 打包后无控制台窗口,sys.stdout/stderr 为 None,不能创建 StreamHandler if sys.stdout is not None: try: @@ -58,49 +101,111 @@ def get_resource_dir(): _log_handlers.append(logging.StreamHandler(_stream_out)) except (AttributeError, OSError): pass + +# 确保至少有一个 handler(兜底用 NullHandler 防止 logging 报错) +if not _log_handlers: + _log_handlers.append(logging.NullHandler()) + logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", + level=logging.DEBUG, # 使用 DEBUG 级别以捕获最详细的启动信息 + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", handlers=_log_handlers, ) logger = logging.getLogger("x2md_tray") +# ───────────────────────────────────────────── +# 启动环境诊断日志 +# ───────────────────────────────────────────── +logger.info("=" * 60) +logger.info("X2MD 启动") +logger.info(f"Python 版本: {sys.version}") +logger.info(f"平台: {sys.platform}") +logger.info(f"可执行文件: {sys.executable}") +logger.info(f"工作目录: {os.getcwd()}") +logger.info(f"打包模式 (frozen): {getattr(sys, 'frozen', False)}") +logger.info(f"_MEIPASS: {getattr(sys, '_MEIPASS', 'N/A')}") +logger.info(f"APP_DIR: {APP_DIR}") +logger.info(f"RESOURCE_DIR: {RESOURCE_DIR}") +logger.info(f"CONFIG_FILE: {CONFIG_FILE}") +logger.info(f"EXT_DIR: {EXT_DIR}") +logger.info(f"日志文件: {_log_file_path}") +logger.info(f"sys.argv: {sys.argv}") +logger.info(f"sys.stdout is None: {sys.stdout is None}") +logger.info(f"sys.stderr is None: {sys.stderr is None}") +logger.debug(f"APP_DIR 存在: {os.path.isdir(APP_DIR)}") +logger.debug(f"CONFIG_FILE 存在: {os.path.isfile(CONFIG_FILE)}") +logger.debug(f"EXT_DIR 存在: {os.path.isdir(EXT_DIR)}") +if os.path.isdir(EXT_DIR): + try: + ext_files = os.listdir(EXT_DIR) + logger.debug(f"EXT_DIR 文件数: {len(ext_files)}, 文件: {ext_files[:10]}") + except Exception as e: + logger.warning(f"无法列出 EXT_DIR 内容: {e}") +logger.info("=" * 60) + def is_setup_completed() -> bool: """检查是否已完成向导设置""" + logger.debug(f"检查向导完成状态, CONFIG_FILE={CONFIG_FILE}, 存在={os.path.exists(CONFIG_FILE)}") if not os.path.exists(CONFIG_FILE): + logger.info("配置文件不存在,向导未完成") return False try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: - return json.load(f).get("setup_completed", False) - except Exception: + cfg = json.load(f) + completed = cfg.get("setup_completed", False) + logger.debug(f"setup_completed={completed}") + return completed + except Exception as e: + logger.warning(f"读取配置文件失败: {e}") return False def run_setup_wizard() -> bool: """运行设置向导(在当前进程中)""" - from setup_wizard import run_wizard - return run_wizard() + logger.info("正在导入 setup_wizard 模块...") + try: + from setup_wizard import run_wizard + logger.info("setup_wizard 模块导入成功,启动向导") + result = run_wizard() + logger.info(f"设置向导返回: completed={result}") + return result + except ImportError as e: + logger.error(f"setup_wizard 模块导入失败: {e}\n{traceback.format_exc()}") + return False + except Exception as e: + logger.error(f"设置向导运行异常: {e}\n{traceback.format_exc()}") + return False def launch_wizard_subprocess(): """以子进程方式启动设置向导(从托盘菜单调用,避免线程冲突)""" - if getattr(sys, 'frozen', False): - # 打包环境:直接启动自身并附加 --wizard 参数 - subprocess.Popen([sys.executable, "--wizard"]) - else: - # 开发模式:setup_wizard.py 在源码目录(与 tray_app.py 同级),而非 APP_DIR - source_dir = os.path.dirname(os.path.abspath(__file__)) - script = os.path.join(source_dir, "setup_wizard.py") - subprocess.Popen([sys.executable, script]) + logger.info("从托盘菜单启动设置向导子进程...") + try: + if getattr(sys, 'frozen', False): + cmd = [sys.executable, "--wizard"] + logger.debug(f"打包环境,执行命令: {cmd}") + subprocess.Popen(cmd) + else: + source_dir = os.path.dirname(os.path.abspath(__file__)) + script = os.path.join(source_dir, "setup_wizard.py") + cmd = [sys.executable, script] + logger.debug(f"开发模式,执行命令: {cmd}") + subprocess.Popen(cmd) + logger.info("设置向导子进程已启动") + except Exception as e: + logger.error(f"启动设置向导子进程失败: {e}\n{traceback.format_exc()}") def get_port() -> int: """从配置文件读取端口""" try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: - return json.load(f).get("port", 9527) - except Exception: + port = json.load(f).get("port", 9527) + logger.debug(f"读取端口配置: {port}") + return port + except Exception as e: + logger.warning(f"读取端口配置失败,使用默认值 9527: {e}") return 9527 @@ -109,8 +214,11 @@ def check_server_alive(port: int) -> bool: try: req = urllib.request.Request(f"http://127.0.0.1:{port}/ping") with urllib.request.urlopen(req, timeout=2) as resp: - return resp.status == 200 - except Exception: + alive = resp.status == 200 + logger.debug(f"服务健康检查 port={port}: alive={alive}") + return alive + except Exception as e: + logger.debug(f"服务健康检查 port={port}: 不可达 ({e})") return False @@ -124,19 +232,42 @@ def start_server_thread(): """在后台线程中启动 HTTP Server""" global _server_ref - from http.server import HTTPServer - from server import X2MDHandler, load_config + logger.info("正在导入 server 模块...") + try: + from http.server import HTTPServer + logger.debug("http.server.HTTPServer 导入成功") + except ImportError as e: + logger.error(f"http.server 导入失败: {e}") + raise + try: + from server import X2MDHandler, load_config + logger.debug("server.X2MDHandler 和 load_config 导入成功") + except ImportError as e: + logger.error(f"server 模块导入失败: {e}\n{traceback.format_exc()}") + raise + + logger.info("加载配置...") cfg = load_config() port = cfg.get("port", 9527) - server = HTTPServer(("127.0.0.1", port), X2MDHandler) + logger.info(f"配置加载完成, port={port}, save_paths={cfg.get('save_paths', [])}") + + logger.info(f"正在绑定 127.0.0.1:{port}...") + try: + server = HTTPServer(("127.0.0.1", port), X2MDHandler) + except OSError as e: + logger.error(f"端口绑定失败 127.0.0.1:{port}: {e}") + if "Address already in use" in str(e) or "10048" in str(e): + logger.error(f"端口 {port} 已被占用,可能有另一个 X2MD 实例正在运行") + raise _server_ref = server - logger.info(f"🚀 x2md 服务已启动,监听 http://127.0.0.1:{port}") - logger.info(f"📁 保存路径:{cfg.get('save_paths', [])}") + logger.info(f"x2md 服务已启动,监听 http://127.0.0.1:{port}") + logger.info(f"保存路径: {cfg.get('save_paths', [])}") - t = threading.Thread(target=server.serve_forever, daemon=True) + t = threading.Thread(target=server.serve_forever, daemon=True, name="x2md-http-server") t.start() + logger.debug(f"HTTP Server 线程已启动: {t.name}, daemon={t.daemon}") return t @@ -144,16 +275,20 @@ def stop_server(): """停止 HTTP Server""" global _server_ref if _server_ref: + logger.info("正在停止 HTTP Server...") _server_ref.shutdown() _server_ref = None - logger.info("服务已停止") + logger.info("HTTP Server 已停止") + else: + logger.debug("stop_server 调用但无运行中的服务") def restart_server(): """重启 HTTP Server""" + logger.info("正在重启服务...") stop_server() start_server_thread() - logger.info("🔄 服务已重启") + logger.info("服务已重启") # ───────────────────────────────────────────── @@ -161,6 +296,7 @@ def restart_server(): # ───────────────────────────────────────────── def create_tray_icon_image(): """生成托盘图标(程序化绘制,无需外部图片文件)""" + logger.debug("正在创建托盘图标...") from PIL import Image, ImageDraw, ImageFont size = 64 @@ -192,10 +328,13 @@ def create_tray_icon_image(): def run_tray(): """运行系统托盘(阻塞主线程)""" + logger.info("正在导入 pystray...") import pystray + logger.debug("pystray 导入成功") icon_image = create_tray_icon_image() port = get_port() + logger.info(f"准备创建托盘图标, port={port}") def on_open_wizard(icon, item): launch_wizard_subprocess() @@ -237,8 +376,13 @@ def on_quit(icon, item): ) icon = pystray.Icon("x2md", icon_image, "X2MD", menu) - logger.info("系统托盘图标已添加") - icon.run() + logger.info("系统托盘图标已创建,开始运行事件循环...") + try: + icon.run() + except Exception as e: + logger.error(f"托盘事件循环异常退出: {e}\n{traceback.format_exc()}") + raise + logger.info("托盘事件循环已退出") # ───────────────────────────────────────────── @@ -264,10 +408,13 @@ def ensure_extension_accessible(): 使用 .x2md_version 标记文件判断是否需要更新。""" global EXT_DIR import shutil + logger.info("检查 extension 文件夹可访问性...") target = os.path.join(APP_DIR, "extension") src = os.path.join(RESOURCE_DIR, "extension") version_file = os.path.join(APP_DIR, ".x2md_version") + logger.debug(f"extension target={target}, src={src}, version_file={version_file}") + logger.debug(f"target 存在={os.path.isdir(target)}, src 存在={os.path.isdir(src)}") # 读取打包资源中的版本号(从 server.py 的 /ping 接口硬编码值获取,或用 manifest) bundled_version = "" @@ -337,6 +484,7 @@ def _merge_config_defaults(): def ensure_config_accessible(): """确保 config.json 在 APP_DIR(可写位置),打包后首次运行时从资源目录复制。 同时迁移旧版本遗留在 app 包内的配置。升级时自动补全新增字段。""" + logger.info(f"检查配置文件可访问性: {CONFIG_FILE}") if os.path.exists(CONFIG_FILE): # 已有配置:合并新版本可能新增的默认字段 _merge_config_defaults() @@ -361,34 +509,65 @@ def ensure_config_accessible(): def main(): + logger.info("main() 开始执行") + logger.info(f"切换工作目录到 APP_DIR: {APP_DIR}") os.chdir(APP_DIR) # 处理 --wizard 参数(从托盘菜单调用) if "--wizard" in sys.argv: + logger.info("检测到 --wizard 参数,启动向导模式") run_setup_wizard() return # 确保 config 和 extension 在用户可访问的位置 + logger.info("[阶段 1/4] 检查配置文件...") ensure_config_accessible() + logger.info("[阶段 2/4] 检查扩展文件夹...") ensure_extension_accessible() # 首次运行:弹出设置向导 + logger.info("[阶段 3/4] 检查向导完成状态...") if not is_setup_completed(): logger.info("首次运行,启动设置向导...") completed = run_setup_wizard() if not completed: logger.info("设置向导被取消,退出。") sys.exit(0) + logger.info("设置向导已完成") + else: + logger.info("向导已完成,跳过") # 尝试启动系统托盘 + logger.info("[阶段 4/4] 启动服务和托盘...") try: + logger.info("检查 pystray 可用性...") import pystray # noqa: F401 + logger.debug("pystray 导入成功") + logger.info("检查 PIL 可用性...") from PIL import Image # noqa: F401 + logger.debug("PIL.Image 导入成功") + + logger.info("启动 HTTP Server 线程...") start_server_thread() + logger.info("启动系统托盘...") run_tray() - except ImportError: + except ImportError as e: + logger.warning(f"GUI 依赖缺失 ({e}),降级到命令行模式") run_cli_mode() + logger.info("main() 正常退出") + if __name__ == "__main__": - main() + try: + main() + except SystemExit: + logger.info("程序通过 sys.exit() 退出") + raise + except Exception as e: + # 顶层异常捕获:防止 console=False 时静默崩溃 + error_msg = f"X2MD 启动失败(未捕获异常):\n\n{type(e).__name__}: {e}\n\n{traceback.format_exc()}" + logger.critical(error_msg) + _write_crash_log(error_msg) + _show_crash_dialog("X2MD 启动失败", f"{type(e).__name__}: {e}\n\n详情请查看日志文件:\n{_log_file_path}") + sys.exit(1) From 325586f8740953bd855b2561485449b26b430171 Mon Sep 17 00:00:00 2001 From: AchengBusiness Date: Mon, 30 Mar 2026 00:54:21 +0000 Subject: [PATCH 48/48] =?UTF-8?q?fix:=20v1.6.0=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D=20=E2=80=94=20P0=20bug=202?= =?UTF-8?q?=E4=B8=AA=20+=20=E9=85=8D=E7=BD=AE=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 严重问题: 1. permissions.request 在 background.js (service worker) 中 → 移至 options.js(用户手势上下文) 2. DEFAULT_CONFIG 浅拷贝(.copy())→ 改为 copy.deepcopy() 防嵌套字典污染 配置补全: - platform_folder_names 添加 Discourse / 飞书聊天 映射 - 补充 theme / notion_prop_comment_count 默认值 Co-Authored-By: Claude Opus 4.6 --- config.json | 6 +++++- extension/background.js | 7 ++----- extension/options.js | 15 ++++++++++++++- server.py | 4 ++-- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/config.json b/config.json index f97a9d5..754662f 100644 --- a/config.json +++ b/config.json @@ -13,7 +13,9 @@ "platform_folder_names": { "Twitter/X": "Twitter", "LINUX DO": "LINUX DO", + "Discourse": "Discourse", "飞书": "飞书", + "飞书聊天": "飞书聊天", "微信公众号": "微信公众号" }, "download_images": true, @@ -48,5 +50,7 @@ "notion_prop_tags": "标签", "notion_prop_saved_date": "保存日期", "notion_prop_type": "类型", - "html_export_folder": "X2MD导出" + "html_export_folder": "X2MD导出", + "theme": "dark", + "notion_prop_comment_count": "评论数" } diff --git a/extension/background.js b/extension/background.js index df6ef65..635c4b7 100644 --- a/extension/background.js +++ b/extension/background.js @@ -114,11 +114,8 @@ async function registerDiscourseContentScripts(domains) { const matches = extraDomains.map(d => `https://${d}/*`); try { - // 先请求可选权限 - const granted = await chrome.permissions.request({ origins: matches }).catch(() => false); - if (!granted) { - console.warn("[x2md] 用户未授权额外域名权限:", extraDomains); - } + // 注:chrome.permissions.request() 在 MV3 service worker 中不可用(需要用户手势) + // 权限请求应在 options.js 中进行 await chrome.scripting.registerContentScripts([{ id: "x2md-discourse-extra", matches, diff --git a/extension/options.js b/extension/options.js index 4ad6d1d..70fa73b 100644 --- a/extension/options.js +++ b/extension/options.js @@ -320,7 +320,7 @@ async function detectSiteType(domain, tagEl) { tagEl.style.color = "var(--text-muted)"; } -function addDiscourseDomain() { +async function addDiscourseDomain() { const input = document.getElementById("newDiscourseDomain"); let domain = input.value.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, ""); if (!domain || !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) { @@ -331,6 +331,19 @@ function addDiscourseDomain() { showToast("不允许使用内网地址", true); return; } if (_discourseDomains.includes(domain)) { showToast("该域名已存在", true); return; } + + // 请求权限(仅 linux.do 外的域名) + if (domain.toLowerCase() !== "linux.do") { + try { + const granted = await chrome.permissions.request({ origins: [`https://${domain}/*`] }); + if (!granted) { + showToast("未授权该域名权限,保存可能无法正常工作", true); + } + } catch (e) { + console.warn("[x2md] 权限请求失败:", e); + } + } + _discourseDomains.push(domain); renderDiscourseDomains(_discourseDomains); input.value = ""; diff --git a/server.py b/server.py index dac538f..490825d 100644 --- a/server.py +++ b/server.py @@ -161,7 +161,7 @@ def load_config() -> dict: logger.warning(f"配置文件读取失败,使用默认配置: {e}") else: logger.info("配置文件不存在,使用默认配置") - _config_cache = DEFAULT_CONFIG.copy() + _config_cache = copy.deepcopy(DEFAULT_CONFIG) save_config(_config_cache) return _config_cache @@ -870,7 +870,7 @@ def _handle_config_update(self, data: dict): def _handle_config_reset(self): """重置配置为默认值""" - save_config(dict(DEFAULT_CONFIG)) + save_config(copy.deepcopy(DEFAULT_CONFIG)) logger.info("配置已重置为默认值") self._respond(200, {"success": True, "config": DEFAULT_CONFIG})