diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe38fc4..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.0.8' + default: '1.6.0' # 推送 tag 时自动触发(如 git tag v1.0.0 && git push --tags) push: tags: @@ -37,7 +37,7 @@ jobs: - name: 安装 Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.12' - name: 安装依赖 run: | @@ -127,12 +127,14 @@ jobs: merge-multiple: true - name: 创建 GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.6.1 with: name: X2MD ${{ github.ref_name }} body: | ## X2MD ${{ github.ref_name }} + 网页内容一键转 Markdown — 支持 X/Twitter、LINUX DO、Discourse、飞书(文档/聊天记录)、微信公众号,多目标保存到 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、Discourse、飞书文档/聊天记录、微信公众号)使用保存按钮保存内容 + 4. 打开扩展选项页面可配置多目标保存、评论提取等高级功能 + + ### 升级用户须知 + - 从旧版本升级后首次运行,应用会自动合并新增配置字段并更新扩展文件 + - 你的已有设置(保存路径等)不会被覆盖 ### 系统要求 - Mac: macOS 12+ diff --git a/README.md b/README.md index 308cc07..68c1e09 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # 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) [![Build](https://img.shields.io/github/actions/workflow/status/izscc/x2md/build.yml?label=build)](https://github.com/izscc/x2md/actions/workflows/build.yml) +> **致谢**: 感谢 [@AchengBusiness](https://github.com/AchengBusiness) 提供 Windows 兼容性修复、多平台 Bug 修复、新功能与测试套件,这批改动已合并回主仓库。 + ## 教程导览图 ![X2MD 快速上手流程](docs/images/tutorial-flow.svg) @@ -17,6 +19,7 @@ - 你需要把 Thread / Note 长文转成可编辑 Markdown,用于二次创作。 - 你在 LINUX DO 论坛中看到值得收藏的帖子。 - 你在飞书知识库阅读团队文档,想要本地留档。 +- 你想把飞书网页版的聊天记录导出为 Markdown 存档。 - 你在微信公众号看到好文章,想保存完整 Markdown 副本。 ## 支持平台 @@ -25,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 分钟快速上手(推荐) @@ -33,11 +38,11 @@ ### 第 1 步:下载客户端(基于 GitHub Release) - 打开 [Releases](https://github.com/izscc/x2md/releases/latest) -- 当前最新版本(截至 2026-03-24):[`v1.0.8`](https://github.com/izscc/x2md/releases/tag/v1.0.8) +- 当前最新版本:[`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.0.8/X2MD_Mac.zip) - - Windows: [`X2MD_Windows.zip`](https://github.com/izscc/x2md/releases/download/v1.0.8/X2MD_Windows.zip) - - 扩展: [`X2MD_Extension.zip`](https://github.com/izscc/x2md/releases/download/v1.0.8/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 步:首次运行并完成向导 @@ -58,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 中查看结果 @@ -71,8 +77,9 @@ ## 常用配置(进阶) -配置文件为根目录的 `config.json`,常用字段: +配置文件位于应用数据目录下的 `config.json`(Mac: `~/Library/Application Support/X2MD/`,Windows: `%APPDATA%/X2MD/`),也可通过扩展选项页面可视化修改。常用字段: +**基础设置:** - `save_paths`: Markdown 输出目录列表 - `filename_format`: 文件名模板,支持 `{summary}` `{date}` `{author}` - `max_filename_length`: 文件名长度上限 @@ -80,6 +87,20 @@ - `enable_video_download`: 是否下载视频 - `video_duration_threshold`: 超长视频二次确认阈值(分钟) - `show_site_save_icon`: 是否在支持的站点显示悬浮保存按钮 +- `enable_platform_folders`: 是否按平台分子文件夹(Twitter/、LINUX DO/、Discourse/、飞书/、飞书聊天/、微信公众号/) +- `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` 内联) 推荐模板示例: @@ -88,7 +109,14 @@ "filename_format": "{summary}_{date}_{author}", "max_filename_length": 60, "enable_video_download": true, - "video_duration_threshold": 5 + "video_duration_threshold": 5, + "enable_platform_folders": true, + "download_images": true, + "overwrite_existing": false, + "save_to_obsidian": true, + "save_to_feishu": false, + "save_to_notion": false, + "enable_comments": false } ``` @@ -159,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 # 内容脚本主入口 @@ -171,7 +199,87 @@ CI 工作流见 [`.github/workflows/build.yml`](./.github/workflows/build.yml) ## 更新日志 -### v1.0.8(2026-03-24) +### 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) + +- 修复版本升级后设置向导不弹出、扩展文件不更新的问题 +- 新增配置自动合并:升级版本后新增的配置字段自动补全,不覆盖用户已有设置 +- 新增扩展版本检测:通过 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、配置导入导出 +- 新增 66 个自动化测试(23 个服务端回归 + 43 个 Obsidian 渲染),发布前全部通过 +- 修复 LinuxDo lightbox 图片被包成嵌套链接的问题,确保导出 Markdown 为直接图片引用 +- README 下载链接、版本徽章与发版口径切回主仓库 +### v1.2.3(2026-03-25) + +- 修复视频占位符 `[MEDIA_VIDEO_URL:xxx]` 泄漏到 Front Matter title 和文件名的 Bug +- 同步所有组件版本号到 1.2.3(server.py / manifest.json / x2md.spec) +- 新增 43 个 Obsidian 渲染专项测试,全部 66 个测试 100% 通过 + +### v1.2.2(2026-03-25) + +- 修复飞书嵌套列表只输出 children 丢失父级文本的问题 +- 修复飞书 `document.head` 为 null 时注入 CSS 崩溃 +- 修复 background.js 中 `noteResultVideos` 死代码导致视频合并失败 +- 修复 Twitter GraphQL 提取中 `note_tweet` 结构变化的兼容性 +- 修复微信公众号代码块 `
` 未转换为换行的问题 + +### v1.2.1(2026-03-25) + +- 新增覆盖/去重选项:基于 Front Matter `源:` URL 自动跳过已保存文件 +- 新增飞书 JSON API 提取策略(3 层自动降级:JSON API -> DOM -> 滚动收集) +- 修复 LINUX DO 有序列表和嵌套列表缩进 +- 修复多个平台 Front Matter 字段缺失问题 + +### v1.2.0(2026-03-25) + +- 新增平台分类文件夹功能(Twitter/、LinuxDo/、Feishu/、WeChat/) +- 新增图片本地下载功能(远程图片 -> assets/ 子目录,相对路径引用) +- 修复跨平台内容提取和 Obsidian 渲染的多个 Bug + +### v1.1.0(2026-03-24) + +- 新增跨设备配置同步功能 +- 修复 Windows 打包后 `sys.stdout` 为 None 导致启动崩溃 +- 修复 Windows 设置向导字体乱码和 emoji 渲染问题 +- 修复 6 个扩展 Bug(GraphQL 权限、监听器泄漏、有序列表等) + +### v1.0.9(2026-03-24) + +- 修复 Windows DLL 崩溃(strip 损坏 DLL + UPX 压缩 Python/VC++ DLL) +- 修复 10 个代码 Bug(路径处理、编码、Front Matter 格式等) +- PyInstaller spec 增加 DLL 保护白名单 + +### v1.0.8(2026-03-24)- 原版最终版本 - 修复飞书虚拟渲染导致丢失 85% 内容的问题,新增滚动收集机制 - 补充飞书 iframe、table、base_refer、synced_source 等 block 类型 @@ -190,8 +298,8 @@ CI 工作流见 [`.github/workflows/build.yml`](./.github/workflows/build.yml) - 修复 X 长文代码块语言标签未并入 Markdown fenced code block 的问题 -## GitHub 数据来源 +## 致谢 + +感谢所有贡献者,特别是 [@AchengBusiness](https://github.com/AchengBusiness) 为本次跨平台增强版带来的大量修复、功能和测试补充。 -- 仓库信息: -- 语言统计: -- 最新发布: +如果这个工具对你有帮助,欢迎给 [izscc/x2md](https://github.com/izscc/x2md) 点个 Star。 diff --git a/config.json b/config.json index 2119ef6..754662f 100644 --- a/config.json +++ b/config.json @@ -1,9 +1,56 @@ { "port": 9527, - "save_paths": [ - "/Users/zscc.in/Desktop/船仓文件/Obsidian/OB/00-资料库/📄 素材库" - ], - "filename_format": "{summary}", + "save_paths": [], + "filename_format": "{summary}_{date}_{author}", "max_filename_length": 60, - "setup_completed": false -} \ No newline at end of file + "setup_completed": false, + "show_site_save_icon": true, + "enable_copy_unlock": false, + "enable_video_download": true, + "video_duration_threshold": 5, + "video_save_path": "", + "enable_platform_folders": true, + "platform_folder_names": { + "Twitter/X": "Twitter", + "LINUX DO": "LINUX DO", + "Discourse": "Discourse", + "飞书": "飞书", + "飞书聊天": "飞书聊天", + "微信公众号": "微信公众号" + }, + "download_images": true, + "image_subfolder": "assets", + "enable_wechat_video_channel": false, + "overwrite_existing": false, + "sync_enabled": true, + "enable_comments": false, + "comments_display": "details", + "max_comments": 200, + "comment_floor_range": "", + "discourse_domains": [ + "linux.do" + ], + "embed_mode": "local", + "save_to_obsidian": true, + "save_to_feishu": false, + "save_to_notion": false, + "export_html": false, + "feishu_api_domain": "feishu", + "feishu_app_id": "", + "feishu_app_secret": "", + "feishu_app_token": "", + "feishu_table_id": "", + "feishu_upload_md": false, + "feishu_upload_html": false, + "notion_token": "", + "notion_database_id": "", + "notion_prop_title": "标题", + "notion_prop_url": "链接", + "notion_prop_author": "作者", + "notion_prop_tags": "标签", + "notion_prop_saved_date": "保存日期", + "notion_prop_type": "类型", + "html_export_folder": "X2MD导出", + "theme": "dark", + "notion_prop_comment_count": "评论数" +} diff --git a/extension/article_markdown.js b/extension/article_markdown.js index d14d4b1..8165d98 100644 --- a/extension/article_markdown.js +++ b/extension/article_markdown.js @@ -32,7 +32,7 @@ const style = safeGetComputedStyle(element, options); const fontWeight = style.fontWeight; const numericWeight = parseInt(fontWeight, 10); - return fontWeight === "bold" || Number.isFinite(numericWeight) && numericWeight >= 700; + return fontWeight === "bold" || (Number.isFinite(numericWeight) && numericWeight >= 700); } function isBlockElement(element, options = {}) { @@ -178,22 +178,40 @@ if (text.includes("![](") || text.includes("[MEDIA_VIDEO_URL:") || text.includes("[[VIDEO_HOLDER_")) { return markdown; } - return `[${text}](${href})`; + return `[${escapeMdLinkText(text)}](${escapeMdLinkUrl(href)})`; } if (isBoldElement(element, options) && markdown.trim()) { markdown = `**${markdown.replace(/\*\*/g, "")}**`; } + // 斜体 + if ((tag === "em" || tag === "i") && markdown.trim()) { + markdown = `*${markdown.trim()}*`; + } + + // 删除线 + if ((tag === "del" || tag === "s") && markdown.trim()) { + markdown = `~~${markdown.trim()}~~`; + } + if (tag === "h1") markdown = `\n# ${markdown.replace(/\*\*/g, "").trim()}\n`; else if (tag === "h2") markdown = `\n## ${markdown.replace(/\*\*/g, "").trim()}\n`; else if (tag === "h3") markdown = `\n### ${markdown.replace(/\*\*/g, "").trim()}\n`; - else if (tag === "h4" || tag === "h5" || tag === "h6") markdown = `\n#### ${markdown.replace(/\*\*/g, "").trim()}\n`; + else if (tag === "h4") markdown = `\n#### ${markdown.replace(/\*\*/g, "").trim()}\n`; + else if (tag === "h5") markdown = `\n##### ${markdown.replace(/\*\*/g, "").trim()}\n`; + else if (tag === "h6") markdown = `\n###### ${markdown.replace(/\*\*/g, "").trim()}\n`; else if (tag === "blockquote") { const lines = markdown.trim().split("\n").filter((line) => line.trim() !== ""); markdown = "\n" + lines.map((line) => "> " + line).join("\n") + "\n"; } else if (tag === "li") { - markdown = `\n- ${markdown.trim()}\n`; + const parentTag = (element.parentElement?.tagName || "").toLowerCase(); + if (parentTag === "ol") { + const idx = Array.from(element.parentElement.children).indexOf(element) + 1; + markdown = `\n${idx}. ${markdown.trim()}\n`; + } else { + markdown = `\n- ${markdown.trim()}\n`; + } } else if (isBlockElement(element, options)) { markdown = `\n${markdown}\n`; } @@ -217,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 c784911..635c4b7 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,18 +1,144 @@ /** - * 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"); importScripts("twitter_graphql.js"); -importScripts("twitter_graphql.js"); -const SERVER_BASE = "http://127.0.0.1:9527"; +// 调试日志开关:仅在开发时设为 true +const X2MD_DEBUG = false; +function debugLog(...args) { if (X2MD_DEBUG) console.log("[x2md]", ...args); } + +let SERVER_BASE = "http://127.0.0.1:9527"; + +// 可同步到 chrome.storage.sync 的配置字段列表(去重定义) +const SYNC_FIELDS = [ + "filename_format", "max_filename_length", + "enable_video_download", "video_duration_threshold", "show_site_save_icon", + "enable_platform_folders", "download_images", "image_subfolder", + "overwrite_existing", + "enable_comments", "comments_display", "max_comments", "comment_floor_range", + "discourse_domains", "embed_mode", + "enable_wechat_video_channel", + // 保存目标开关 + "save_to_obsidian", "save_to_feishu", "save_to_notion", "export_html", + // 外观 + "theme", + // 飞书 Bitable + "feishu_api_domain", "feishu_app_id", "feishu_app_token", "feishu_table_id", + "feishu_upload_md", "feishu_upload_html", + // Notion Database + "notion_database_id", + "notion_prop_title", "notion_prop_url", "notion_prop_author", + "notion_prop_tags", "notion_prop_saved_date", "notion_prop_type", + // HTML 导出 + "html_export_folder", + // 飞书一键复制 + "enable_copy_unlock", +]; + +// 从本地存储恢复用户自定义端口 +chrome.storage.local.get("x2md_port", (data) => { + if (data.x2md_port) SERVER_BASE = `http://127.0.0.1:${data.x2md_port}`; +}); + +// ── 高性能缓存层(Cache-First + Background Revalidate)────────── +// MV3 Service Worker 生命周期短暂,内存缓存易丢失,使用 chrome.storage.local 持久化 +const CONFIG_CACHE_KEY = "x2md_config_cache"; +const CONFIG_CACHE_TTL = 60 * 1000; // 60秒内直接使用缓存 +let _configMemCache = null; // 内存快速缓存 +let _configMemCacheTime = 0; + +async function getCachedConfig() { + // 内存缓存命中(最快路径) + const now = Date.now(); + if (_configMemCache && (now - _configMemCacheTime) < CONFIG_CACHE_TTL) { + return _configMemCache; + } + // 持久化缓存命中 + try { + const stored = await chrome.storage.local.get(CONFIG_CACHE_KEY); + const cached = stored[CONFIG_CACHE_KEY]; + if (cached && cached.data && (now - cached.time) < CONFIG_CACHE_TTL) { + _configMemCache = cached.data; + _configMemCacheTime = cached.time; + return cached.data; + } + } catch { /* fall through */ } + // 缓存未命中:从服务器获取 + return refreshConfigCache(); +} + +async function refreshConfigCache() { + try { + const resp = await fetch(`${SERVER_BASE}/config`, { signal: AbortSignal.timeout(3000) }); + if (resp.ok) { + const data = await resp.json(); + const now = Date.now(); + _configMemCache = data; + _configMemCacheTime = now; + await chrome.storage.local.set({ [CONFIG_CACHE_KEY]: { data, time: now } }); + return data; + } + } catch { /* server offline */ } + // 服务器不可达:返回过期缓存或 null + try { + const stored = await chrome.storage.local.get(CONFIG_CACHE_KEY); + return stored[CONFIG_CACHE_KEY]?.data || null; + } catch { return null; } +} + +function invalidateConfigCache() { + _configMemCache = null; + _configMemCacheTime = 0; + chrome.storage.local.remove(CONFIG_CACHE_KEY).catch(() => {}); +} + +// 动态注册额外 Discourse 域名的内容脚本 +async function registerDiscourseContentScripts(domains) { + // 先移除旧的动态注册 + try { + await chrome.scripting.unregisterContentScripts({ ids: ["x2md-discourse-extra"] }); + } catch { /* 不存在则忽略 */ } + + // 过滤掉已在 manifest 中声明的 linux.do + const extraDomains = (domains || []).filter(d => d.toLowerCase() !== "linux.do"); + if (extraDomains.length === 0) return; + + const matches = extraDomains.map(d => `https://${d}/*`); + try { + // 注:chrome.permissions.request() 在 MV3 service worker 中不可用(需要用户手势) + // 权限请求应在 options.js 中进行 + await chrome.scripting.registerContentScripts([{ + id: "x2md-discourse-extra", + matches, + js: ["dom_utils.js", "article_markdown.js", "discourse.js", "site_actions.js", "content.js"], + runAt: "document_idle", + }]); + debugLog(" 已注册额外 Discourse 域名:", extraDomains); + } catch (err) { + console.warn("[x2md] 注册 Discourse 内容脚本失败:", err); + } +} + +// 启动时从缓存或服务器获取配置并注册额外域名 +(async () => { + try { + const cfg = await getCachedConfig(); + if (cfg && cfg.discourse_domains) { + registerDiscourseContentScripts(cfg.discourse_domains); + } + } catch { /* 服务未启动,跳过 */ } +})(); const GRAPHQL_DISCOVERY_CACHE = new Map(); +const _graphqlInflight = new Map(); // 防止并发重复请求同一 origin function hasDiscoveredOperationIds(ids) { return Array.isArray(ids?.TweetDetail) && ids.TweetDetail.length > 0 || @@ -39,50 +165,64 @@ async function discoverGraphQLOperationIdsFromPage(pageUrl) { return cached; } - try { - const htmlResp = await fetch(pageUrl, { - credentials: "include", - headers: { - "x-twitter-active-user": "yes", - "x-twitter-client-language": "zh-cn", - }, - }); - if (!htmlResp.ok) { - return { TweetDetail: [], TweetResultByRestId: [] }; - } - - const html = await htmlResp.text(); - const scriptUrls = extractScriptUrlsFromHtml(html, pageUrl) - .filter((url) => url.includes("abs.twimg.com") && url.endsWith(".js")) - .sort((left, right) => { - const leftMain = left.includes("/main."); - const rightMain = right.includes("/main."); - if (leftMain === rightMain) return 0; - return leftMain ? -1 : 1; + // 如果已有并发请求在执行,等待它的结果,避免重复请求 + if (_graphqlInflight.has(cacheKey)) { + return _graphqlInflight.get(cacheKey); + } + + const fetchPromise = (async () => { + try { + const htmlResp = await fetch(pageUrl, { + credentials: "include", + headers: { + "x-twitter-active-user": "yes", + "x-twitter-client-language": "zh-cn", + }, + signal: AbortSignal.timeout(8000), }); + if (!htmlResp.ok) { + return { TweetDetail: [], TweetResultByRestId: [] }; + } - let discovered = { TweetDetail: [], TweetResultByRestId: [] }; + const html = await htmlResp.text(); + const scriptUrls = extractScriptUrlsFromHtml(html, pageUrl) + .filter((url) => url.includes("abs.twimg.com") && url.endsWith(".js")) + .sort((left, right) => { + const leftMain = left.includes("/main."); + const rightMain = right.includes("/main."); + if (leftMain === rightMain) return 0; + return leftMain ? -1 : 1; + }); + + let discovered = { TweetDetail: [], TweetResultByRestId: [] }; - for (const scriptUrl of scriptUrls) { - const scriptResp = await fetch(scriptUrl); - if (!scriptResp.ok) continue; + for (const scriptUrl of scriptUrls) { + const scriptResp = await fetch(scriptUrl, { signal: AbortSignal.timeout(5000) }); + if (!scriptResp.ok) continue; - const scriptText = await scriptResp.text(); - discovered = mergeDiscoveredOperationIds( - discovered, - extractGraphQLOperationIdsFromScriptText(scriptText), - ); + const scriptText = await scriptResp.text(); + discovered = mergeDiscoveredOperationIds( + discovered, + extractGraphQLOperationIdsFromScriptText(scriptText), + ); - if (hasDiscoveredOperationIds(discovered)) { - GRAPHQL_DISCOVERY_CACHE.set(cacheKey, discovered); - return discovered; + if (hasDiscoveredOperationIds(discovered)) { + GRAPHQL_DISCOVERY_CACHE.set(cacheKey, discovered); + return discovered; + } } + } catch (error) { + console.warn("[x2md] 自动探测 GraphQL operation id 失败:", error); } - } catch (error) { - console.warn("[x2md] 自动探测 GraphQL operation id 失败:", error); - } + return { TweetDetail: [], TweetResultByRestId: [] }; + })(); - return { TweetDetail: [], TweetResultByRestId: [] }; + _graphqlInflight.set(cacheKey, fetchPromise); + try { + return await fetchPromise; + } finally { + _graphqlInflight.delete(cacheKey); + } } // ───────────────────────────────────────────── @@ -229,20 +369,29 @@ async function fetchNoteContent(articleUrl) { resolved = true; clearTimeout(timeout); chrome.tabs.remove(tabId).catch(() => { }); - console.log(`[x2md] Note 内容提取成功:title="${result.title.slice(0, 30)}" 长度=${result.content.length}`); + debugLog(`Note 内容提取成功:title="${result.title.slice(0, 30)}" 长度=${result.content.length}`); resolve(result); } ); }; - // 等待 tab 完成加载后开始轮询 - chrome.tabs.onUpdated.addListener(function listener(changedTabId, info) { - if (changedTabId === tabId && info.status === 'complete') { + // 等待 tab 完成加载后开始轮询(含超时自动清理防止泄漏) + const tabTimeout = setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + if (!resolved) poll(); // 超时后仍尝试轮询一次(但先检查 resolved 防止竞态) + }, 12000); + function listener(changedTabId, info) { + if (changedTabId === tabId && (info.status === 'complete' || info.status === 'error')) { chrome.tabs.onUpdated.removeListener(listener); - // 等额外 1.5 秒等 CSR 渲染 - setTimeout(poll, 1500); + clearTimeout(tabTimeout); + if (info.status === 'complete') { + setTimeout(poll, 1500); + } else { + poll(); // error 状态也尝试一次 + } } - }); + } + chrome.tabs.onUpdated.addListener(listener); } ); }); @@ -293,6 +442,7 @@ async function fetchViaGraphQL(tweetId, options = {}) { const resp = await fetch(url, { credentials: "include", headers, + signal: AbortSignal.timeout(10000), }); if (!resp.ok) { @@ -333,6 +483,28 @@ async function fetchViaGraphQL(tweetId, options = {}) { mainParsed.thread_tweets = threadParsed; mainParsed._graphql_source = `${plan.operationName}:${plan.operationId}`; + + // ── 收集非作者回复(评论功能)── + if (authorRestId && allTweets.length > 0) { + const replyTweets = allTweets.filter((result) => + result.core?.user_results?.result?.rest_id !== authorRestId + ); + if (replyTweets.length > 0) { + mainParsed._replyTweets = replyTweets.map((rt, idx) => { + const userLegacy = rt.core?.user_results?.result?.legacy; + const parsed = parseLegacyTweet(rt, userLegacy); + return { + floor: idx + 2, + author: parsed?.author || userLegacy?.name || "匿名", + handle: parsed?.handle || (userLegacy?.screen_name ? `@${userLegacy.screen_name}` : ""), + content: parsed?.text || "", + published: parsed?.published || "", + images: parsed?.images || [], + }; + }); + } + } + return mainParsed; } @@ -417,6 +589,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)), @@ -425,6 +610,7 @@ function parseLegacyTweet(result, userLegacy) { author, handle, published, + hashtags, }; } @@ -434,7 +620,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(); @@ -465,7 +651,7 @@ async function fetchFullTweetData(tweetData) { if (!match) return tweetData; const tweetId = match[1]; - console.log("[x2md] 开始获取完整推文:", tweetId); + debugLog(" 开始获取完整推文:", tweetId); // 策略1:GraphQL API let apiResult = await fetchViaGraphQL(tweetId, { @@ -475,16 +661,16 @@ async function fetchFullTweetData(tweetData) { // 策略2:oEmbed(GraphQL 失败时) if (!apiResult || !apiResult.text) { - console.log("[x2md] GraphQL 失败,尝试 oEmbed"); + debugLog(" GraphQL 失败,尝试 oEmbed"); apiResult = await fetchViaOEmbed(tweetData.url); } if (!apiResult) { - console.log("[x2md] 所有 API 均失败,使用 DOM 原始数据"); + debugLog(" 所有 API 均失败,使用 DOM 原始数据"); return tweetData; } - console.log(`[x2md] API 获取成功:text=${apiResult.text.slice(0, 50)} images=${apiResult.images.length}`); + debugLog(`API 获取成功:text=${apiResult.text.slice(0, 50)} images=${apiResult.images.length}`); return { ...tweetData, @@ -500,19 +686,960 @@ 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`); + } + + // 上传图片到飞书(最多10张,避免超时) + const imageTokens = []; + const images = Array.isArray(data.images) ? data.images.slice(0, 10) : []; + for (let i = 0; i < images.length; i++) { + try { + const imgUrl = images[i]; + if (!imgUrl || !imgUrl.startsWith("http")) continue; + 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]; + const imgName = `${sanitizeFeishuFilename(title)}_img_${i + 1}${ext.startsWith(".") ? ext : "." + ext}`; + const imgToken = await uploadFeishuFile(baseUrl, token, imgBlob, imgName); + if (imgToken) imageTokens.push({ file_token: imgToken }); + } catch (_) { /* 单张图片失败不影响整体 */ } + } + + // 构建字段(与 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.platform || (data.type === "article" ? "文章" : data.type === "thread" ? "Thread" : "推文"), + "标签": tags.join("、"), + "保存时间": Date.now(), + "评论数": commentCount, + }; + if (mdToken) fields["附件"] = [{ file_token: mdToken }]; + if (htmlToken) fields["HTML附件"] = [{ file_token: htmlToken }]; + if (imageTokens.length > 0) fields["图片"] = imageTokens; + + 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" }; + } +} + +// 飞书字段类型映射 +const FEISHU_FIELD_TYPES = { + TEXT: 1, // 文本 + NUMBER: 2, // 数字 + SELECT: 3, // 单选 + DATE: 5, // 日期 + URL: 15, // 超链接 + ATTACHMENT: 17, // 附件 +}; + +// 飞书多维表格必需字段(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.TEXT, desc: "文本" }, + { name: "标签", type: FEISHU_FIELD_TYPES.TEXT, desc: "文本" }, + { name: "保存时间", type: FEISHU_FIELD_TYPES.DATE, desc: "日期" }, + { name: "评论数", type: FEISHU_FIELD_TYPES.NUMBER, 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 existingFields = result.data?.items || []; + const fieldMap = {}; + existingFields.forEach(f => { + fieldMap[f.field_name] = { type: f.type, typeName: getFeishuFieldTypeName(f.type) }; + }); + + // 步骤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 = []; + + requiredFields.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 }; + } +} + +// ═══════════════════════════════════════════════ +// 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 || "推文" } }; + 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; +} + +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; } + + // 视频占位符 [MEDIA_VIDEO_URL:xxx] → video 或 embed 块 + const videoPlaceholder = line.match(/^\[MEDIA_VIDEO_URL:(https?:\/\/[^\]]+)\]$/); + if (videoPlaceholder) { + const vUrl = videoPlaceholder[1]; + if (/\.(mp4|webm|mov)(\?|$)/i.test(vUrl)) { + blocks.push({ type: "video", video: { type: "external", external: { url: vUrl } } }); + } else { + blocks.push({ type: "embed", embed: { url: vUrl } }); + } + 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; + } + // Checkbox / Todo: - [x] 或 - [ ] + const todoMatch = line.match(/^[-*+] \[([ xX])\] (.+)/); + if (todoMatch) { + blocks.push({ type: "to_do", to_do: { rich_text: parseNotionRichText(todoMatch[2]), checked: todoMatch[1].toLowerCase() === "x" } }); 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) => { + const ext = filename.split(".").pop().toLowerCase(); + if (["mp4", "webm", "mov"].includes(ext)) return `

[视频: ${filename}]

`; + if (["mp3", "wav", "ogg", "m4a"].includes(ext)) return `

[音频: ${filename}]

`; + return `

[图片: ${filename}]

`; + }); + // 代码块(```lang ... ```)—— 先修复未关闭的代码块 + const openFences = (html.match(/```/g) || []).length; + if (openFences % 2 !== 0) html += "\n```"; + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { + const escaped = code.replace(/&/g, "&").replace(//g, ">"); + return `
${escaped}
`; + }); + // 行内代码 + html = html.replace(/`([^`\n]+)`/g, (_, code) => `${code.replace(/`); + // 图片(必须在链接之前) + html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + // 链接 + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + // 标题(h1-h6,从 h6 向上匹配避免误匹配) + html = html.replace(/^######\s+(.+)$/gm, "
$1
"); + html = html.replace(/^#####\s+(.+)$/gm, "
$1
"); + html = html.replace(/^####\s+(.+)$/gm, "

$1

"); + html = html.replace(/^###\s+(.+)$/gm, "

$1

"); + html = html.replace(/^##\s+(.+)$/gm, "

$1

"); + html = html.replace(/^#\s+(.+)$/gm, "

$1

"); + // 粗体 / 斜体 / 删除线 + html = html.replace(/\*\*(.+?)\*\*/g, "$1"); + html = html.replace(/\*(.+?)\*/g, "$1"); + html = html.replace(/~~(.+?)~~/g, "$1"); + // 引用块(逐行标记再合并) + html = html.replace(/^>\s+(.+)$/gm, "
$1
"); + html = html.replace(/<\/blockquote>\n
/g, "
"); + // 任务列表(Obsidian 格式:- [x] / - [ ]) + html = html.replace(/^[-*+]\s+\[x\]\s+(.+)$/gm, '
  • $1
  • '); + html = html.replace(/^[-*+]\s+\[ \]\s+(.+)$/gm, '
  • $1
  • '); + // 有序列表(必须在无序列表之前,防止
  • 被无序列表二次匹配) + html = html.replace(/^(\d+)\.\s+(.+)$/gm, '
  • $2
  • '); + // 无序列表 + html = html.replace(/^[-*+]\s+(.+)$/gm, "
  • $1
  • "); + // 包裹连续
    • (区分有序和无序) + html = html.replace(/((?:
    • .*<\/li>\n?)+)/g, (match) => { + return "
        " + match.replace(/ data-ol="\d+"/g, "") + "
      "; + }); + html = html.replace(/((?:].*<\/li>\n?)+)/g, (match) => { + if (match.startsWith("
        ")) return match; // 已处理的有序列表 + return "
          " + match + "
        "; + }); + // 分割线 + html = html.replace(/^---+$/gm, "
        "); + // 表格 + html = html.replace(/^(\|.+\|)\n\|[\s:|-]+\|\n((?:\|.+\|\n?)*)/gm, (_, headerRow, bodyRows) => { + const headers = headerRow.split("|").filter(c => c.trim()).map(c => `${c.trim()}`).join(""); + const rows = bodyRows.trim().split("\n").filter(Boolean).map(row => { + const cells = row.split("|").filter(c => c.trim()).map(c => `${c.trim()}`).join(""); + return `${cells}`; + }).join(""); + return `${headers}${rows}
        `; + }); + // iframe 保留(已有 HTML 标签行不包裹

        ) + // 段落:非空非 HTML 标签行包裹

        + html = html.replace(/^(?!<[a-z/]|$)(.+)$/gm, "

        $1

        "); + // 清理多余空行 + html = html.replace(/\n{3,}/g, "\n\n"); + return html; +} + +// ═══════════════════════════════════════════════ +// HTML 文件导出(base64 data URL,Service Worker 兼容) +// ═══════════════════════════════════════════════ +async function handleDownloadHtml(data) { + try { + const rawContent = data.article_content || data.text || ""; + // 将 Markdown 转换为 HTML(而非直接塞入 body) + const bodyHtml = convertMarkdownToHtml(rawContent); + const title = data.article_title || data.title || "untitled"; + const safeTitle = title.replace(/&/g, "&").replace(//g, ">"); + const safeUrl = (data.url || "").replace(/&/g, "&").replace(/"/g, """); + const safeAuthor = (data.author || "").replace(/&/g, "&").replace(/ + + +${safeTitle} + + +

        ${safeTitle}

        +
        +

        来源:${safeUrl}

        +${safeAuthor ? `

        作者:${safeAuthor}

        ` : ""} +${data.published ? `

        日期:${data.published}

        ` : ""} +
        +
        +${bodyHtml} +`; + + const base64 = btoa(unescape(encodeURIComponent(fullHtml))); + // Chrome data URL 下载上限约 2MB,超过则改用 Blob URL + const DATA_URL_LIMIT = 2 * 1024 * 1024; + let downloadUrl; + if (base64.length > DATA_URL_LIMIT) { + // Service Worker 中没有 URL.createObjectURL,降级为截断警告 + // 实际场景中 500KB Markdown(convertMarkdownToHtml 已截断)生成的 HTML base64 约 700KB-1MB,一般不会超 + return { success: false, error: "HTML 内容过大(超过 2MB),无法导出为文件", target: "html" }; + } + downloadUrl = `data:text/html;base64,${base64}`; + + return new Promise(resolve => { + chrome.downloads.download({ url: downloadUrl, filename, saveAs: false, conflictAction: "overwrite" }, downloadId => { + if (chrome.runtime.lastError) resolve({ success: false, error: chrome.runtime.lastError.message, target: "html" }); + else resolve({ success: true, downloadId, target: "html" }); + }); + }); + } catch (err) { + return { success: false, error: err.message, target: "html" }; + } +} + +// ═══════════════════════════════════════════════ +// 多目标保存分发(save_tweet / force_save_tweet 共用) +// ═══════════════════════════════════════════════ +async function dispatchMultiTargetSave(data, serverBase, existingCfg = null) { + // 获取完整配置(如果调用方已获取过,则复用,避免重复请求) + let cfg = existingCfg || {}; + if (!existingCfg) { + try { + 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"); + if (syncData.x2md_sync) Object.assign(cfg, syncData.x2md_sync); + } catch {} + } catch {} + } + + const saveResults = []; + const saveToObsidian = cfg.save_to_obsidian !== false; + const saveToFeishu = !!cfg.save_to_feishu; + const saveToNotion = !!cfg.save_to_notion; + const exportHtml = !!cfg.export_html; + + // 边界:所有保存目标都未启用 + if (!saveToObsidian && !saveToFeishu && !saveToNotion && !exportHtml) { + return { success: false, results: [], error: "未启用任何保存目标,请在设置中至少开启一个(Obsidian/飞书/Notion/HTML)" }; + } + + // 1. Obsidian + if (saveToObsidian) { + try { + const resp = await fetch(`${serverBase}/save`, { + 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 }); + } catch (obsErr) { + saveResults.push({ target: "obsidian", success: false, error: obsErr.message }); + } + } + + // 2. 补全 markdown / htmlContent 字段(在浅拷贝上操作,不污染调用方的 data) + const enriched = { ...data }; + const markdownContent = enriched.article_content || enriched.text || ""; + if (!enriched.markdown && markdownContent) enriched.markdown = markdownContent; + if (!enriched.htmlContent && markdownContent) enriched.htmlContent = convertMarkdownToHtml(markdownContent); + + // 3. 飞书 / Notion / HTML 并行(使用 enriched 确保含 markdown/htmlContent) + const parallelTasks = []; + if (saveToFeishu && cfg.feishu_app_id && cfg.feishu_app_secret) { + parallelTasks.push( + handleSaveToFeishu({ + ...enriched, + feishu_app_id: cfg.feishu_app_id, + feishu_app_secret: cfg.feishu_app_secret, + feishu_app_token: cfg.feishu_app_token, + feishu_table_id: cfg.feishu_table_id, + feishu_api_domain: cfg.feishu_api_domain, + feishu_upload_md: cfg.feishu_upload_md, + feishu_upload_html: cfg.feishu_upload_html, + }).catch(e => ({ success: false, error: e.message, target: "feishu" })) + ); + } + if (saveToNotion && cfg.notion_token && cfg.notion_database_id) { + parallelTasks.push( + handleSaveToNotion({ + ...enriched, + notion_token: cfg.notion_token, + notion_database_id: cfg.notion_database_id, + notion_prop_title: cfg.notion_prop_title, + notion_prop_url: cfg.notion_prop_url, + notion_prop_author: cfg.notion_prop_author, + 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" })) + ); + } + if (exportHtml) { + parallelTasks.push( + handleDownloadHtml({ ...enriched, html_export_folder: cfg.html_export_folder || "X2MD导出" }) + .catch(e => ({ success: false, error: e.message, target: "html" })) + ); + } + + if (parallelTasks.length > 0) { + saveResults.push(...await Promise.all(parallelTasks)); + } + + // 汇总 + const anySuccess = saveResults.some(r => r.success); + const errors = saveResults.filter(r => !r.success); + return { + success: anySuccess, + results: saveResults, + error: errors.length > 0 ? errors.map(e => `[${e.target}] ${e.error}`).join("; ") : undefined, + }; +} + // ───────────────────────────────────────────── // 消息处理 // ───────────────────────────────────────────── chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + // 安全校验:仅接受来自本扩展的消息(content scripts 或 popup/options 页面) + if (!sender || (sender.id !== chrome.runtime.id)) { + sendResponse({ success: false, error: "Unauthorized sender" }); + return false; + } if (message.action === "save_tweet") { (async () => { + const serverBase = SERVER_BASE; // 快照,防止并发修改 + // 分阶段超时保护: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 整体超时(90s),强制返回错误"); + safeSendResponse({ success: false, error: "操作超时,请检查网络连接和本地服务是否正常运行" }); + }, OVERALL_TIMEOUT); try { let data = message.data; if (data.type === "note" && data.note_article_url) { // 长文 Note:后台打开 tab,等待渲染后提取完整内容 - console.log("[x2md] 处理 Note(后台 tab 方案):", data.note_article_url); + debugLog(" 处理 Note(后台 tab 方案):", data.note_article_url); // 【关键修复】即使是解析长文,也必须对母推文本身调用 GraphQL 获取其自带的高清视频或原图,防止富媒体遗失! let enrichedData = await fetchFullTweetData(data); @@ -533,6 +1660,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { article_title: noteResult.title || enrichedData.text?.slice(0, 50) || "Note", article_content: noteResult.content, images: mergedImages, + videos: [...(enrichedData.videos || []), ...(noteResult.videos || [])], url: enrichedData.url, // 保留 /status/ 链接作为源 }; } else { @@ -553,7 +1681,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const articleMatch = data.text && data.text.match(/https?:\/\/(x|twitter)\.com\/(i\/article|[^/]+\/article)\/\d+/i); if (articleMatch) { const articleUrl = articleMatch[0].replace("twitter.com", "x.com"); - console.log("[x2md] 在推文提取文本中发现长文(Note)链接,切换提取模式:", articleUrl); + debugLog(" 在推文提取文本中发现长文(Note)链接,切换提取模式:", articleUrl); const noteResult = await fetchNoteContent(articleUrl); if (noteResult && noteResult.content) { @@ -583,6 +1711,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { article_title: noteResult.title || (data.text ? data.text.trim().split('\n')[0].replace(/https?:\/\/\S+/g, '').replace(/[\n\t]/g, '').slice(0, 50).trim() : "Untitled"), article_content: prefix + noteResult.content, images: mergedImages, + videos: [...(data.videos || []), ...(noteResult.videos || [])], }; } else { data.text = data.text + `\n\n📔 完整长文:${articleUrl}`; @@ -593,7 +1722,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // --------- 统一填补长文专栏中的遗留视频占位符 --------- let contentToFix = data.article_content || data.content || ""; if (contentToFix.includes("[[VIDEO_HOLDER_")) { - console.log("[x2md] 检测到长文包含未解析的视频占位符,尝试通过 GraphQL 兜底获取"); + debugLog(" 检测到长文包含未解析的视频占位符,尝试通过 GraphQL 兜底获取"); let apiData = data; if (!data._api_fetched && data.url && data.url.includes("/status/")) { @@ -602,7 +1731,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { let finalVideos = data.videos || []; if (apiData.videos) finalVideos.push(...apiData.videos); - if (data.noteResultVideos) finalVideos.push(...data.noteResultVideos); + // noteResult.videos 已在上方合并到 data.videos,无需额外处理 const filledContent = fillArticleVideoPlaceholders(contentToFix, finalVideos); const extractedVideoUrls = Array.from( @@ -626,15 +1755,24 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } // ---------------------------------------------------- - // --------- 视频时长检测与二次确认拦截 --------- + // --------- 获取服务端配置 + 同步配置合并 --------- let enableVideoDownload = true; let durationThresholdMin = 5; + let cfg = {}; try { - const cfgResp = await fetch(`${SERVER_BASE}/config`); - const cfg = await cfgResp.json(); + 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 中的扩展配置 + try { + const syncData = await chrome.storage.sync.get("x2md_sync"); + if (syncData.x2md_sync) { + Object.assign(cfg, syncData.x2md_sync); + } + } catch {} enableVideoDownload = cfg.enable_video_download !== false; durationThresholdMin = cfg.video_duration_threshold || 5; - } catch (e) { console.warn("[x2md] 获取配置失败,使用默认视频设置", e); } + } catch (e) { console.warn("[x2md] 获取配置失败,使用默认设置", e); } const allVideos = [...(data.videos || [])]; const allDurations = [...(data.videoDurations || [])]; @@ -651,8 +1789,8 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const maxDurationMin = maxDurationMs / 1000 / 60; if (maxDurationMin > durationThresholdMin) { - console.log(`[x2md] 发现超长视频 (${maxDurationMin.toFixed(1)} > ${durationThresholdMin} min),要求前台确认`); - sendResponse({ + debugLog(`发现超长视频 (${maxDurationMin.toFixed(1)} > ${durationThresholdMin} min),要求前台确认`); + safeSendResponse({ require_video_confirm: true, durationMin: maxDurationMin.toFixed(1), payload: data @@ -664,43 +1802,56 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } else if (!enableVideoDownload) { data.download_video = false; } else if (data.video_confirmed) { - // 如果二次确认识别到强制保留 - // 注意由于 confirm 环节 content.js 会带过来用户的选择赋值 - // 即有可能是 true 或 false ,无需再动 + // 二次确认:content.js 会设置 download_video,但防御性兜底 + if (data.download_video === undefined) data.download_video = true; } else { // 如果本身就不含任何视频(但为了防止键位缺失赋予默认状态) data.download_video = true; } // --------------------------------------------- - const resp = await fetch(`${SERVER_BASE}/save`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data) - }); - const json = await resp.json(); - sendResponse({ success: json.success !== false, result: json }); + // ── 注入评论/回复数据(如果配置开启且有回复)── + if (cfg.enable_comments && data._replyTweets && data._replyTweets.length > 0) { + data.comments = data._replyTweets; + delete data._replyTweets; + } else { + delete data._replyTweets; + } + + // ── 多目标保存分发(复用共享函数,传入已获取的 cfg 避免重复请求)── + const dispatchResult = await dispatchMultiTargetSave(data, serverBase, cfg); + safeSendResponse(dispatchResult); } catch (err) { console.error("[x2md] 后台处理或请求失败:", err); - sendResponse({ success: false, error: err.message || String(err) }); + safeSendResponse({ success: false, error: err.message || String(err) }); } })(); - // 必须返回 true 以保持消息通道开启,否则 async 块内部的 sendResponse 将因为通道关闭而失败,产生 content 侧一直 Loading return true; } if (message.action === "force_save_tweet") { (async () => { + const serverBase = SERVER_BASE; // 快照 try { const data = message.data; - const resp = await fetch(`${SERVER_BASE}/save`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data) - }); - const json = await resp.json(); - sendResponse({ success: json.success !== false, result: json }); + + // 填充遗留的视频占位符(与 save_tweet 保持一致) + let contentToFix = data.article_content || data.content || ""; + if (contentToFix.includes("[[VIDEO_HOLDER_")) { + const filledContent = fillArticleVideoPlaceholders(contentToFix, data.videos || []); + const extractedVideoUrls = Array.from( + filledContent.matchAll(/\[MEDIA_VIDEO_URL:(.+?)\]/g), + (match) => match[1], + ); + if (data.article_content) data.article_content = filledContent; + else data.content = filledContent; + data.videos = Array.from(new Set([...(data.videos || []), ...extractedVideoUrls])); + } + + // 多目标保存分发(与 save_tweet 一致,由共享函数自行获取配置) + const dispatchResult = await dispatchMultiTargetSave(data, serverBase); + sendResponse(dispatchResult); } catch (err) { sendResponse({ success: false, error: err.message || String(err) }); } @@ -711,8 +1862,22 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "get_config") { (async () => { try { - const resp = await fetch(`${SERVER_BASE}/config`); - 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) { + const synced = syncData.x2md_sync; + for (const k of SYNC_FIELDS) { + if (synced[k] !== undefined) cfg[k] = synced[k]; + } + cfg.sync_enabled = true; + } + // 缓存端口到 local storage,下次启动时无需先请求服务即可使用正确端口 + if (cfg.port) { + 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 }); @@ -723,13 +1888,36 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.action === "update_config") { (async () => { + const serverBase = SERVER_BASE; // 快照 try { - const resp = await fetch(`${SERVER_BASE}/config`, { + 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 + if (message.config.sync_enabled) { + 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}`; + } + // 更新 Discourse 域名动态注册 + 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 }); @@ -738,10 +1926,38 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; } + if (message.action === "reset_config") { + (async () => { + try { + 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(); + sendResponse({ success: json.success !== false }); + } catch (err) { + sendResponse({ success: false, error: err.message }); + } + })(); + 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 () => { + const serverBase = SERVER_BASE; // 快照 try { - const resp = await fetch(`${SERVER_BASE}/ping`, { + const resp = await fetch(`${serverBase}/ping`, { signal: AbortSignal.timeout(2000) }); const json = await resp.json(); @@ -752,4 +1968,69 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { })(); return true; } + + // ── 飞书连接测试 ── + if (message.action === "test_feishu") { + (async () => { + try { + const result = await handleTestFeishu(message.data || {}); + sendResponse(result); + } catch (err) { + sendResponse({ success: false, error: err.message }); + } + })(); + return true; + } + + // ── Notion 连接测试 ── + if (message.action === "test_notion") { + (async () => { + try { + const result = await handleTestNotion(message.data || {}); + sendResponse(result); + } catch (err) { + sendResponse({ success: false, error: err.message }); + } + })(); + return true; + } + + // ── 保存到飞书 ── + if (message.action === "save_to_feishu") { + (async () => { + try { + const result = await handleSaveToFeishu(message.data || {}); + sendResponse(result); + } catch (err) { + sendResponse({ success: false, error: err.message, target: "feishu" }); + } + })(); + return true; + } + + // ── 保存到 Notion ── + if (message.action === "save_to_notion") { + (async () => { + try { + const result = await handleSaveToNotion(message.data || {}); + sendResponse(result); + } catch (err) { + sendResponse({ success: false, error: err.message, target: "notion" }); + } + })(); + return true; + } + + // ── HTML 文件导出 ── + if (message.action === "download_html") { + (async () => { + try { + const result = await handleDownloadHtml(message.data || {}); + sendResponse(result); + } catch (err) { + sendResponse({ success: false, error: err.message, target: "html" }); + } + })(); + return true; + } }); diff --git a/extension/content.js b/extension/content.js index e9ab625..80d3c1a 100644 --- a/extension/content.js +++ b/extension/content.js @@ -9,6 +9,10 @@ * 完整内容(解决"显示更多"截断、图片丢失)由 background.js 处理。 */ +// 调试日志开关 +const X2MD_DEBUG = false; +function debugLog(...args) { if (X2MD_DEBUG) console.log("[x2md]", ...args); } + // ───────────────────────────────────────────── // 图片辅助(用于 DOM 级后备数据) // ───────────────────────────────────────────── @@ -276,7 +280,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$')); @@ -429,27 +448,53 @@ let runtimeConfig = null; function requestRuntimeConfig() { if (runtimeConfig) { ensureFloatingSaveButton(); + ensureFloatingCopyButton(); return; } chrome.runtime.sendMessage({ action: "get_config" }, (resp) => { runtimeConfig = resp?.success ? (resp.config || {}) : {}; + // 初始化 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(); }); } -function captureLinuxDoPostElement(post) { +// 重复保存保护:记录本次会话已保存的 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; + } + + // 判断点赞的是主帖(#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, }); @@ -459,9 +504,44 @@ function captureLinuxDoPostElement(post) { return; } - console.log("[x2md] LINUX DO 帖子:", { url: data.url, author: data.author, title: data.article_title }); - showToast("正在保存 LINUX DO 帖子…", "loading", null); + // 通过 Discourse JSON API 获取回复数据 + 帖子标签 + const topicMatch = location.pathname.match(/^\/t\/[^/]+\/(\d+)/); + if (topicMatch) { + try { + const replyResult = await fetchDiscourseReplies(topicMatch[1], location.hostname); + const allReplies = replyResult.replies || []; + // 提取 topic 级别标签 + if (replyResult.topicTags && replyResult.topicTags.length > 0) { + data.tags = replyResult.topicTags; + } + if (allReplies.length > 0) { + if (clickedPostNumber <= 1) { + // 点赞主帖:附带全部评论 + data.comments = allReplies; + debugLog(`主帖保存,附带 ${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; + debugLog(`评论 #${clickedPostNumber} 保存,附带 ${repliesToComment.length} 条回复`); + } + } + } catch (e) { + console.warn("[x2md] 获取 Discourse 回复失败:", e); + } + } + + const siteName = location.hostname === "linux.do" ? "LINUX DO" : location.hostname; + debugLog(`${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) { @@ -480,56 +560,72 @@ 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,跳过滚动收集"); + debugLog(" 未找到可滚动容器,跳过滚动收集"); return null; } - const seen = new Map(); // blockId -> DOM element (clone) + const seen = new Map(); 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; - // 克隆节点以保留 DOM 状态(虚拟渲染会回收原节点) - 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)); + } } } - 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(); + const maxScroll = container.scrollHeight - container.clientHeight; + if (container.scrollTop >= maxScroll - 10) { + stuckCount++; + await new Promise((r) => setTimeout(r, 150)); + 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; + debugLog(`飞书滚动收集完成:${seen.size} unique blocks`); - console.log(`[x2md] 飞书滚动收集完成:${seen.size} unique blocks`); - - // 按 blockId 排序返回(blockId 在飞书中通常是递增的) const sorted = Array.from(seen.entries()) .sort((a, b) => { const na = parseInt(a[0], 10); @@ -543,30 +639,90 @@ async function scrollAndCollectFeishuBlocks() { } function captureFeishuDocument() { - showToast("正在滚动页面加载全部内容…", "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; + } - scrollAndCollectFeishuBlocks().then((collectedBlocks) => { - const pageUrl = location.href; - const options = { pageUrl }; + // ─── docx / wiki 文档:三策略流程(已修复优先级) ─── - let articleContent; - if (collectedBlocks && collectedBlocks.length > 0) { - articleContent = extractFeishuMarkdownFromBlocks(collectedBlocks, options); - } + showToast("正在通过 API 读取飞书文档…", "loading", null); - // 如果滚动收集失败或结果不佳,回退到直接提取 - if (!articleContent || articleContent.length < 50) { - const data = extractFeishuDocumentData(document, options); - if (!data || !data.article_content.trim()) { - showToast("飞书文档提取失败", "error", 4000); - return; + // 策略1:通过飞书内部 JSON API 直接读取(最完整,不受滚动/权限影响) + fetchFeishuDocViaApi(pageUrl).then((apiData) => { + if (apiData) { + const apiContent = convertFeishuApiDataToMarkdown(apiData); + if (apiContent && apiContent.length >= 50) { + debugLog(" Feishu 文档(JSON API 读取成功,长度=" + apiContent.length + ")"); + return finishFeishuCapture(apiContent); } - console.log("[x2md] Feishu 文档(直接提取):", { url: data.url, title: data.article_title }); - showToast("正在保存飞书文档…", "loading", null); - sendToBackground(data); - return; + debugLog(" Feishu JSON API 返回内容太短(" + (apiContent?.length || 0) + "),降级到滚动收集"); + } else { + debugLog(" Feishu JSON API 不可用(可能权限限制),降级到滚动收集"); } + // 策略2(原策略3):直接跳到滚动收集 — 跳过 DOM 直读 + // 原因:DOM 直读受虚拟滚动限制,只能拿到一屏内容,极易误判为"成功" + showToast("正在滚动页面加载全部内容…", "loading", null); + scrollAndCollectFeishuBlocks().then((collectedBlocks) => { + let scrollContent = null; + if (collectedBlocks && collectedBlocks.length > 0) { + scrollContent = extractFeishuMarkdownFromBlocks(collectedBlocks, options); + } + + if (scrollContent && scrollContent.length >= 50) { + debugLog(" Feishu 文档(滚动收集 " + collectedBlocks.length + " blocks,长度=" + scrollContent.length + ")"); + return finishFeishuCapture(scrollContent); + } + + // 策略3(兜底):如果滚动收集也失败了,尝试 DOM 直读(聊胜于无) + debugLog(" 滚动收集失败,最后尝试 DOM 直读"); + const domData = extractFeishuDocumentData(document, options); + if (domData && domData.article_content && domData.article_content.length >= 30) { + debugLog(" Feishu 文档(DOM 兜底提取,长度=" + domData.article_content.length + ")"); + return finishFeishuCapture(domData.article_content); + } + + showToast("飞书文档提取失败", "error", 4000); + }).catch((err) => { + console.error("[x2md] Feishu 滚动收集出错:", err); + showToast("飞书文档提取失败", "error", 4000); + }); + }).catch((err) => { + console.error("[x2md] Feishu API 读取出错:", err); + showToast("飞书文档提取失败", "error", 4000); + }); + + function finishFeishuCapture(articleContent) { const title = extractFeishuTitle(document); const author = extractFeishuAuthor(document); const data = { @@ -580,13 +736,12 @@ function captureFeishuDocument() { article_content: articleContent, images: [], videos: [], - platform: "Feishu", + platform: "飞书", }; - console.log("[x2md] Feishu 文档(滚动收集 " + collectedBlocks.length + " blocks):", { url: data.url, title }); showToast("正在保存飞书文档…", "loading", null); sendToBackground(data); - }); + } } function captureWechatArticle() { @@ -596,13 +751,83 @@ function captureWechatArticle() { return; } - console.log("[x2md] WeChat 文章:", { url: data.url, author: data.author, title: data.article_title }); + debugLog(" WeChat 文章:", { url: data.url, author: data.author, title: data.article_title }); showToast("正在保存微信公众号文章…", "loading", null); 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 || {}); + + // 检测是否有实际消息内容(包含"**作者**"格式或至少有非空内容行) + 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", + 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); + 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); + } else { + showToast("聊天记录提取失败。请确保已打开一个聊天会话。", "error", 5000); + } + }).catch((err) => { + console.error("[x2md] Feishu Chat 提取出错:", err); + // 最后兜底尝试 DOM + const domData = extractFeishuChatFromDOM(document); + 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 { + showToast("聊天记录提取失败", "error", 4000); + } + }); +} + function handleFloatingSave(siteKey) { - if (siteKey === "linux_do") { + if (siteKey === "linux_do" || siteKey === "discourse") { captureLinuxDoPostElement(findCurrentLinuxDoPost()); return; } @@ -610,6 +835,10 @@ function handleFloatingSave(siteKey) { captureFeishuDocument(); return; } + if (siteKey === "feishu_chat") { + captureFeishuChat(); + return; + } if (siteKey === "wechat") { captureWechatArticle(); } @@ -680,14 +909,14 @@ function captureAndSend(btn) { // ── Note 长文推文检测 ───────────────────────────── const noteArticleUrl = detectNoteUrl(article); if (noteArticleUrl) { - console.log("[x2md] 检测到 Note 推文,文章链接:", noteArticleUrl); + debugLog(" 检测到 Note 推文,文章链接:", noteArticleUrl); showToast("检测到长文 Note,正在提取内容…", "loading", null); try { // 优先:在当前页面查找内嵌文章内容(推文详情页会渲染 Note 预览) const inlineArticle = detectAndExtractArticle(); if (inlineArticle && inlineArticle.article_content && inlineArticle.article_content.trim().length > 50) { - console.log("[x2md] 当前页面已有内嵌文章内容,直接保存"); + debugLog(" 当前页面已有内嵌文章内容,直接保存"); sendToBackground({ ...inlineArticle, url: tweetUrl, // 用原始推文链接(/status/xxx)作为源 @@ -695,6 +924,7 @@ function captureAndSend(btn) { handle: inlineArticle.handle || handle, published: inlineArticle.published || published, images: inlineArticle.images, // 透传已经过去重的剩余外部图 + platform: "Twitter/X", graphql_operation_ids: inlineArticle.graphql_operation_ids || extractDiscoveredGraphQLOperationIds(), }); return; @@ -705,7 +935,7 @@ function captureAndSend(btn) { } // 降级:让 background 尝试 fetch(可能得到空壳),最终降级为摘要+链接 - console.log("[x2md] 当前页面无内嵌内容,由 background 处理"); + debugLog(" 当前页面无内嵌内容,由 background 处理"); sendToBackground({ type: "note", url: tweetUrl, @@ -713,6 +943,7 @@ function captureAndSend(btn) { author, handle, published, images, text: extractTweetTextBasic(article), thread_tweets: [], + platform: "Twitter/X", graphql_operation_ids: extractDiscoveredGraphQLOperationIds(), }); return; @@ -722,7 +953,7 @@ function captureAndSend(btn) { const text = extractTweetTextBasic(article); const thread_tweets = extractThreadBasic(article, handle); - console.log("[x2md] 普通推文:", { handle, url: tweetUrl, text: text.slice(0, 40) }); + debugLog(" 普通推文:", { handle, url: tweetUrl, text: text.slice(0, 40) }); sendToBackground({ author, handle, @@ -732,11 +963,16 @@ function captureAndSend(btn) { images, thread_tweets, type: "tweet", + platform: "Twitter/X", graphql_operation_ids: extractDiscoveredGraphQLOperationIds(), }); } 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); @@ -763,7 +999,40 @@ function sendToBackground(data) { } function handleSaveResponse(resp) { - if (resp && resp.success) { + if (!resp) { showToast("保存失败:无响应", "error", 5000); return; } + + // 多目标格式(V1.5+) + if (Array.isArray(resp.results)) { + const successes = resp.results.filter(r => r.success); + const failures = resp.results.filter(r => !r.success); + const targetNames = { obsidian: "Obsidian", feishu: "飞书", notion: "Notion", html: "HTML" }; + + if (successes.length > 0) { + const names = successes.map(r => targetNames[r.target] || r.target).join("、"); + let detail = ""; + // Obsidian 文件名 + const obsResult = successes.find(r => r.target === "obsidian"); + if (obsResult?.result?.saved?.[0]) { + const parts = obsResult.result.saved[0].split("/"); + let savedName = parts[parts.length - 1].replace(/\.md$/, ""); + if (savedName.length > 28) savedName = savedName.slice(0, 28) + "…"; + detail = `\n📄 ${savedName}`; + } + let msg = `已保存到 ${names}${detail}`; + if (failures.length > 0) { + const failNames = failures.map(r => targetNames[r.target] || r.target).join("、"); + msg += `\n⚠️ ${failNames} 失败`; + } + showToast(msg, failures.length > 0 ? "warning" : "success", 5000); + } else { + const errMsg = failures.map(r => `[${targetNames[r.target] || r.target}] ${r.error || "未知"}`).join("; "); + showToast(`保存失败:${String(errMsg).slice(0, 80)}`, "error", 5000); + } + return; + } + + // 旧格式(单目标,向后兼容) + if (resp.success) { let savedName = ""; if (resp.result?.saved?.[0]) { const parts = resp.result.saved[0].split("/"); @@ -783,6 +1052,7 @@ function handleSaveResponse(resp) { const TOAST_COLORS = { loading: { bg: "#1d9bf0", shadow: "rgba(29,155,240,.4)" }, success: { bg: "#00ba7c", shadow: "rgba(0,186,124,.4)" }, + warning: { bg: "#ff9800", shadow: "rgba(255,152,0,.4)" }, error: { bg: "#f4212e", shadow: "rgba(244,33,46,.4)" }, }; @@ -813,7 +1083,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); @@ -841,6 +1116,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; @@ -879,24 +1159,283 @@ 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; +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) return; + + // 解除复制保护 + neutralizeFeishuCopyProtection(document); + + showToast("正在提取全部内容…", "loading", null); + 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) { + debugLog("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); + 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; + clearTimeout(_copyLockTimer); + } +} + +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; +function bindAllDebounced() { + if (_bindAllTimer) return; + _bindAllTimer = setTimeout(() => { + _bindAllTimer = null; + bindAll(); + }, 200); } +// Discourse 点赞按钮:单击 = 保存到 OB(拦截点赞),双击 = 正常点赞(不保存) +// 使用 per-post 追踪代替全局标志,避免多帖子并发操作时标志竞态 +const _likePassThroughSet = new WeakSet(); // 记录正在放行的按钮 +const _likePendingClicks = new WeakMap(); // btn → { count, timer } + document.addEventListener("click", (event) => { - if (!isLinuxDoTopicPage()) return; + if (!isDiscourseTopicPage()) return; const btn = event.target?.closest?.(LINUX_DO_LIKE_SELECTOR); if (!btn) return; - setTimeout(() => captureLinuxDoPost(btn), 250); + + // 放行标记:双击重放的点击不拦截(per-button) + if (_likePassThroughSet.has(btn)) { + _likePassThroughSet.delete(btn); + return; + } + + // 拦截本次点击 + event.preventDefault(); + event.stopPropagation(); + + let pending = _likePendingClicks.get(btn); + if (!pending) { + pending = { count: 0, timer: null }; + _likePendingClicks.set(btn, pending); + } + pending.count++; + + clearTimeout(pending.timer); + pending.timer = setTimeout(() => { + const clicks = pending.count; + _likePendingClicks.delete(btn); + + if (clicks >= 2) { + // 双击:恢复点赞,不保存 + _likePassThroughSet.add(btn); + btn.click(); + } else { + // 单击:保存到 OB,不点赞 + captureLinuxDoPost(btn); + } + }, 300); }, true); -const observer = new MutationObserver(bindAll); +const observer = new MutationObserver(bindAllDebounced); observer.observe(document.body, { childList: true, subtree: true }); requestRuntimeConfig(); bindAll(); -console.log("[x2md] 内容脚本已加载 v1.3"); +debugLog(" 内容脚本已加载 v1.3"); diff --git a/extension/discourse.js b/extension/discourse.js index ef67895..4ac0401 100644 --- a/extension/discourse.js +++ b/extension/discourse.js @@ -1,5 +1,14 @@ (function (globalScope) { const LINUX_DO_HOST = "linux.do"; + // 可配置 Discourse 域名列表,运行时从配置注入 + let _discourseDomains = ["linux.do"]; + function setDiscourseDomains(domains) { + if (Array.isArray(domains) && domains.length > 0) { + _discourseDomains = domains.map(d => d.toLowerCase().trim()).filter(Boolean); + } + } + function getDiscourseDomains() { return _discourseDomains; } + const LINUX_DO_LIKE_SELECTOR = "button.btn-toggle-reaction-like.reaction-button"; const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]); const BLOCK_TAGS = new Set(["p", "div", "section", "article", "blockquote", "ul", "ol", "li", "pre"]); @@ -30,10 +39,15 @@ } } - function isLinuxDoTopicPage(locationLike = globalScope.location) { + function isDiscourseTopicPage(locationLike = globalScope.location) { const hostname = String(locationLike?.hostname || "").toLowerCase(); const pathname = String(locationLike?.pathname || ""); - return hostname === LINUX_DO_HOST && /^\/t\/[^/]+\/\d+(?:\/\d+)?\/?$/.test(pathname); + return _discourseDomains.includes(hostname) && /^\/t\/[^/]+\/\d+(?:\/\d+)?\/?$/.test(pathname); + } + + // 向后兼容别名 + function isLinuxDoTopicPage(locationLike) { + return isDiscourseTopicPage(locationLike); } function buildLinuxDoPostTitle(topicTitle, postNumber, username) { @@ -57,7 +71,7 @@ return classList.includes("anchor") || classList.includes("cooked-selection-barrier") || classList.includes("codeblock-button-wrapper") || - classList.includes("meta") && !!safeClosest(element, "a.lightbox") || + (classList.includes("meta") && !!safeClosest(element, "a.lightbox")) || tag === "svg" || tag === "script" || tag === "style"; @@ -83,12 +97,43 @@ const lightboxHref = safeGetAttribute(safeClosest(node, "a.lightbox[href]"), "href") || ""; const src = lightboxHref || node.currentSrc || node.src || safeGetAttribute(node, "src") || ""; const resolved = resolveLinuxDoUrl(src, options.pageUrl); - return resolved ? `\n![](${resolved})\n` : ""; + const alt = (node.alt || "").replace(/[\[\]]/g, ""); + return resolved ? `\n![${alt}](${resolved})\n` : ""; } if (tag === "br") return "\n"; if (tag === "hr") return "\n---\n"; + // 视频标签 → iframe 或 Markdown 链接 + if (tag === "video") { + const src = safeGetAttribute(node, "src") || node.querySelector?.("source")?.src || ""; + if (src) { + const resolved = resolveLinuxDoUrl(src, options.pageUrl); + return `\n![video](${resolved})\n`; + } + return ""; + } + + // iframe 嵌入(YouTube / Bilibili / 其他) + if (tag === "iframe") { + const src = safeGetAttribute(node, "src") || ""; + if (!src) return ""; + const resolved = resolveLinuxDoUrl(src, options.pageUrl); + // YouTube + if (/youtube\.com\/embed\/|youtu\.be\//i.test(resolved)) { + const videoId = resolved.match(/(?:embed\/|youtu\.be\/)([a-zA-Z0-9_-]+)/); + if (videoId) { + return `\n\n`; + } + } + // Bilibili + if (/bilibili\.com\/(?:video|player)/i.test(resolved)) { + return `\n\n`; + } + // 其他 iframe → 保留为链接 + return `\n[嵌入内容](${resolved})\n`; + } + if (tag === "pre") { let codeNode = null; for (const child of node.childNodes || []) { @@ -102,6 +147,87 @@ return formatCodeFence(code, normalizeCodeLanguage(codeNode)); } + const classList = getClassList(node); + + // Discourse
        折叠/剧透块 + if (tag === "details") { + const summary = node.querySelector?.("summary"); + const summaryText = summary ? (getNodeText(summary).trim() || "详情") : "详情"; + let inner = ""; + for (const child of node.childNodes || []) { + if (child === summary) continue; + inner += convertLinuxDoNodeToMarkdown(child, options); + } + const content = inner.trim(); + if (!content) return ""; + return `\n
        \n${summaryText}\n\n${content}\n\n
        \n`; + } + if (tag === "summary") return ""; // 已在 details 中处理 + + // Discourse onebox 引用(aside.quote / aside.onebox) + if (tag === "aside" && (classList.includes("quote") || classList.includes("onebox"))) { + // 检查 onebox 是否包含视频嵌入(YouTube/Bilibili) + if (classList.includes("onebox")) { + const iframeEl = node.querySelector?.("iframe"); + if (iframeEl) { + return convertLinuxDoNodeToMarkdown(iframeEl, options); + } + // 检查 data-onebox-src 或链接中的视频 URL + const oneboxSrc = safeGetAttribute(node, "data-onebox-src") || ""; + const linkEl = node.querySelector?.("a[href]"); + const linkHref = safeGetAttribute(linkEl, "href") || ""; + const videoUrl = oneboxSrc || linkHref; + if (videoUrl && /youtube\.com\/watch|youtu\.be\/|bilibili\.com\/video/i.test(videoUrl)) { + // YouTube watch URL → embed + const ytMatch = videoUrl.match(/(?:watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)/); + if (ytMatch) { + return `\n\n`; + } + // Bilibili → embed + const bvMatch = videoUrl.match(/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/i); + if (bvMatch) { + return `\n\n`; + } + } + } + + const title = node.querySelector?.(".title, header"); + const titleText = title ? getNodeText(title).trim() : ""; + let inner = ""; + for (const child of node.childNodes || []) { + if (child === title || (child.nodeType === 1 && (getClassList(child).includes("title") || getTagName(child) === "header"))) continue; + inner += convertLinuxDoNodeToMarkdown(child, options); + } + const lines = inner.trim().split("\n").filter((l) => l.trim() !== ""); + if (titleText) lines.unshift(titleText); + if (!lines.length) return ""; + return "\n" + lines.map((l) => `> ${l}`).join("\n") + "\n"; + } + + // Discourse 表格(使用共享 GFM 转换函数) + if (tag === "table") { + const result = convertTableToGfm(node, convertLinuxDoNodeToMarkdown, options); + if (result) return result; + } + if (tag === "tr" || tag === "td" || tag === "th" || tag === "thead" || tag === "tbody") { + // 被 table handler 调用时单独处理 + let md = ""; + for (const child of node.childNodes || []) { + md += convertLinuxDoNodeToMarkdown(child, options); + } + return md; + } + + // Discourse 投票(poll widget)—— 简化为文本列表 + if (classList.includes("poll")) { + const titleEl = node.querySelector?.(".poll-title"); + const title = titleEl ? getNodeText(titleEl).trim() : "投票"; + const optionEls = node.querySelectorAll?.(".poll-option .option-text, li[data-poll-option-id]") || []; + const optionTexts = Array.from(optionEls).map(el => getNodeText(el).trim()).filter(Boolean); + if (!optionTexts.length) return ""; + return `\n> **${title}**\n${optionTexts.map(o => `> - ${o}`).join("\n")}\n`; + } + let markdown = ""; for (const child of node.childNodes || []) { markdown += convertLinuxDoNodeToMarkdown(child, options); @@ -111,18 +237,28 @@ const href = resolveLinuxDoUrl(safeGetAttribute(node, "href") || "", options.pageUrl); const text = markdown.trim(); if (!href || !text) return markdown; - if (text.includes("![](")) return markdown; - return `[${text}](${href})`; + if (/!\[[^\]]*]\(/.test(text)) return markdown; + return `[${escapeMdLinkText(text)}](${escapeMdLinkUrl(href)})`; } if ((tag === "strong" || tag === "b") && markdown.trim()) { markdown = `**${markdown.replace(/\*\*/g, "")}**`; } + if ((tag === "em" || tag === "i") && markdown.trim()) { + markdown = `*${markdown.trim()}*`; + } + + if ((tag === "del" || tag === "s") && markdown.trim()) { + markdown = `~~${markdown.trim()}~~`; + } + if (tag === "h1") return `\n# ${markdown.replace(/\*\*/g, "").trim()}\n`; if (tag === "h2") return `\n## ${markdown.replace(/\*\*/g, "").trim()}\n`; if (tag === "h3") return `\n### ${markdown.replace(/\*\*/g, "").trim()}\n`; - if (tag === "h4" || tag === "h5" || tag === "h6") return `\n#### ${markdown.replace(/\*\*/g, "").trim()}\n`; + if (tag === "h4") return `\n#### ${markdown.replace(/\*\*/g, "").trim()}\n`; + if (tag === "h5") return `\n##### ${markdown.replace(/\*\*/g, "").trim()}\n`; + if (tag === "h6") return `\n###### ${markdown.replace(/\*\*/g, "").trim()}\n`; if (tag === "blockquote") { const lines = markdown.trim().split("\n").filter((line) => line.trim() !== ""); @@ -130,6 +266,11 @@ } if (tag === "li") { + const parentTag = getTagName(node.parentElement); + if (parentTag === "ol") { + const idx = Array.from(node.parentElement.children).indexOf(node) + 1; + return `\n${idx}. ${markdown.trim()}\n`; + } return `\n- ${markdown.trim()}\n`; } @@ -186,22 +327,112 @@ article_content: articleContent, images: [], videos: [], - platform: "LINUX DO", + platform: getDiscoursePlatformName(), + }; + } + + function getDiscoursePlatformName() { + const host = String(globalScope.location?.hostname || "").toLowerCase(); + if (host === "linux.do") return "LINUX DO"; + // 其他 Discourse 站点用域名作为平台名 + return host.replace(/\./g, "_") || "Discourse"; + } + + function cookedHtmlToMarkdown(htmlString, pageUrl) { + // 使用 DOMParser 代替 innerHTML,避免直接执行脚本(安全加固) + let container; + if (typeof DOMParser !== "undefined") { + const parser = new DOMParser(); + const doc = parser.parseFromString(`
        ${htmlString || ""}
        `, "text/html"); + container = doc.body.firstElementChild || doc.body; + } else { + // Node.js 测试环境等无 DOMParser 时的兜底 + const div = document.createElement("div"); + div.innerHTML = htmlString || ""; + container = div; + } + return extractLinuxDoMarkdown(container, { pageUrl: pageUrl || "" }); + } + + async function fetchDiscourseReplies(topicId, hostname) { + const host = hostname || globalScope.location?.hostname || "linux.do"; + 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(); + + // 第一批帖子(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 || "", `${baseUrl}/t/${topicId}`), + published: p.created_at || "", + likes: p.like_count || 0, + reply_to: p.reply_to_post_number || null, + })); + // 返回结构化对象,而非在数组上挂属性 + return { + replies, + topicTags: Array.isArray(data.tags) ? data.tags : [], }; } + // 向后兼容别名(默认使用 linux.do 作为 hostname) + function fetchLinuxDoReplies(topicId, hostname) { + return fetchDiscourseReplies(topicId, hostname || "linux.do"); + } + const exported = { LINUX_DO_LIKE_SELECTOR, buildLinuxDoPostTitle, cleanLinuxDoPostUrl, + cookedHtmlToMarkdown, extractLinuxDoMarkdown, extractLinuxDoPostData, + fetchLinuxDoReplies, + fetchDiscourseReplies, isLinuxDoTopicPage, + isDiscourseTopicPage, + setDiscourseDomains, + getDiscourseDomains, + getDiscoursePlatformName, }; if (typeof module !== "undefined" && module.exports) { 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 ca9aad0..0d8baa3 100644 --- a/extension/dom_utils.js +++ b/extension/dom_utils.js @@ -36,11 +36,123 @@ return String(text || "").replace(/[\u200B-\u200D\u2060\uFEFF]/g, ""); } + /** + * 转义 Markdown 链接文本中的特殊字符 [ 和 ] + */ + function escapeMdLinkText(text) { + return String(text || "").replace(/\[/g, "\\[").replace(/\]/g, "\\]"); + } + + /** + * 转义 Markdown 链接 URL 中的括号 + */ + function escapeMdLinkUrl(url) { + return String(url || "").replace(/\(/g, "%28").replace(/\)/g, "%29"); + } + + /** + * 将 HTML 节点转换为 GFM pipe table markdown + * @param {Element} tableNode -
        DOM 节点 + * @param {Function} cellConverter - 将单元格节点转为 markdown 文本的函数 (cell, options) => string + * @param {Object} options - 传递给 cellConverter 的选项 + * @returns {string} GFM markdown table 字符串 + */ + function convertTableToGfm(tableNode, cellConverter, options) { + // 处理 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 (row.length) grid.push(row); + } + if (!grid.length) return ""; + const colCount = Math.max(...grid.map(r => r.length)); + const lines = []; + grid.forEach((row, i) => { + while (row.length < colCount) row.push(""); + lines.push("| " + row.join(" | ") + " |"); + if (i === 0) lines.push("| " + Array(colCount).fill("---").join(" | ") + " |"); + }); + 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, }; @@ -49,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 ed00068..808a2dd 100644 --- a/extension/feishu.js +++ b/extension/feishu.js @@ -1,13 +1,217 @@ (function (globalScope) { - function cleanFeishuUrl(url) { + + // ───────────────────────────────────────────── + // 飞书权限绕过:解除"仅阅读/禁止复制"的前端限制 + // ───────────────────────────────────────────── + + 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/messenger) + // ───────────────────────────────────────────── + + 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"; + if (/^\/messenger\b/.test(pathname)) return "messenger"; + + 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: "飞书", + }; + } + + // ───────────────────────────────────────────── + // 飞书通用页面滚动收集(兼容 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; + } + + /** + * 清理飞书页面 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); } } @@ -15,23 +219,29 @@ 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; + // 绝对 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 */ } } - try { - return new URL(raw, pageUrl || globalScope.location?.href || "").href; - } catch (error) { - return raw; + // 飞书内部相对路径 fallback:补全飞书域名 + if (/^\/(wiki|docx|docs|sheets|minutes|drive|mindnotes)\//.test(raw)) { + return "https://feishu.cn" + raw; } + return raw; } 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) { @@ -53,7 +263,7 @@ return getTagName(node) === "strong" || getTagName(node) === "b" || style === "bold" || - Number.isFinite(numericWeight) && numericWeight >= 700; + (Number.isFinite(numericWeight) && numericWeight >= 700); } function extractFeishuInlineMarkdown(node, options = {}) { @@ -69,9 +279,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})` : ""; } @@ -87,8 +294,9 @@ const href = resolveFeishuUrl(safeGetAttribute(node, "href") || "", options.pageUrl); const text = markdown.trim(); if (!href || !text) return markdown; - if (text.includes("![](")) return text; - return `[${text}](${href})`; + // 带链接的图片:[![](img)](href) + if (text.includes("![](")) return `[${text}](${escapeMdLinkUrl(href)})`; + return `[${escapeMdLinkText(text)}](${escapeMdLinkUrl(href)})`; } if (tag === "div" && getClassList(node).includes("ace-line")) { @@ -99,6 +307,21 @@ return `**${markdown.replace(/\*\*/g, "")}**`; } + // 斜体 + if ((tag === "em" || tag === "i") && markdown.trim()) { + return `*${markdown.trim()}*`; + } + + // 删除线 + if ((tag === "del" || tag === "s") && markdown.trim()) { + return `~~${markdown.trim()}~~`; + } + + // 行内代码 + if (tag === "code" && markdown.trim() && !markdown.includes("\n")) { + return `\`${markdown.trim()}\``; + } + return markdown; } @@ -115,8 +338,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 = {}) { @@ -136,6 +364,16 @@ if (type === "heading2") return `## ${extractBlockText(block, options)}`.trim(); if (type === "heading3") return `### ${extractBlockText(block, options)}`.trim(); if (type === "heading4") return `#### ${extractBlockText(block, options)}`.trim(); + if (type === "heading5") return `##### ${extractBlockText(block, options)}`.trim(); + if (type === "heading6" || type === "heading7" || type === "heading8" || type === "heading9") return `###### ${extractBlockText(block, options)}`.trim(); + + // Todo / Checkbox 块 + if (type === "todo") { + const checkbox = block.querySelector?.("input[type='checkbox'], .checkbox, .todo-checkbox"); + const checked = checkbox?.checked || getClassList(checkbox).includes("checked") || safeGetAttribute(checkbox, "data-checked") === "true"; + const content = extractBlockText(block.querySelector?.(".list-content, .todo-content") || block, options); + return content ? `- [${checked ? "x" : " "}] ${content}` : ""; + } if (type === "ordered") { const order = (block.querySelector?.(".order")?.innerText || "1.").trim(); @@ -160,8 +398,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})`; } @@ -193,7 +431,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 ""; @@ -202,7 +441,7 @@ const link = block.querySelector?.("a"); const href = safeGetAttribute(link, "href") || ""; const text = cleanZeroWidth(getNodeText(link)).trim() || "多维表格"; - return href ? `[${text}](${resolveFeishuUrl(href, options.pageUrl)})` : text; + return href ? `[${escapeMdLinkText(text)}](${escapeMdLinkUrl(resolveFeishuUrl(href, options.pageUrl))})` : text; } if (type === "code") { @@ -221,30 +460,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) { @@ -289,25 +561,809 @@ article_content: articleContent, images: [], videos: [], - platform: "Feishu", + platform: "飞书", }; } + // ───────────────────────────────────────────── + // 飞书内部 API JSON 读取方案(替代滚动收集) + // ───────────────────────────────────────────── + + function extractFeishuDocToken(url) { + const match = String(url || "").match(/\/(wiki|docx)\/([A-Za-z0-9]+)/); + return match ? { type: match[1], token: match[2] } : null; + } + + // 将飞书 API 返回的 text.elements 转为 markdown 内联文本 + // pageUrl: 用于将相对链接解析为绝对 URL + function convertFeishuApiTextElements(elements, pageUrl) { + let md = ""; + for (const el of elements || []) { + if (el.text_run) { + let text = el.text_run.content || ""; + if (!text) continue; + const style = el.text_run.text_element_style || {}; + if (style.inline_code) text = `\`${text}\``; + else { + if (style.bold && style.italic) text = `***${text}***`; + else if (style.bold) text = `**${text}**`; + else if (style.italic) text = `*${text}*`; + if (style.strikethrough) text = `~~${text}~~`; + } + if (style.link && style.link.url) { + const escapedText = escapeMdLinkText(text); + 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 || "文档"; + 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 || ""}$`; + } + } + return md; + } + + // 代码语言枚举映射(飞书 API 返回数字类型) + const FEISHU_CODE_LANG_MAP = { + 1: "plaintext", 2: "bash", 3: "c", 4: "cpp", 5: "csharp", + 6: "css", 7: "go", 8: "html", 9: "java", 10: "javascript", + 11: "json", 12: "kotlin", 13: "lua", 14: "markdown", 15: "objc", + 16: "perl", 17: "php", 18: "python", 19: "ruby", 20: "rust", + 21: "scala", 22: "shell", 23: "sql", 24: "swift", 25: "typescript", + 26: "xml", 27: "yaml", + }; + + // 递归将飞书 API 的 JSON block 转换为 markdown + 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, options.pageUrl); + + // 列表类型集合(API block_type 枚举) + const API_LIST_TYPES = new Set([12, 13, 16]); // bullet, ordered, todo + + // 递归转换子块,智能拼接:连续列表项用 \n,其他用 \n\n + function convertChildren(childDepth) { + 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) { + case 1: // page + return convertChildren(0); + case 2: // text + return text || ""; + case 3: return text ? `# ${text}` : ""; + case 4: return text ? `## ${text}` : ""; + case 5: return text ? `### ${text}` : ""; + case 6: return text ? `#### ${text}` : ""; + case 7: return text ? `##### ${text}` : ""; + case 8: case 9: case 10: case 11: // heading6-9 → h6 + return text ? `###### ${text}` : ""; + case 12: { // bullet + const children = convertChildren(depth + 1); + const indent = " ".repeat(depth); + const line = text ? `${indent}- ${text}` : ""; + return [line, children].filter(Boolean).join("\n"); + } + case 13: { // ordered + const children = convertChildren(depth + 1); + const indent = " ".repeat(depth); + const line = text ? `${indent}1. ${text}` : ""; + return [line, children].filter(Boolean).join("\n"); + } + case 14: { // code + const codeElements = block.code?.text?.elements || textElements; + const codeText = codeElements.map(function (e) { return e.text_run?.content || ""; }).join(""); + const langVal = block.code?.style?.language; + const langStr = typeof langVal === "number" ? (FEISHU_CODE_LANG_MAP[langVal] || "") : String(langVal || "").toLowerCase(); + return codeText ? `\`\`\`${langStr}\n${codeText}\n\`\`\`` : ""; + } + case 15: { // quote + const content = text || convertChildren(depth); + return content ? content.split("\n").map(function (l) { return `> ${l}`; }).join("\n") : ""; + } + case 16: { // todo + const checked = block.todo?.style?.done === true; + return text ? `- [${checked ? "x" : " "}] ${text}` : ""; + } + case 17: return "---"; // divider + case 18: { // image + const token = block.image?.token || ""; + return token ? `![](feishu-image://${token})` : ""; + } + case 19: { // table + const cellIds = block.children || []; + if (!cellIds.length) return ""; + const cols = block.table?.property?.column_size || 1; + const rows = []; + for (let i = 0; i < cellIds.length; i += cols) { + const row = []; + 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, options); }) + .filter(Boolean) + .join(" ") + .replace(/\|/g, "\\|") + .replace(/\n/g, " ") : ""; + row.push(cellContent); + } + rows.push(row); + } + if (!rows.length) return ""; + 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"); + const gfmTable = [header, sep, body].filter(Boolean).join("\n"); + return renderTableDualFormat(gfmTable, !!options.includeHtmlTable); + } + case 20: // table_cell - handled by table + return ""; + case 22: case 23: // grid, grid_column + return convertChildren(depth); + case 27: { // callout + const content = convertChildren(depth); + return content ? content.split("\n").map(function (l) { return `> ${l}`; }).join("\n") : ""; + } + default: + return text || convertChildren(depth); + } + } + + // 通过飞书内部 API 获取文档 JSON 并转 markdown(通过页面上下文注入执行) + // 返回 Promise + function fetchFeishuDocViaApi(pageUrl) { + const info = extractFeishuDocToken(pageUrl); + if (!info) return Promise.resolve(null); + + return new Promise(function (resolve) { + // 生成唯一 message ID + var msgId = "__x2md_feishu_" + 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); + + // 超时 8 秒 + var timer = setTimeout(function () { + globalScope.removeEventListener("message", onMessage); + resolve(null); + }, 8000); + + // 注入脚本到页面上下文(这样 fetch 自带页面 cookies) + var script = globalScope.document.createElement("script"); + script.textContent = `(function(){ + var msgId = ${JSON.stringify(msgId)}; + var docType = ${JSON.stringify(info.type)}; + var docToken = ${JSON.stringify(info.token)}; + var origin = location.origin; + + function sendResult(data) { + window.postMessage({ type: msgId, result: data }, location.origin); + } + + async function run() { + try { + var realDocToken = docToken; + // wiki 页面需先解析真实 doc_token + if (docType === "wiki") { + var wikiResp = await fetch(origin + "/space/api/wiki/v2/tree/get_info?token=" + docToken, { + credentials: "include" + }); + if (wikiResp.ok) { + var wikiData = await wikiResp.json(); + realDocToken = (wikiData && wikiData.data && wikiData.data.wiki_info && wikiData.data.wiki_info.doc_token) || docToken; + } + } + // 获取文档原始内容 + var resp = await fetch(origin + "/space/api/docx/v2/" + realDocToken + "/raw_content", { + credentials: "include" + }); + if (!resp.ok) { sendResult(null); return; } + var json = await resp.json(); + if (!json || json.code !== 0 || !json.data) { sendResult(null); return; } + sendResult(json.data); + } catch(e) { + console.error("[x2md] Feishu API fetch failed:", e); + sendResult(null); + } + } + run(); + })();`; + (globalScope.document.head || globalScope.document.documentElement).appendChild(script); + script.remove(); + }); + } + + // 将 API 返回的 data 转为 markdown + // options.includeHtmlTable: 是否同时输出 HTML 表格 + function convertFeishuApiDataToMarkdown(apiData, options) { + if (!apiData) return null; + options = options || {}; + + var blocks = apiData.blocks || {}; + var doc = apiData.document; + if (!doc || !doc.block_id) return null; + + var rootBlock = blocks[doc.block_id]; + if (!rootBlock) return null; + + // 将 document.children 设为 rootBlock 的 children + rootBlock.children = doc.children || rootBlock.children || []; + + var md = convertFeishuApiBlock(rootBlock, blocks, 0, options); + 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() || "飞书聊天记录"; + console.debug("[x2md] Feishu Chat: 所有标题选择器均未命中,使用 document.title 兜底:" + chatTitle); + } + + // 尝试多种消息容器选择器 + 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) { + // 所有消息选择器均未命中,记录诊断信息帮助排查飞书 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 = []; + 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) { + console.warn("[x2md] Feishu Chat DOM 提取:找到 " + messageElements.length + + " 个消息元素,但未能从中提取到有效文本内容。内容选择器可能需要更新。"); + 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) { + 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 16/17/18) + if (!chatId) { + var appRoot = document.querySelector("#root, #app, [id*='messenger']"); + if (appRoot) { + try { + // 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) {} + } + } + + 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, extractFeishuMarkdownFromBlocks, + extractFeishuMinutesData, extractFeishuTitle, extractFeishuUpdated, + fetchFeishuChatViaInternalApi, + fetchFeishuDocViaApi, + findFeishuScrollContainer, + formatFeishuTimestamp, + isFeishuContentPage, isFeishuWikiOrDocxPage, + neutralizeFeishuCopyProtection, }; if (typeof module !== "undefined" && module.exports) { module.exports = exported; } + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/extension/manifest.json b/extension/manifest.json index c10eead..548b329 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,14 +1,15 @@ { "manifest_version": 3, "name": "X2MD - 网页内容转Markdown", - "version": "1.0.8", - "description": "支持 X、LINUX DO、飞书知识库、飞书云文档、微信公众号的一键保存 Markdown", + "version": "1.6.0", + "description": "支持 X/Twitter、LINUX DO、Discourse多域名、飞书(知识库/云文档/聊天记录)、微信公众号的一键保存 Markdown,多目标保存(Obsidian/飞书多维表格/Notion/HTML),支持评论提取、iframe嵌入", "permissions": [ "storage", "notifications", "cookies", "tabs", - "scripting" + "scripting", + "downloads" ], "host_permissions": [ "*://*.twitter.com/*", @@ -16,10 +17,26 @@ "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://*.feishu.cn/messenger/*", + "https://*.larksuite.com/wiki/*", + "https://*.larksuite.com/messenger/*", + "https://*.larksuite.com/docx/*", + "https://*.larksuite.com/minutes/*", "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/*" + "https://publish.twitter.com/*", + "https://abs.twimg.com/*", + "https://open.feishu.cn/*", + "https://open.larksuite.com/*", + "https://api.notion.com/*" + ], + "optional_host_permissions": [ + "https://*/*" ], "background": { "service_worker": "background.js" @@ -32,6 +49,15 @@ "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://*.feishu.cn/messenger/*", + "https://*.larksuite.com/wiki/*", + "https://*.larksuite.com/messenger/*", + "https://*.larksuite.com/docx/*", + "https://*.larksuite.com/minutes/*", "https://mp.weixin.qq.com/*" ], "js": [ 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/options.html b/extension/options.html index f4ae20a..c17bbb7 100644 --- a/extension/options.html +++ b/extension/options.html @@ -1,527 +1,653 @@ - - - X2MD 设置 - + + +X2MD 设置 + - -
        - -
        -
        🔖
        -
        -

        X2MD 设置

        -

        配置 X / LINUX DO / 飞书 / 微信公众号 → Obsidian Markdown 保存规则

        -
        +
        + + +
        +
        MD
        +
        +

        X2MD 设置

        +

        X / LINUX DO / 飞书 / 微信公众号 内容保存与管理

        +
        +
        + + +
        +
        +
        + 检测本地服务... + localhost:9527 +
        + +
        + + +
        +
        + 🎨 +

        外观设置

        + +
        +
        +
        +
        主题
        + +
        +
        +
        + + +
        +
        + 💾 +

        保存目标

        + +
        +
        +
        选择保存内容的目标平台,可同时保存到多个目标。
        + + + + +
        +
        + + +
        +
        + 📁 +

        文件与路径

        + +
        +
        + + +
        服务连接
        +
        +
        服务端口
        + +
        + +
        + + +
        文件命名
        +
        +
        命名模板
        +
        + +
        + {date}{author} + {handle}{summary} + {timestamp} +
        - - -
        -
        -
        - 检测本地服务… - 正在连接 localhost:9527 -
        - +
        +
        +
        文件名限长
        + +
        +
        +
        + +
        + +
        + + +
        Obsidian 保存路径
        +
        支持添加多个路径,保存时同步写入所有路径。
        +
        + + +
        + + +
        平台分类文件夹
        + +
        + 开启后文件保存到 保存路径/平台名/文件名.md
        + 注意:各平台文件夹名必须互不相同,也不能与下方"媒体子文件夹"同名。 +
        +
        + Twitter/X: + + LINUX DO: + + 飞书: + + 微信公众号: + +
        +
        +
        + + +
        +
        + 🎞️ +

        媒体与嵌入

        + +
        +
        + + +
        图片下载
        + +
        +
        媒体子文件夹
        + +
        +
        + 图片和视频下载到 Markdown 文件同级的子文件夹中,Obsidian 使用 ![[文件名]] 引用。 +
        + +
        + + +
        视频下载
        + +
        +
        视频保存路径
        + +
        +
        +
        时长警告(分钟)
        +
        + +
        超过此时长的视频会弹窗确认再下载。
        - - -
        -
        -
        📁
        -
        -

        保存路径

        -

        支持添加多个路径,保存时同步写入所有路径

        -
        -
        -
        -
        - -
        +
        + +
        + + +
        微信视频号
        + +
        + 关闭时,文章中嵌入的视频号视频仅显示占位提示,不会下载。开启后视频会被下载保存。 +
        + +
        + + +
        视频嵌入模式
        +
        +
        嵌入方式
        +
        + +
        仅对 YouTube/Bilibili 等可嵌入视频生效。
        - - -
        -
        -
        ⚙️
        -
        -

        服务设置

        -

        本地服务器和文件命名规则

        -
        -
        -
        -
        -
        服务端口
        - -
        -
        -
        -
        文件命名
        -
        - -
        支持的变量:
        -
        - {date} - {author} - {handle} - {summary} - {timestamp} -
        -
        -
        -
        -
        -
        文件名限长
        - -
        -
        +
        +
        +
        + + +
        +
        + 💬 +

        内容提取与快捷操作

        + +
        +
        + + +
        快捷操作
        + +
        适用于 linux.do / 飞书 / 微信公众号页面。
        + +
        开启后,飞书知识库页面右上角出现复制按钮,自动提取全部内容为 Markdown。
        + +
        + + +
        评论/回复提取
        + +
        +
        +
        显示模式
        +
        - - -
        -
        -
        🎞️
        -
        -

        媒体下载设置

        -

        管理推文视频的大文件直接下载与警告参数

        -
        -
        -
        -
        -
        视频自动下载
        -
        - - -
        -
        -
        -
        -
        视频保存路径
        - -
        -
        -
        -
        警告阈值(分钟)
        -
        - -
        当推特视频时长超过此分钟数时,将弹窗向您确认是否仍然下载文件。
        -
        -
        -
        +
        +
        最大评论数
        +
        - - -
        -
        -
        📌
        -
        -

        站点快捷保存

        -

        控制 linux.do、飞书、微信公众号页面右上角的一键保存 MD 小图标

        -
        -
        -
        -
        -
        显示小图标
        -
        - - -
        -
        -
        关闭后仍可使用既有的站内触发方式,例如 linux.do 的点赞保存。
        -
        +
        +
        楼层范围
        +
        + +
        - - - +
        + +
        + + +
        Discourse 站点管理
        +
        + 添加自建 Discourse 论坛域名。linux.do 为内置域名,无需手动添加。 +
        +
        +
        + + +
        +
        +
        + + + + + + + + + + + +
        +
        + 🔄 +

        跨设备同步

        + +
        +
        + +
        + 同步:文件命名、限长、视频设置、站点图标开关。不同步:保存路径、端口、API 密钥。 +
        +
        +
        +
        -
        + +
        + + +
        +
        + 所有配置默认保存在浏览器本地存储中,除非手动清除浏览器数据或点击"重置所有设置",否则不会丢失。 +
        - - +
        + +
        + + diff --git a/extension/options.js b/extension/options.js index 20dcbd8..70fa73b 100644 --- a/extension/options.js +++ b/extension/options.js @@ -1,78 +1,378 @@ -// options.js - 全部用 addEventListener,不依赖 inline onclick(规避 CSP) +// options.js — X2MD 设置页逻辑(V1.5) +// 全部用 addEventListener,不依赖 inline onclick(规避 CSP) let currentConfig = {}; -// ───────────────────────────────────────────── -// 初始化 -// ───────────────────────────────────────────── +// ─── 初始化 ─── document.addEventListener("DOMContentLoaded", () => { - // 绑定按钮事件(不使用 inline onclick) + // 主题:立即应用(避免闪烁) + applyTheme(localStorage.getItem("x2md_theme") || "light"); + + // 绑定按钮事件 document.getElementById("btnRefresh").addEventListener("click", checkStatus); document.getElementById("btnAdd").addEventListener("click", addPath); document.getElementById("btnSave").addEventListener("click", saveConfig); + // 评论开关联动 + document.getElementById("enableComments").addEventListener("change", toggleCommentSub); + + // Discourse 域名 + document.getElementById("btnAddDomain").addEventListener("click", addDiscourseDomain); + document.getElementById("newDiscourseDomain").addEventListener("keydown", (e) => { + if (e.key === "Enter") addDiscourseDomain(); + }); + + // 主题切换 + document.getElementById("themeSelect").addEventListener("change", (e) => { + applyTheme(e.target.value); + localStorage.setItem("x2md_theme", e.target.value); + }); + + // 保存目标 checkbox 联动(显隐配置面板) + document.getElementById("saveToObsidian").addEventListener("change", updateTargetVisibility); + document.getElementById("saveToFeishu").addEventListener("change", updateTargetVisibility); + document.getElementById("saveToNotion").addEventListener("change", updateTargetVisibility); + document.getElementById("exportHtml").addEventListener("change", updateTargetVisibility); + + // 飞书 / Notion 测试连接 + document.getElementById("testFeishu").addEventListener("click", testFeishuConnection); + document.getElementById("testNotion").addEventListener("click", testNotionConnection); + + // 重置按钮 + document.getElementById("btnResetAll").addEventListener("click", resetAllConfig); + + // 折叠面板 + setupCollapsible(); + loadConfig(); checkStatus(); }); -// ───────────────────────────────────────────── -// 加载配置 -// ───────────────────────────────────────────── +// ─── 主题 ─── +function applyTheme(theme) { + const html = document.documentElement; + html.classList.remove("theme-dark", "theme-system"); + if (theme === "dark") html.classList.add("theme-dark"); + else if (theme === "system") html.classList.add("theme-system"); + // "light" = 默认,不加 class + + const sel = document.getElementById("themeSelect"); + if (sel) sel.value = theme; +} + +// 监听系统主题变化(仅 system 模式有效,CSS 自动处理,这里刷新 UI 即可) +window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { + if ((localStorage.getItem("x2md_theme") || "light") === "system") { + applyTheme("system"); + } +}); + +// ─── 折叠面板 ─── +function setupCollapsible() { + document.querySelectorAll(".card-header[data-toggle]").forEach((header) => { + header.addEventListener("click", () => { + const targetId = header.getAttribute("data-toggle"); + const body = document.getElementById(targetId); + if (!body) return; + const isHidden = body.classList.toggle("hidden"); + header.classList.toggle("collapsed", isHidden); + }); + }); +} + +// ─── 保存目标联动 ─── +function updateTargetVisibility() { + const obsidian = document.getElementById("saveToObsidian").checked; + const feishu = document.getElementById("saveToFeishu").checked; + const notion = document.getElementById("saveToNotion").checked; + const html = document.getElementById("exportHtml").checked; + + document.getElementById("obsidianSection").style.display = obsidian ? "" : "none"; + document.getElementById("feishuBitableSection").style.display = feishu ? "" : "none"; + document.getElementById("notionSection").style.display = notion ? "" : "none"; + document.getElementById("htmlExportSection").style.display = html ? "" : "none"; +} + +// ─── 加载配置 ─── function loadConfig() { chrome.runtime.sendMessage({ action: "get_config" }, (resp) => { + if (chrome.runtime.lastError) { + // 服务不可达时尝试从本地缓存恢复 + loadConfigFromLocal(); + return; + } if (resp && resp.success && resp.config) { currentConfig = resp.config; applyConfigToUI(resp.config); + // 同时写入本地缓存(备份) + saveConfigToLocal(resp.config); + } else { + // 服务端无配置时尝试本地缓存 + loadConfigFromLocal(); + } + }); +} + +function saveConfigToLocal(cfg) { + try { + chrome.storage.local.set({ x2md_config_backup: cfg }); + } catch (_) { /* 静默失败 */ } +} + +function loadConfigFromLocal() { + chrome.storage.local.get("x2md_config_backup", (result) => { + if (result && result.x2md_config_backup) { + currentConfig = result.x2md_config_backup; + applyConfigToUI(result.x2md_config_backup); + showToast("已从本地缓存恢复设置(服务未连接)"); } else { showToast("无法读取配置,请确认服务已启动", true); } }); } +function resetAllConfig() { + if (!confirm("确定要重置所有设置为默认值吗?此操作不可撤销。")) return; + chrome.storage.local.remove("x2md_config_backup"); + chrome.runtime.sendMessage({ action: "reset_config" }, (resp) => { + if (chrome.runtime.lastError || !resp || !resp.success) { + showToast("已清除本地缓存,刷新页面后将使用默认配置"); + } else { + showToast("所有设置已恢复为默认值"); + } + // 刷新页面以加载默认配置 + setTimeout(() => location.reload(), 800); + }); +} + function applyConfigToUI(cfg) { + // 主题 + const theme = cfg.theme || localStorage.getItem("x2md_theme") || "light"; + applyTheme(theme); + + // 服务设置 document.getElementById("portInput").value = cfg.port || 9527; document.getElementById("portLabel").textContent = cfg.port || 9527; - document.getElementById("filenameFormat").value = - cfg.filename_format || "{date}_{author}_{summary}"; + document.getElementById("filenameFormat").value = cfg.filename_format || "{summary}_{date}_{author}"; document.getElementById("maxLen").value = cfg.max_filename_length || 60; - // 视频设置回显 + // 保存目标 + document.getElementById("saveToObsidian").checked = cfg.save_to_obsidian !== false; + document.getElementById("saveToFeishu").checked = !!cfg.save_to_feishu; + document.getElementById("saveToNotion").checked = !!cfg.save_to_notion; + document.getElementById("exportHtml").checked = !!cfg.export_html; + + // 飞书多维表格 + document.getElementById("feishuApiDomain").value = cfg.feishu_api_domain || "feishu"; + document.getElementById("feishuAppId").value = cfg.feishu_app_id || ""; + document.getElementById("feishuAppSecret").value = cfg.feishu_app_secret || ""; + document.getElementById("feishuAppToken").value = cfg.feishu_app_token || ""; + document.getElementById("feishuTableId").value = cfg.feishu_table_id || ""; + document.getElementById("feishuUploadMd").checked = !!cfg.feishu_upload_md; + document.getElementById("feishuUploadHtml").checked = !!cfg.feishu_upload_html; + + // Notion + document.getElementById("notionToken").value = cfg.notion_token || ""; + document.getElementById("notionDatabaseId").value = cfg.notion_database_id || ""; + document.getElementById("notionPropTitle").value = cfg.notion_prop_title || "标题"; + document.getElementById("notionPropUrl").value = cfg.notion_prop_url || "链接"; + document.getElementById("notionPropAuthor").value = cfg.notion_prop_author || "作者"; + 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导出"; + + // 媒体设置 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; + document.getElementById("enableCopyUnlock").checked = !!cfg.enable_copy_unlock; + document.getElementById("enableWechatVideoChannel").checked = !!cfg.enable_wechat_video_channel; + + // 平台分类文件夹 + document.getElementById("enablePlatformFolders").checked = cfg.enable_platform_folders !== false; + const folderNames = cfg.platform_folder_names || {}; + document.querySelectorAll(".platform-folder-input").forEach((input) => { + const platform = input.dataset.platform; + if (platform && folderNames[platform]) input.value = folderNames[platform]; + }); + + // 图片 + document.getElementById("downloadImages").checked = cfg.download_images !== false; + document.getElementById("imageSubfolder").value = cfg.image_subfolder || "assets"; + + // 覆盖策略 + 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 || ""; + toggleCommentSub(); + + // Discourse 域名 + renderDiscourseDomains(cfg.discourse_domains || ["linux.do"]); + + // 嵌入模式 + document.getElementById("embedMode").value = cfg.embed_mode || "local"; + // 同步 + document.getElementById("syncEnabled").checked = cfg.sync_enabled !== false; + updateSyncStatus(cfg.sync_enabled !== false); + + // 路径 renderPaths(cfg.save_paths || []); + + // 保存目标联动 + updateTargetVisibility(); +} + +// ─── 评论子控件联动 ─── +function toggleCommentSub() { + const enabled = document.getElementById("enableComments").checked; + const panel = document.getElementById("commentSubControls"); + panel.classList.toggle("disabled", !enabled); + document.querySelectorAll(".comment-sub").forEach((el) => { + el.disabled = !enabled; + }); +} + +// ─── 同步状态 ─── +function updateSyncStatus(enabled) { + const el = document.getElementById("syncStatus"); + if (enabled) { + el.textContent = "同步已开启 — 偏好设置将通过 Chrome 账号同步"; + el.style.color = "var(--success)"; + } else { + el.textContent = "同步未开启 — 设置仅保存在本机"; + el.style.color = "var(--text-muted)"; + } } -// ───────────────────────────────────────────── -// 路径列表 -// ───────────────────────────────────────────── +// ─── Discourse 域名管理 ─── +let _discourseDomains = ["linux.do"]; + +function renderDiscourseDomains(domains) { + _discourseDomains = domains && domains.length ? [...domains] : ["linux.do"]; + const list = document.getElementById("discourseDomainList"); + list.textContent = ""; + + _discourseDomains.forEach((domain, i) => { + const row = document.createElement("div"); + row.className = "path-row"; + + const icon = document.createElement("span"); + icon.textContent = "🌐"; + icon.style.cssText = "font-size:14px; flex-shrink:0;"; + + const span = document.createElement("span"); + span.style.cssText = "flex:1; font-size:13px; font-family:monospace;"; + span.textContent = domain; + + const tag = document.createElement("span"); + tag.style.cssText = "font-size:11px; padding:2px 8px; border-radius:4px; background:var(--surface2); border:1px solid var(--border); color:var(--accent);"; + tag.textContent = "检测中…"; + detectSiteType(domain, tag); + + const isBuiltin = domain === "linux.do"; + const btn = document.createElement("button"); + btn.className = "btn-remove"; + btn.textContent = "×"; + btn.style.opacity = isBuiltin ? "0.3" : "1"; + btn.disabled = isBuiltin; + if (!isBuiltin) { + btn.addEventListener("click", () => { + _discourseDomains.splice(i, 1); + renderDiscourseDomains(_discourseDomains); + }); + } + + row.appendChild(icon); + row.appendChild(span); + row.appendChild(tag); + row.appendChild(btn); + list.appendChild(row); + }); +} + +async function detectSiteType(domain, tagEl) { + const known = { + "linux.do": "Discourse (LinuxDo)", + "meta.discourse.org": "Discourse (Official)", + }; + if (domain.endsWith(".feishu.cn")) { tagEl.textContent = "飞书"; tagEl.style.color = "var(--success)"; return; } + if (known[domain.toLowerCase()]) { tagEl.textContent = known[domain.toLowerCase()]; tagEl.style.color = "var(--success)"; return; } + 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(); + tagEl.textContent = `Discourse (${data.title || domain})`; + tagEl.style.color = "var(--success)"; + return; + } + } catch {} + tagEl.textContent = "Discourse (未验证)"; + tagEl.style.color = "var(--text-muted)"; +} + +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)) { + showToast("请输入有效域名", true); return; + } + // 安全校验:禁止内网地址 + if (/^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(domain)) { + 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 = ""; +} + +// ─── 路径列表 ─── function renderPaths(paths) { const list = document.getElementById("pathList"); - list.innerHTML = ""; + list.textContent = ""; paths.forEach((p, i) => { const row = document.createElement("div"); row.className = "path-row"; const icon = document.createElement("span"); - icon.className = "path-row-icon"; icon.textContent = "📂"; + icon.style.cssText = "font-size:14px; flex-shrink:0;"; const input = document.createElement("input"); input.type = "text"; input.value = p; - input.dataset.index = i; input.placeholder = "/path/to/obsidian/vault"; const btn = document.createElement("button"); btn.className = "btn-remove"; - btn.title = "删除"; btn.textContent = "×"; btn.addEventListener("click", () => { - const paths2 = collectPaths(); - paths2.splice(i, 1); - renderPaths(paths2); + const all = collectPaths(); + all.splice(i, 1); + renderPaths(all); }); row.appendChild(icon); @@ -83,9 +383,7 @@ function renderPaths(paths) { } function collectPaths() { - return [...document.querySelectorAll("#pathList input")] - .map(i => i.value.trim()) - .filter(Boolean); + return [...document.querySelectorAll("#pathList input")].map(i => i.value.trim()).filter(Boolean); } function addPath() { @@ -96,70 +394,261 @@ function addPath() { if (inputs.length) inputs[inputs.length - 1].focus(); } -// ───────────────────────────────────────────── -// 服务状态检测 -// ───────────────────────────────────────────── +// ─── 服务状态 ─── function checkStatus() { const dot = document.getElementById("statusDot"); const txt = document.getElementById("statusText"); - dot.className = "status-indicator offline"; + dot.className = "status-dot offline"; txt.textContent = "检测中…"; - chrome.runtime.sendMessage({ action: "ping" }, (resp) => { + if (chrome.runtime.lastError) return; const online = resp && resp.online; - dot.className = "status-indicator " + (online ? "online" : "offline"); - txt.textContent = online ? "本地服务运行中 ✓" : "服务未启动,请运行 start_server.sh"; + dot.className = "status-dot " + (online ? "online" : "offline"); + txt.textContent = online ? "本地服务运行中" : "服务未启动,请运行 start_server.sh"; }); } -// ───────────────────────────────────────────── -// 保存配置 -// ───────────────────────────────────────────── +// ─── 飞书测试连接 ─── +function testFeishuConnection() { + const statusEl = document.getElementById("feishuStatus"); + statusEl.className = "status-msg"; + statusEl.style.display = "none"; + + const appId = document.getElementById("feishuAppId").value.trim(); + const appSecret = document.getElementById("feishuAppSecret").value.trim(); + const apiDomain = document.getElementById("feishuApiDomain").value; + const appToken = document.getElementById("feishuAppToken").value.trim(); + const tableId = document.getElementById("feishuTableId").value.trim(); + + if (!appId || !appSecret) { + statusEl.className = "status-msg error"; + statusEl.textContent = "请填写 App ID 和 App Secret"; + return; + } + + statusEl.className = "status-msg"; + statusEl.style.display = "block"; + statusEl.textContent = "正在测试..."; + statusEl.style.color = "var(--text-muted)"; + + chrome.runtime.sendMessage({ + action: "test_feishu", + data: { + feishu_app_id: appId, + feishu_app_secret: appSecret, + feishu_api_domain: apiDomain, + feishu_app_token: appToken, + feishu_table_id: tableId, + } + }, (resp) => { + 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} 个字段`; + if (resp.missingFields && resp.missingFields.length > 0) { + msg += `\n⚠️ 缺少字段:${resp.missingFields.join("、")}`; + statusEl.className = "status-msg warning"; + } + statusEl.textContent = msg; + } else { + statusEl.className = "status-msg error"; + statusEl.textContent = "❌ 连接失败:" + (resp?.error || "未知错误"); + } + statusEl.style.display = "block"; + }); +} + +// ─── Notion 测试连接 ─── +function testNotionConnection() { + const statusEl = document.getElementById("notionStatus"); + statusEl.className = "status-msg"; + statusEl.style.display = "none"; + + const token = document.getElementById("notionToken").value.trim(); + const dbId = document.getElementById("notionDatabaseId").value.trim(); + + if (!token || !dbId) { + statusEl.className = "status-msg error"; + statusEl.textContent = "请填写 Token 和 Database ID"; + return; + } + + statusEl.className = "status-msg"; + statusEl.style.display = "block"; + statusEl.textContent = "正在测试..."; + statusEl.style.color = "var(--text-muted)"; + + chrome.runtime.sendMessage({ + action: "test_notion", + 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} 个属性)`; + if (resp.missingProperties && resp.missingProperties.length > 0) { + msg += `\n⚠️ 缺少属性:${resp.missingProperties.join("、")}`; + statusEl.className = "status-msg warning"; + } else { + statusEl.className = "status-msg success"; + } + statusEl.textContent = msg; + } else { + statusEl.className = "status-msg error"; + statusEl.textContent = "连接失败:" + (resp?.error || "未知错误"); + } + }); +} + +// ─── 保存配置 ─── function saveConfig() { - const port = parseInt(document.getElementById("portInput").value) || 9527; - const filenameFormat = - document.getElementById("filenameFormat").value.trim() || - "{date}_{author}_{summary}"; - const maxLen = parseInt(document.getElementById("maxLen").value) || 60; const savePaths = collectPaths(); + const saveToObsidian = document.getElementById("saveToObsidian").checked; - // 媒体设置读取 - const enableVideoDownload = document.getElementById("enableVideoDownload").checked; - const videoSavePath = document.getElementById("videoSavePath").value.trim() || "/Users/zscc.in/Desktop/船仓文件/Obsidian/OB/00-资料库/附件/视频/2026"; - const videoDurationThreshold = parseFloat(document.getElementById("videoDurationThreshold").value) || 5; - const showSiteSaveIcon = document.getElementById("showSiteSaveIcon").checked; - - if (!savePaths.length) { - showToast("请至少添加一个保存路径", true); + if (saveToObsidian && !savePaths.length) { + showToast("Obsidian 模式下请至少添加一个保存路径", true); return; } + // 飞书验证 + const saveToFeishu = document.getElementById("saveToFeishu").checked; + if (saveToFeishu) { + const appId = document.getElementById("feishuAppId").value.trim(); + const appSecret = document.getElementById("feishuAppSecret").value.trim(); + const appToken = document.getElementById("feishuAppToken").value.trim(); + const tableId = document.getElementById("feishuTableId").value.trim(); + if (!appId || !appSecret || !appToken || !tableId) { + showToast("飞书多维表格的 App ID、App Secret、app_token、table_id 均为必填", true); + return; + } + } + + // Notion 验证 + const saveToNotion = document.getElementById("saveToNotion").checked; + if (saveToNotion) { + const token = document.getElementById("notionToken").value.trim(); + const dbId = document.getElementById("notionDatabaseId").value.trim(); + if (!token || !dbId) { + showToast("Notion 的 Token 和 Database ID 为必填", true); + return; + } + } + + // 平台分类文件夹 + const platformFolderNames = {}; + document.querySelectorAll(".platform-folder-input").forEach((input) => { + const platform = input.dataset.platform; + if (platform) platformFolderNames[platform] = input.value.trim() || platform; + }); + + const port = parseInt(document.getElementById("portInput").value) || 9527; + const syncEnabled = document.getElementById("syncEnabled").checked; + const newConfig = { + // 主题 + theme: document.getElementById("themeSelect").value, + // 保存目标 + save_to_obsidian: saveToObsidian, + save_to_feishu: saveToFeishu, + save_to_notion: saveToNotion, + export_html: document.getElementById("exportHtml").checked, + // 服务 port, - filename_format: filenameFormat, - max_filename_length: maxLen, + filename_format: document.getElementById("filenameFormat").value.trim() || "{summary}_{date}_{author}", + max_filename_length: parseInt(document.getElementById("maxLen").value) || 60, save_paths: savePaths, - enable_video_download: enableVideoDownload, - video_save_path: videoSavePath, - video_duration_threshold: videoDurationThreshold, - show_site_save_icon: showSiteSaveIcon, + // 飞书 + feishu_api_domain: document.getElementById("feishuApiDomain").value, + feishu_app_id: document.getElementById("feishuAppId").value.trim(), + feishu_app_secret: document.getElementById("feishuAppSecret").value.trim(), + feishu_app_token: document.getElementById("feishuAppToken").value.trim(), + feishu_table_id: document.getElementById("feishuTableId").value.trim(), + feishu_upload_md: document.getElementById("feishuUploadMd").checked, + feishu_upload_html: document.getElementById("feishuUploadHtml").checked, + // Notion + notion_token: document.getElementById("notionToken").value.trim(), + notion_database_id: document.getElementById("notionDatabaseId").value.trim(), + 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() || "评论数", + // HTML + html_export_folder: document.getElementById("htmlExportFolder").value.trim() || "X2MD导出", + // 媒体 + enable_video_download: document.getElementById("enableVideoDownload").checked, + video_save_path: document.getElementById("videoSavePath").value.trim(), + video_duration_threshold: parseFloat(document.getElementById("videoDurationThreshold").value) || 5, + show_site_save_icon: document.getElementById("showSiteSaveIcon").checked, + enable_copy_unlock: document.getElementById("enableCopyUnlock").checked, + enable_wechat_video_channel: document.getElementById("enableWechatVideoChannel").checked, + sync_enabled: syncEnabled, + // 平台 + enable_platform_folders: document.getElementById("enablePlatformFolders").checked, + platform_folder_names: platformFolderNames, + // 图片 + download_images: document.getElementById("downloadImages").checked, + image_subfolder: document.getElementById("imageSubfolder").value.trim() || "assets", + // 覆盖 + overwrite_existing: document.getElementById("overwriteExisting").checked, + // 评论 + enable_comments: document.getElementById("enableComments").checked, + comments_display: document.getElementById("commentsDisplay").value || "details", + max_comments: parseInt(document.getElementById("maxComments").value) || 200, + comment_floor_range: document.getElementById("commentFloorRange").value.trim(), + // Discourse + discourse_domains: [..._discourseDomains], + // 嵌入 + embed_mode: document.getElementById("embedMode").value || "local", }; document.getElementById("portLabel").textContent = port; chrome.runtime.sendMessage({ action: "update_config", config: newConfig }, (resp) => { + if (chrome.runtime.lastError) { + showToast("扩展通信失败,请刷新页面重试", true); + return; + } if (resp && resp.success) { currentConfig = newConfig; - showToast("✅ 设置已保存"); + saveConfigToLocal(newConfig); + updateSyncStatus(syncEnabled); + showToast("设置已保存"); } else { - showToast("❌ 保存失败,服务是否在线?", true); + // 即使服务不在线,也保存到本地 + saveConfigToLocal(newConfig); + showToast("服务未连接,设置已保存到本地缓存", true); } }); } -// ───────────────────────────────────────────── -// Toast 提示 -// ───────────────────────────────────────────── +// ─── Toast ─── function showToast(msg, isError = false) { const t = document.getElementById("toast"); t.textContent = msg; diff --git a/extension/popup.js b/extension/popup.js index 1c976ed..a11e984 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,5 +1,6 @@ // popup.js chrome.runtime.sendMessage({ action: "ping" }, (resp) => { + if (chrome.runtime.lastError) { /* 扩展上下文失效,忽略 */ return; } const dot = document.getElementById("dot"); const txt = document.getElementById("status-text"); const online = resp && resp.online; @@ -8,15 +9,36 @@ chrome.runtime.sendMessage({ action: "ping" }, (resp) => { }); chrome.runtime.sendMessage({ action: "get_config" }, (resp) => { + if (chrome.runtime.lastError) { return; } const list = document.getElementById("path-list"); + const hint = document.getElementById("status-hint"); if (!resp || !resp.success) { - list.innerHTML = '
        无法读取配置
        '; + list.textContent = ""; + const errDiv = document.createElement("div"); + errDiv.className = "path-item"; + errDiv.style.color = "#f4212e"; + errDiv.textContent = "无法读取配置"; + list.appendChild(errDiv); return; } + // 动态更新端口显示 + if (resp.config && resp.config.port && hint) { + hint.textContent = `localhost:${resp.config.port}`; + } const paths = (resp.config && resp.config.save_paths) || []; if (!paths.length) { - list.innerHTML = '
        未配置保存路径
        '; + list.textContent = ""; + const emptyDiv = document.createElement("div"); + emptyDiv.className = "path-item"; + emptyDiv.textContent = "未配置保存路径"; + list.appendChild(emptyDiv); return; } - list.innerHTML = paths.map(p => `
        ${p}
        `).join(""); + list.textContent = ""; + paths.forEach(p => { + const div = document.createElement("div"); + div.className = "path-item"; + div.textContent = p; + list.appendChild(div); + }); }); diff --git a/extension/site_actions.js b/extension/site_actions.js index 47028b5..eb06e42 100644 --- a/extension/site_actions.js +++ b/extension/site_actions.js @@ -5,18 +5,38 @@ return config.show_site_save_icon !== false; } + // 复用 discourse.js 的域名列表,避免双份存储不同步 + // getDiscourseDomains 由 discourse.js 通过全局/X2MD 导出 + function _getDiscourseDomainsList() { + return (globalScope.X2MD?.getDiscourseDomains || globalScope.getDiscourseDomains || (() => ["linux.do"]))(); + } + // 保留向后兼容(调用方可能仍调用此函数),但实际更新 discourse.js 的存储 + function setSiteDiscourseDomains(domains) { + const setter = globalScope.X2MD?.setDiscourseDomains || globalScope.setDiscourseDomains; + if (typeof setter === "function") setter(domains); + } + 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 (_getDiscourseDomainsList().includes(hostname) && /^\/t\/[^/]+\/\d+(?:\/\d+)?\/?$/.test(pathname)) { + return hostname === "linux.do" ? "linux_do" : "discourse"; } - 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"; } + 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"; } @@ -43,6 +63,24 @@ }; } + if (siteKey === "discourse") { + return { + label: "MD", + title: "保存当前 Discourse 内容为 Markdown", + background: "#f97316", + shadow: "rgba(249, 115, 22, 0.35)", + }; + } + + if (siteKey === "feishu_chat") { + return { + label: "MD", + title: "保存当前飞书聊天记录为 Markdown", + background: "#1677ff", + shadow: "rgba(22, 119, 255, 0.35)", + }; + } + if (siteKey === "wechat") { return { label: "MD", @@ -60,11 +98,13 @@ detectFloatingSaveSite, getFloatingSaveSiteConfig, isFloatingSaveIconEnabled, + setSiteDiscourseDomains, }; if (typeof module !== "undefined" && module.exports) { module.exports = exported; } + globalScope.X2MD = Object.assign(globalScope.X2MD || {}, exported); Object.assign(globalScope, exported); })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/extension/tests/discourse.test.js b/extension/tests/discourse.test.js index 94d0595..0b8e25f 100644 --- a/extension/tests/discourse.test.js +++ b/extension/tests/discourse.test.js @@ -198,7 +198,7 @@ test("extractLinuxDoMarkdown keeps discourse originals with site-specific rules" "", "详情见 [Iberxilong](https://linux.do/u/iberxilong)", "", - "![](https://cdn3.linux.do/original/4X/8/8/e/example.png)", + "![example](https://cdn3.linux.do/original/4X/8/8/e/example.png)", "", "```js", "console.log('linux.do');", diff --git a/extension/twitter_graphql.js b/extension/twitter_graphql.js index 8250f9e..54b40d4 100644 --- a/extension/twitter_graphql.js +++ b/extension/twitter_graphql.js @@ -75,7 +75,7 @@ } catch (error) { } } - return normalizeOperationIdList(urls); + return Array.from(new Set(urls.filter(function (u) { return typeof u === "string" && u.trim() !== ""; }))); } function buildTweetDetailVariables(tweetId) { @@ -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 95855c1..8e41955 100644 --- a/extension/wechat.js +++ b/extension/wechat.js @@ -88,6 +88,47 @@ if (shouldSkipWechatNode(node)) return ""; const tag = getTagName(node); + const classList = getClassList(node); + + // 语音消息 + if (tag === "mpvoice" || classList.includes("voice_player_inner")) { + return "\n> [语音消息]\n"; + } + + // 视频号 / 内嵌视频:提取实际 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")) { + const title = safeGetAttribute(node, "data-title") || getNodeText(node).trim() || "小程序"; + return `\n> [小程序: ${title}]\n`; + } // 图片:微信用 data-src 做懒加载 if (tag === "img") { @@ -120,13 +161,23 @@ } // 微信常用 code_snippet_box 样式的代码块 - if (getClassList(node).includes("code_snippet_box") || getClassList(node).includes("code-snippet__fix")) { + if (classList.includes("code_snippet_box") || classList.includes("code-snippet__fix")) { const codeNode = node.querySelector?.("code, pre") || node; const code = cleanZeroWidth(codeNode.innerText || codeNode.textContent || "").trim(); if (!code) return ""; return `\n\`\`\`\n${code}\n\`\`\`\n`; } + // 列表容器:传递嵌套深度给子节点 + if (tag === "ul" || tag === "ol") { + const parentDepth = options._listDepth || 0; + let listMd = ""; + for (const child of node.childNodes || []) { + listMd += convertWechatNodeToMarkdown(child, { ...options, _listDepth: parentDepth + 1 }); + } + return `\n${listMd}\n`; + } + // 递归子节点 let markdown = ""; for (const child of node.childNodes || []) { @@ -145,8 +196,9 @@ const text = markdown.trim(); if (!href || !text || href.startsWith("javascript:")) return markdown; if (text.includes("![](")) return text; - // 微信内部跳转链接也保留 - return `[${text}](${href})`; + // 微信内部跳转链接转为绝对路径 + const absHref = href.startsWith("/") ? `https://mp.weixin.qq.com${href}` : href; + return `[${escapeMdLinkText(text)}](${escapeMdLinkUrl(absHref)})`; } // 加粗 @@ -167,7 +219,9 @@ if (tag === "h1") return `\n# ${markdown.replace(/\*\*/g, "").trim()}\n`; if (tag === "h2") return `\n## ${markdown.replace(/\*\*/g, "").trim()}\n`; if (tag === "h3") return `\n### ${markdown.replace(/\*\*/g, "").trim()}\n`; - if (tag === "h4" || tag === "h5" || tag === "h6") return `\n#### ${markdown.replace(/\*\*/g, "").trim()}\n`; + if (tag === "h4") return `\n#### ${markdown.replace(/\*\*/g, "").trim()}\n`; + if (tag === "h5") return `\n##### ${markdown.replace(/\*\*/g, "").trim()}\n`; + if (tag === "h6") return `\n###### ${markdown.replace(/\*\*/g, "").trim()}\n`; // 引用 if (tag === "blockquote") { @@ -180,12 +234,14 @@ if (tag === "li") { const parent = node.parentElement; const parentTag = getTagName(parent); + const depth = Math.max(0, (options._listDepth || 1) - 1); + const indent = " ".repeat(depth); if (parentTag === "ol") { const siblings = Array.from(parent?.children || []); const index = siblings.indexOf(node) + 1; - return `\n${index}. ${markdown.trim()}\n`; + return `\n${indent}${index}. ${markdown.trim()}\n`; } - return `\n- ${markdown.trim()}\n`; + return `\n${indent}- ${markdown.trim()}\n`; } // section 中用内联样式模拟标题的情况 @@ -198,8 +254,17 @@ } } + // 表格处理:转换为 GFM pipe table(使用共享函数) + if (tag === "table") { + const result = convertTableToGfm(node, convertWechatNodeToMarkdown, options); + if (result) return result; + } + if (tag === "tr" || tag === "td" || tag === "th" || tag === "thead" || tag === "tbody") { + return markdown; + } + // 块级元素换行 - const blockTags = new Set(["p", "div", "section", "article", "ul", "ol", "figure", "figcaption", "table"]); + const blockTags = new Set(["p", "div", "section", "article", "figure", "figcaption"]); if (blockTags.has(tag)) { return `\n${markdown}\n`; } @@ -269,17 +334,174 @@ 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); + } + + /** + * 尝试移除微信付费文章的遮罩层,暴露完整内容。 + * 付费文章通过 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) return null; + if (!articleContent && !isPaid) return null; 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()); + }); + + // 提取图片 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" + finalContent; + } + } + return { type: "article", url: cleanWechatUrl(pageUrl), @@ -288,19 +510,23 @@ author_url: "", published: extractWechatPublished(doc), article_title: title, - article_content: articleContent, - images: [], - videos: [], - platform: "WeChat", + article_content: finalContent, + images, + videos, + platform: "微信公众号", + tags, }; } const exported = { cleanWechatUrl, convertWechatNodeToMarkdown, + detectWechatPaywall, extractWechatDocumentData, extractWechatMarkdown, + extractWechatVideos, isWechatArticlePage, + removeWechatPaywall, resolveWechatImageUrl, }; @@ -308,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/requirements.txt b/requirements.txt index 01df6cc..c27aea5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pystray>=0.19 Pillow>=10.0 certifi>=2024.0 +tzdata>=2024.1 diff --git a/server.py b/server.py index 7ab4bda..490825d 100644 --- a/server.py +++ b/server.py @@ -6,18 +6,30 @@ 转换为 Obsidian 兼容的 Markdown 文件并保存到指定目录。 """ +import copy import json import os import re import ssl import sys import logging +import traceback import urllib.request from concurrent.futures import ThreadPoolExecutor -from datetime import datetime -from http.server import BaseHTTPRequestHandler, HTTPServer +from datetime import datetime, timezone, timedelta +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs, urlencode, urlunparse +# 北京时间(UTC+8):优先使用 zoneinfo,Windows 打包时可能缺少 tzdata 则回退到 timedelta +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)) + def _build_ssl_context(): """构建 SSL 上下文:优先使用 certifi 证书包,否则回退到不验证模式""" @@ -43,7 +55,7 @@ def get_app_dir(): if sys.platform == "darwin": d = os.path.join(os.path.expanduser("~"), "Library", "Application Support", "X2MD") elif sys.platform == "win32": - d = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "X2MD") + d = os.path.join(os.environ.get("APPDATA") or os.path.expanduser("~"), "X2MD") else: if getattr(sys, 'frozen', False): return os.path.dirname(sys.executable) @@ -66,51 +78,101 @@ def get_app_dir(): "video_save_path": os.path.join(HOME, "Desktop", "X2MD", "Videos"), "show_site_save_icon": True, "setup_completed": False, + # 视频下载(扩展侧使用) + "enable_video_download": True, + "video_duration_threshold": 5, + # 按平台分类子文件夹(V1.2 新增) + "enable_platform_folders": True, + "platform_folder_names": { + "Twitter/X": "Twitter", + "LINUX DO": "LINUX DO", + "飞书": "飞书", + "微信公众号": "微信公众号", + }, + # 图片下载到本地(V1.2 新增) + "download_images": True, + "image_subfolder": "assets", + # 微信视频号视频保存(默认关闭) + "enable_wechat_video_channel": False, + # 覆盖已有同源文件(默认关闭) + "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")] +if sys.stdout is not None: + try: + _stream_out = (open(sys.stdout.fileno(), mode='w', encoding='utf-8', closefd=False) + if sys.platform == "win32" else sys.stdout) + _log_handlers.append(logging.StreamHandler(_stream_out)) + except (AttributeError, OSError): + pass logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[ - logging.FileHandler(os.path.join(APP_DIR, "x2md.log"), encoding="utf-8"), - logging.StreamHandler(sys.stdout) - ] + 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 -_video_executor = ThreadPoolExecutor(max_workers=3) +_config_lock = threading.Lock() +_save_lock = threading.Lock() # 保护文件写入,防止并发写冲突 +_media_executor = ThreadPoolExecutor(max_workers=5) def load_config() -> dict: - """加载配置文件,优先返回缓存,不存在则从磁盘读取""" + """加载配置文件,优先返回缓存,不存在则从磁盘读取(线程安全)""" global _config_cache if _config_cache is not None: return _config_cache - if os.path.exists(CONFIG_FILE): - try: - with open(CONFIG_FILE, "r", encoding="utf-8") as f: - cfg = json.load(f) - for k, v in DEFAULT_CONFIG.items(): - if k not in cfg: - cfg[k] = v - _config_cache = cfg - return cfg - except Exception as e: - logger.warning(f"配置文件读取失败,使用默认配置:{e}") - _config_cache = DEFAULT_CONFIG.copy() - save_config(_config_cache) - return _config_cache + logger.info(f"首次加载配置文件: {CONFIG_FILE}") + with _config_lock: + # double-check after acquiring lock + if _config_cache is not None: + return _config_cache + + if os.path.exists(CONFIG_FILE): + 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}") + else: + logger.info("配置文件不存在,使用默认配置") + _config_cache = copy.deepcopy(DEFAULT_CONFIG) + save_config(_config_cache) + return _config_cache def save_config(cfg: dict): - """保存配置到文件并刷新缓存""" + """保存配置到文件并刷新缓存(线程安全)""" global _config_cache - with open(CONFIG_FILE, "w", encoding="utf-8") as f: - json.dump(cfg, f, ensure_ascii=False, indent=2) - _config_cache = cfg + with _config_lock: + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + _config_cache = cfg def normalize_image_url(url: str) -> str: @@ -125,40 +187,194 @@ def normalize_image_url(url: str) -> str: def sanitize_filename(name: str, max_len: int = 60) -> str: - """清理文件名中的非法字符""" + """清理文件名中的非法字符(兼容 Windows)""" name = re.sub(r'[\\/:*?"<>|]', "_", name) name = re.sub(r'\s+', " ", name.strip()) - return name[:max_len] + name = name[:max_len] + # Windows 不允许文件名以 . 或空格结尾 + name = name.rstrip(". ") + return name or "untitled" + + +def find_existing_file_by_source_url(directory: str, source_url: str) -> str | None: + """在目录中查找 front matter 里 源: 字段匹配的 .md 文件,返回文件路径或 None""" + if not source_url or not os.path.isdir(directory): + return None + try: + for fname in os.listdir(directory): + if not fname.endswith(".md"): + continue + fpath = os.path.join(directory, fname) + try: + with open(fpath, "r", encoding="utf-8") as f: + # 只读取前 15 行检查 front matter + in_fm = False + for i, line in enumerate(f): + if i == 0 and line.strip() == "---": + in_fm = True + continue + if in_fm and line.strip() == "---": + break + if in_fm and line.startswith('源:'): + existing_url = line.split(':', 1)[1].strip().strip('"').strip("'") + if existing_url == source_url: + return fpath + if i > 30: + break + except Exception: + continue + except Exception: + pass + return None + + +def _download_file(url: str, out_file: str, label: str = "文件"): + """通用文件下载(阻塞),供线程池任务调用""" + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + ssl_ctx = _build_ssl_context() + with urllib.request.urlopen(req, context=ssl_ctx, timeout=60) as response, \ + open(out_file, 'wb') as fh: + while True: + chunk = response.read(8192) + if not chunk: + break + fh.write(chunk) + return True + except Exception as e: + logger.error(f"❌ {label}下载失败 [{url[:80]}]: {e}") + return False def download_video_async(url: str, save_path: str, filename: str): - """提交视频下载任务到线程池(限并发 3),避免阻塞 HTTP 响应""" + """提交视频下载任务到线程池,避免阻塞 HTTP 响应""" def _download(): - try: - logger.info(f"开启长视频下载通道: {url} -> {save_path}/{filename}") - os.makedirs(save_path, exist_ok=True) - out_file = os.path.join(save_path, filename) - - req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) - ssl_ctx = _build_ssl_context() - with urllib.request.urlopen(req, context=ssl_ctx) as response, open(out_file, 'wb') as out_file_handle: - while True: - chunk = response.read(8192) - if not chunk: - break - out_file_handle.write(chunk) - - logger.info(f"✅ 视频文件下载成功: {out_file}") - except Exception as e: - logger.error(f"❌ 视频流下载失败: {e}") + logger.info(f"开启视频下载: {url[:80]} -> {save_path}/{filename}") + os.makedirs(save_path, exist_ok=True) + out_file = os.path.join(save_path, filename) + if _download_file(url, out_file, "视频"): + logger.info(f"✅ 视频下载成功: {out_file}") + + _media_executor.submit(_download) + + +def download_image_async(url: str, save_dir: str, filename: str): + """提交图片下载任务到线程池""" + def _download(): + os.makedirs(save_dir, exist_ok=True) + out_file = os.path.join(save_dir, filename) + if os.path.exists(out_file): + return # 已存在则跳过 + if _download_file(url, out_file, "图片"): + logger.info(f"✅ 图片下载成功: {out_file}") - _video_executor.submit(_download) + _media_executor.submit(_download) -def build_markdown(data: dict, cfg: dict) -> tuple[str, str]: +def _guess_image_ext(url: str) -> str: + """从 URL 中猜测图片扩展名""" + parsed = urlparse(url) + path = parsed.path.lower() + for ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'): + if ext in path: + return ext + # 检查 format 参数(微信/推特常用) + params = parse_qs(parsed.query) + fmt = params.get('wx_fmt', params.get('format', ['']))[0].lower() + ext_map = {'jpeg': '.jpg', 'jpg': '.jpg', 'png': '.png', 'gif': '.gif', 'webp': '.webp'} + return ext_map.get(fmt, '.jpg') + + +def _normalize_publish_time(raw: str) -> str: + """将各平台不同格式的发布时间统一为北京时间 YYYY-MM-DD HH:MM""" + if not raw or not raw.strip(): + return "" + raw = raw.strip() + beijing = BEIJING_TZ + + # 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" / "...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).replace(tzinfo=timezone.utc) + dt = dt.astimezone(beijing) + return dt.strftime("%Y-%m-%d %H:%M") + except ValueError: + continue + # 已经是 YYYY-MM-DD 格式 + if re.match(r'^\d{4}-\d{2}-\d{2}$', raw): + return raw + # 无法解析,原样返回 + 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, list]: """ 将接收到的推文/文章数据构建为 Markdown 字符串。 - 返回 (文件名不含后缀, markdown内容) + 返回 (文件名不含后缀, markdown内容, 图片下载任务列表, 视频下载任务列表) + 图片下载任务: [(url, subfolder, filename), ...] + 视频下载任务: [(url, subfolder, filename), ...] """ author = data.get("author", "unknown") handle = data.get("handle", "") @@ -174,37 +390,73 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str]: thread_tweets = data.get("thread_tweets", []) # 线程推文列表 platform = data.get("platform", "Twitter/X") - now = datetime.now() + # 微信视频号:未启用时过滤掉视频号视频和占位符 + if platform == "微信公众号" and not cfg.get("enable_wechat_video_channel", False): + videos = [] + if article_content: + article_content = re.sub( + r'\n?\[MEDIA_VIDEO_URL:[^\]]*\]\n?', + '\n> [视频号视频 - 未启用自动保存]\n', + article_content, + ) + + # 标签:优先使用平台提供的 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(BEIJING_TZ) date_str = now.strftime("%Y-%m-%d") datetime_str = now.strftime("%Y-%m-%d %H:%M") + # 规范化发布时间格式(各平台格式不一,统一为 YYYY-MM-DD HH:MM 或 YYYY-MM-DD) + published = _normalize_publish_time(published) + # ── 文件名(按配置格式构建)───────────── summary_src = article_title if article_title else text + # 清理可能残留的视频/媒体占位符(避免污染文件名) + summary_src = re.sub(r'\[MEDIA_VIDEO_URL:[^\]]*\]', '', summary_src) + 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") fmt = cfg.get("filename_format", "{summary}_{date}_{author}") filename = (fmt .replace("{date}", date_str) .replace("{author}", author_clean) + .replace("{handle}", handle_clean) + .replace("{timestamp}", timestamp_str) .replace("{summary}", summary_short)) - # 去除文件名里可能产生的多余下划线 + # 去除文件名里可能产生的多余下划线,并限制最终长度(Windows 255 字符限制) filename = re.sub(r'_+', '_', filename).strip('_') + filename = sanitize_filename(filename, max_len) # ── Front Matter ────────────────────────── title_src = article_title if article_title else text # 去除换行和多余空白,保证 Front Matter 单行合法 title = " ".join(title_src.split()) - title = title[:80] + ("…" if len(title) > 80 else "") + # 清理 title 中可能残留的视频/媒体占位符 + title = re.sub(r'\[MEDIA_VIDEO_URL:[^\]]*\]', '', title) + title = re.sub(r'\[\[VIDEO_HOLDER_\d+\]\]', '', title) + title = " ".join(title.split()).strip() + 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") - if author_url is None: - author_url = f"https://x.com/{handle.lstrip('@')}" if handle else "" + author_url = data.get("author_url") or "" + 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: "{title}" -tags: [] +标题: "{title}" +tags: {tags_yaml} 源: "{url}" 作者主页: "{author_url}" 创建时间: "{datetime_str}" @@ -219,29 +471,54 @@ def build_markdown(data: dict, cfg: dict) -> tuple[str, str]: # ── 正文构建 ──────────────────────────────────── # 原则:仅保留原文内容,不包含作者名和时间(那些已在 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 = {} - save_dir = cfg.get("video_save_path", os.path.join(HOME, "Desktop", "X2MD", "Videos")) - + video_tasks = [] # (url, subfolder, filename) — 与图片同结构,在 _handle_save 中处理 + embed_mode = cfg.get("embed_mode", "local") + all_videos = list(videos) for t in thread_tweets: all_videos.extend(t.get("videos", [])) - + video_idx = 1 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" - download_video_async(vid_url, save_dir, vid_filename) + video_tasks.append((vid_url, image_subfolder, vid_filename)) + # 使用相对路径引用,与图片一致,Obsidian 可直接识别 vid_map[vid_url] = f"![[{vid_filename}]]" video_idx += 1 else: - vid_map[vid_url] = f"🎞️ [推特媒体:点击播放视频]({vid_url})" + vid_map[vid_url] = f"[视频:点击播放]({vid_url})" + + # 记录已在正文中内联使用的视频 URL(在占位符被替换前记录) + _inlined_videos = set() - 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 replace_video_placeholders(text_content): + """替换 [MEDIA_VIDEO_URL:xxx] 占位符为实际视频引用,并记录已内联的视频""" + result = text_content + for v_url, md_ref in vid_map.items(): + target = f"[MEDIA_VIDEO_URL:{v_url}]" + if target in result: + result = result.replace(target, md_ref) + _inlined_videos.add(v_url) + return result + + def append_unused_videos(lines_list, 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 v not in _inlined_videos] if unused_vids: lines_list.append("") for v in unused_vids: @@ -251,67 +528,134 @@ def append_unused_videos(lines_list, content_text): if download_video and videos: images = [img for img in images if "video_thumb" not in img] + # ── 图片本地化处理 ──────────────────────────── + do_download_images = cfg.get("download_images", True) + image_tasks = [] # (url, save_dir_placeholder, local_filename) + _img_counter = [0] # 用列表做闭包可变计数器 + + def make_image_ref(img_url: str, alt: str = "") -> str: + """生成图片引用:本地模式返回 Obsidian wiki-link,否则返回远程 URL""" + orig_url = normalize_image_url(img_url) + if not do_download_images: + return f"![{alt}]({orig_url})" + _img_counter[0] += 1 + ext = _guess_image_ext(orig_url) + local_name = f"{filename}_img_{_img_counter[0]}{ext}" + image_tasks.append((orig_url, image_subfolder, local_name)) + return f"![[{local_name}]]" + + # ── 处理 article_content 中已内嵌的远程图片链接 ── + def localize_article_images(md_text: str) -> str: + """将 article_content 中的 ![xxx](remote_url) 替换为本地路径""" + if not do_download_images: + return md_text + def _repl(m): + alt_text = m.group(1) + img_url = m.group(2) + # 跳过非 HTTP(S) 的 URL(如 feishu-image://token),替换为说明文本 + if not img_url.startswith(("http://", "https://")): + if img_url.startswith("feishu-image://"): + return f"> [飞书图片 - 需在飞书客户端中查看]" + return m.group(0) + return make_image_ref(img_url, alt_text) + return re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', _repl, md_text) + if content_type == "article": # X Article 图片嵌入(作为封面或母贴遗留的前导图放在顶端) if images: for i, img_url in enumerate(images): - orig_url = normalize_image_url(img_url) - lines.append(f"![{i+1}]({orig_url})") + lines.append(make_image_ref(img_url, str(i + 1))) lines.append("") - append_unused_videos(lines, article_content) - # X Article:直接输出正文(已由 content.js 转换为 Markdown 段落) if article_content: - text_result = article_content.strip() - for v_url, md_ref in vid_map.items(): - target = f"[MEDIA_VIDEO_URL:{v_url}]" - text_result = text_result.replace(target, md_ref) + text_result = replace_video_placeholders(article_content.strip()) + text_result = localize_article_images(text_result) lines.append(text_result) + + append_unused_videos(lines) else: # 普通推文:只输出推文原文 - text_result = text.strip() - for v_url, md_ref in vid_map.items(): - target = f"[MEDIA_VIDEO_URL:{v_url}]" - if target in text_result: - text_result = text_result.replace(target, md_ref) + text_result = replace_video_placeholders(text.strip()) lines.append(text_result) # ── 图片嵌入(首条推文的图片) if images: lines.append("") for i, img_url in enumerate(images): - orig_url = normalize_image_url(img_url) - lines.append(f"![{i+1}]({orig_url})") - - append_unused_videos(lines, text_result) + lines.append(make_image_ref(img_url, str(i + 1))) + + append_unused_videos(lines) # ── 线程推文(长推文)─────────────────────────── for idx, tw in enumerate(thread_tweets): tw_text = tw.get("text", "").strip() tw_images = tw.get("images", []) tw_videos = tw.get("videos", []) - + if not tw_text and not tw_images and not tw_videos: continue lines.append("\n---\n") if tw_text: lines.append(tw_text) - + if tw_images: lines.append("") for i, img_url in enumerate(tw_images): - orig_url = normalize_image_url(img_url) - lines.append(f"![{idx+2}-{i+1}]({orig_url})") - + lines.append(make_image_ref(img_url, f"{idx+2}-{i+1}")) + if tw_videos: lines.append("") for v_url in tw_videos: if v_url in vid_map: lines.append(vid_map[v_url]) + _inlined_videos.add(v_url) body = "\n".join(lines) - return filename, front_matter + "\n" + body + # 规范化:消除连续3+空行为2行(1个空行),防止排版碎裂 + body = re.sub(r'\n{3,}', '\n\n', body) + + # ── 评论/回复渲染 ──────────────────────────────── + 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 class X2MDHandler(BaseHTTPRequestHandler): @@ -320,8 +664,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") @@ -333,28 +695,38 @@ 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.0.8"}) + 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 请求体上限 + 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 @@ -362,39 +734,81 @@ def do_POST(self): self._handle_save(data) elif path == "/config": self._handle_config_update(data) + 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: - filename, content = build_markdown(data, cfg) + 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: - logger.error(f"Markdown 构建失败:{e}") + logger.error(f"Markdown 构建失败: {e}\n{traceback.format_exc()}") self._respond(500, {"error": str(e)}) return + # ── 平台子文件夹 ────────────────────────── + platform = data.get("platform", "Twitter/X") + enable_platform_folders = cfg.get("enable_platform_folders", True) + platform_folder_names = cfg.get("platform_folder_names", {}) + saved_files = [] errors = [] for save_path in save_paths: try: - os.makedirs(save_path, exist_ok=True) - filepath = os.path.join(save_path, filename + ".md") - # 避免同名文件覆盖 - if os.path.exists(filepath): - ts = datetime.now().strftime("%H%M%S") - filepath = os.path.join(save_path, f"{filename}_{ts}.md") - with open(filepath, "w", encoding="utf-8") as f: - f.write(content) + # 计算最终保存目录 + final_dir = save_path + if enable_platform_folders: + folder_name = platform_folder_names.get(platform, platform.replace("/", "_")) + folder_name = sanitize_filename(folder_name, 50) + final_dir = os.path.join(save_path, folder_name) + + os.makedirs(final_dir, exist_ok=True) + + # 加锁保护文件查找+写入的原子性,防止并发请求写同一文件 + with _save_lock: + filepath = os.path.join(final_dir, filename + ".md") + overwrite = cfg.get("overwrite_existing", False) + source_url = data.get("url", "") + + if overwrite and source_url: + existing = find_existing_file_by_source_url(final_dir, source_url) + if existing: + filepath = existing + logger.info(f"🔁 覆盖已有文件:{filepath}") + elif os.path.exists(filepath): + ts = datetime.now().strftime("%H%M%S") + filepath = os.path.join(final_dir, f"{filename}_{ts}.md") + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + logger.info(f"✅ 已保存:{filepath}") saved_files.append(filepath) + + # ── 媒体下载到本地(异步,不需要锁)── + if image_tasks: + for img_url, subfolder, local_name in image_tasks: + img_dir = os.path.join(final_dir, subfolder) + download_image_async(img_url, img_dir, local_name) + if video_tasks: + for vid_url, subfolder, local_name in video_tasks: + vid_dir = os.path.join(final_dir, subfolder) + download_video_async(vid_url, vid_dir, local_name) + except Exception as e: logger.error(f"写入失败 [{save_path}]:{e}") errors.append(str(e)) @@ -408,14 +822,58 @@ 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", + "enable_platform_folders", "platform_folder_names", + "download_images", "image_subfolder", + "overwrite_existing", "sync_enabled", + "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", + "feishu_upload_md", "feishu_upload_html", + # Notion Database + "notion_token", "notion_database_id", + "notion_prop_title", "notion_prop_url", "notion_prop_author", + "notion_prop_tags", "notion_prop_saved_date", "notion_prop_type", + # HTML 导出 + "html_export_folder", + # 飞书一键复制 + "enable_copy_unlock", + } + def _handle_config_update(self, data: dict): - """更新配置""" - cfg = load_config() - cfg.update(data) - save_config(cfg) - logger.info(f"配置已更新:{data}") + """更新配置(仅允许白名单内的字段)""" + 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.update(filtered) + save_config(cfg) + logger.info(f"配置已更新: {list(filtered.keys())}") self._respond(200, {"success": True, "config": cfg}) + def _handle_config_reset(self): + """重置配置为默认值""" + save_config(copy.deepcopy(DEFAULT_CONFIG)) + logger.info("配置已重置为默认值") + self._respond(200, {"success": True, "config": DEFAULT_CONFIG}) + def _respond(self, code: int, payload: dict): body = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(code) @@ -427,16 +885,24 @@ def _respond(self, code: int, payload: dict): def main(): + logger.info("server.py 独立启动模式") cfg = load_config() port = cfg.get("port", 9527) - server = HTTPServer(("127.0.0.1", port), X2MDHandler) - 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 967b68c..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") # ───────────────────────────────────────────── # 路径工具 @@ -24,7 +27,7 @@ def get_app_dir(): if sys.platform == "darwin": d = os.path.join(os.path.expanduser("~"), "Library", "Application Support", "X2MD") elif sys.platform == "win32": - d = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "X2MD") + d = os.path.join(os.environ.get("APPDATA") or os.path.expanduser("~"), "X2MD") else: if getattr(sys, 'frozen', False): return os.path.dirname(sys.executable) @@ -55,9 +58,11 @@ def get_app_dir(): "success": "#00ba7c", } -# 字体(Mac 优先 SF Pro,Windows 优先微软雅黑,兜底 Inter) +# 字体(Mac 优先 PingFang SC,Windows 优先微软雅黑 UI) +# 注意:Windows 上 tkinter 使用 bold 字重渲染中文可能导致乱码, +# 使用 "Microsoft YaHei UI" 代替 "Microsoft YaHei" 可缓解此问题 IS_MAC = platform.system() == "Darwin" -FONT_FAMILY = "PingFang SC" if IS_MAC else "Microsoft YaHei" +FONT_FAMILY = "PingFang SC" if IS_MAC else "Microsoft YaHei UI" FONT = lambda size, weight="normal": (FONT_FAMILY, size, weight) @@ -65,17 +70,22 @@ 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("720x580") - self.root.resizable(False, False) + self.root.geometry("720x680") + self.root.minsize(600, 500) + self.root.resizable(True, True) self.root.configure(bg=C["bg"]) # 居中显示 self.root.update_idletasks() x = (self.root.winfo_screenwidth() - 720) // 2 - y = (self.root.winfo_screenheight() - 580) // 2 - self.root.geometry(f"720x580+{x}+{y}") + y = (self.root.winfo_screenheight() - 680) // 2 + self.root.geometry(f"720x680+{x}+{y}") # 确保窗口置顶可见(解决 LSUIElement app 窗口被遮挡的问题) self.root.lift() @@ -141,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() @@ -190,21 +201,23 @@ def _make_card(self, parent, **kw): # ── 步骤一:欢迎 ───────────────────────────── def _step_welcome(self): - tk.Label(self.content_frame, text="🔖", font=("Segoe UI Emoji", 56), + # Windows tkinter 对大号 emoji 渲染不佳,改用 Segoe UI Emoji 小号 + 兜底 + _emoji_font = ("Segoe UI Emoji", 48) if sys.platform == "win32" else (FONT_FAMILY, 56) + tk.Label(self.content_frame, text="\U0001F516", font=_emoji_font, bg=C["bg"]).pack(pady=(24, 8)) tk.Label(self.content_frame, text="欢迎使用 X2MD", 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) @@ -281,7 +294,21 @@ def _do_browse(e, sv=string_var): def _browse(self, string_var): init = string_var.get() init_dir = init if os.path.isdir(init) else os.path.dirname(init) if init else HOME - folder = filedialog.askdirectory(title="选择保存目录", initialdir=init_dir) + + # 记住当前窗口尺寸和位置(Windows 下 filedialog 会导致窗口缩小) + current_geo = self.root.geometry() + + folder = filedialog.askdirectory( + title="选择保存目录", + initialdir=init_dir, + parent=self.root # 关键:指定父窗口,防止 Windows 丢失窗口状态 + ) + + # 恢复窗口尺寸(Windows 下 filedialog 关闭后可能触发 geometry 重算) + self.root.geometry(current_geo) + self.root.deiconify() + self.root.lift() + if folder: string_var.set(folder) @@ -292,7 +319,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)) @@ -301,7 +328,7 @@ def _step_extension(self): ("❷", "打开右上角的「开发者模式」开关"), ("❸", "点击左上角「加载已解压的扩展程序」按钮"), ("❹", "选择 X2MD 安装目录下的 extension 文件夹"), - ("❺", "扩展安装完成!在推特页面上点击推文的书签按钮即可保存"), + ("❺", "安装完成!在 X/Twitter、LINUX DO、飞书、微信公众号页面使用保存按钮即可"), ] for num, text in steps: @@ -332,7 +359,8 @@ def _step_extension(self): # ── 步骤四:完成 ───────────────────────────── def _step_finish(self): - tk.Label(self.content_frame, text="✅", font=("Segoe UI Emoji", 48), + _emoji_font = ("Segoe UI Emoji", 42) if sys.platform == "win32" else (FONT_FAMILY, 48) + tk.Label(self.content_frame, text="\u2705", font=_emoji_font, bg=C["bg"]).pack(pady=(16, 8)) tk.Label(self.content_frame, text="设置完成!", @@ -362,7 +390,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)) @@ -376,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 = {} @@ -387,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({ @@ -400,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/test_full.py b/test_full.py new file mode 100644 index 0000000..2d1f99a --- /dev/null +++ b/test_full.py @@ -0,0 +1,750 @@ +#!/usr/bin/env python3 +""" +x2md 全面测试脚本 — 模拟 Windows 端完整流程 +测试覆盖: 保存、覆盖/去重、配置、各平台、路径兼容 +""" +import json +import os +import shutil +import sys +import tempfile +import time +import threading +import urllib.request +import urllib.error + +# 确保导入项目模块 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import server + +PASS = 0 +FAIL = 0 +ERRORS = [] + + +def test(name): + """装饰器:记录测试结果""" + def decorator(fn): + def wrapper(*args, **kwargs): + global PASS, FAIL + try: + fn(*args, **kwargs) + PASS += 1 + print(f" [PASS] {name}") + except Exception as e: + FAIL += 1 + ERRORS.append((name, str(e))) + print(f" [FAIL] {name}: {e}") + return wrapper + return decorator + + +# ═══════════════════════════════════════════════ +# 1. 基础函数测试 +# ═══════════════════════════════════════════════ +print("\n=== 1. 基础函数测试 ===") + + +@test("sanitize_filename: 清理非法字符") +def test_sanitize(): + assert server.sanitize_filename('hello/world:test?') == "hello_world_test_" + assert server.sanitize_filename('a' * 200, 60) == 'a' * 60 + assert server.sanitize_filename('file.name.') == "file.name" + assert server.sanitize_filename('') == "untitled" +test_sanitize() + + +@test("normalize_image_url: 推特图片 URL 标准化") +def test_normalize(): + url = "https://pbs.twimg.com/media/abc.jpg?name=small" + result = server.normalize_image_url(url) + assert "name=orig" in result + assert "name=small" not in result + # 非推特 URL 不变 + assert server.normalize_image_url("https://example.com/img.jpg") == "https://example.com/img.jpg" +test_normalize() + + +# ═══════════════════════════════════════════════ +# 2. URL 去重查找测试 +# ═══════════════════════════════════════════════ +print("\n=== 2. URL 去重查找测试 ===") + +TEMP_DIR = tempfile.mkdtemp(prefix="x2md_test_") + + +@test("find_existing_file_by_source_url: 找到匹配文件") +def test_find_url_match(): + fpath = os.path.join(TEMP_DIR, "test_match.md") + with open(fpath, "w", encoding="utf-8") as f: + f.write('---\n标题: "Test"\n源: "https://x.com/user/status/123"\n---\nContent\n') + result = server.find_existing_file_by_source_url(TEMP_DIR, "https://x.com/user/status/123") + assert result == fpath, f"Expected {fpath}, got {result}" +test_find_url_match() + + +@test("find_existing_file_by_source_url: URL 不匹配返回 None") +def test_find_url_no_match(): + result = server.find_existing_file_by_source_url(TEMP_DIR, "https://x.com/other/456") + assert result is None +test_find_url_no_match() + + +@test("find_existing_file_by_source_url: 空目录返回 None") +def test_find_url_empty_dir(): + empty = os.path.join(TEMP_DIR, "empty_sub") + os.makedirs(empty, exist_ok=True) + result = server.find_existing_file_by_source_url(empty, "https://x.com/user/status/123") + assert result is None +test_find_url_empty_dir() + + +@test("find_existing_file_by_source_url: 目录不存在返回 None") +def test_find_url_no_dir(): + result = server.find_existing_file_by_source_url("/nonexistent/dir", "https://x.com/test") + assert result is None +test_find_url_no_dir() + + +# ═══════════════════════════════════════════════ +# 3. build_markdown 测试(各平台) +# ═══════════════════════════════════════════════ +print("\n=== 3. build_markdown 各平台测试 ===") + +BASE_CFG = { + "filename_format": "{summary}_{date}_{author}", + "max_filename_length": 60, + "download_images": False, + "image_subfolder": "assets", +} + + +@test("build_markdown: Twitter 推文") +def test_build_twitter(): + data = { + "author": "TestUser", + "handle": "@testuser", + "text": "Hello World, this is a test tweet!", + "url": "https://x.com/testuser/status/123", + "platform": "Twitter/X", + "type": "tweet", + "images": [], + "videos": [], + } + filename, content, img_tasks, _ = server.build_markdown(data, BASE_CFG) + assert filename, "Filename should not be empty" + assert '源: "https://x.com/testuser/status/123"' in content + assert '平台: "Twitter/X"' in content + assert "Hello World" in content +test_build_twitter() + + +@test("build_markdown: LinuxDo 帖子") +def test_build_linuxdo(): + data = { + "author": "linux_user", + "handle": "", + "text": "这是一个 Linux Do 帖子的内容测试", + "url": "https://linux.do/t/topic/12345", + "platform": "LinuxDo", + "type": "tweet", + "images": [], + "videos": [], + } + filename, content, _, _ = server.build_markdown(data, BASE_CFG) + assert '平台: "LinuxDo"' in content + assert "Linux Do" in content +test_build_linuxdo() + + +@test("build_markdown: 飞书文档") +def test_build_feishu(): + data = { + "author": "飞书用户", + "handle": "", + "text": "飞书文档测试内容\n\n包含多段文字", + "url": "https://xxx.feishu.cn/wiki/abc123", + "platform": "Feishu", + "type": "tweet", + "images": [], + "videos": [], + } + filename, content, _, _ = server.build_markdown(data, BASE_CFG) + assert '平台: "Feishu"' in content + assert "飞书文档测试" in content +test_build_feishu() + + +@test("build_markdown: 微信公众号") +def test_build_wechat(): + data = { + "author": "公众号作者", + "handle": "", + "text": "微信公众号文章内容", + "url": "https://mp.weixin.qq.com/s/abc123", + "platform": "WeChat", + "type": "tweet", + "images": [], + "videos": [], + } + filename, content, _, _ = server.build_markdown(data, BASE_CFG) + assert '平台: "WeChat"' in content +test_build_wechat() + + +@test("build_markdown: X Article") +def test_build_article(): + data = { + "author": "ArticleWriter", + "handle": "@writer", + "text": "Short summary", + "url": "https://x.com/writer/status/999", + "platform": "Twitter/X", + "type": "article", + "article_title": "My Long Article Title", + "article_content": "# Heading\n\nParagraph content here.", + "images": [], + "videos": [], + } + filename, content, _, _ = server.build_markdown(data, BASE_CFG) + assert "My Long Article Title" in content + assert "# Heading" in content +test_build_article() + + +# ═══════════════════════════════════════════════ +# 4. 覆盖/去重逻辑端到端测试 +# ═══════════════════════════════════════════════ +print("\n=== 4. 覆盖/去重逻辑测试 ===") + +SAVE_DIR = os.path.join(TEMP_DIR, "saves") +os.makedirs(SAVE_DIR, exist_ok=True) + + +@test("非覆盖模式: 同名文件生成时间戳后缀") +def test_no_overwrite(): + no_ow_dir = os.path.join(TEMP_DIR, "no_overwrite") + os.makedirs(no_ow_dir, exist_ok=True) + cfg = {**BASE_CFG, "overwrite_existing": False} + data = { + "author": "Dup", "handle": "@dup", "text": "First save", + "url": "https://x.com/dup/status/111", "platform": "Twitter/X", + "images": [], "videos": [], + } + # 第一次保存 + filename, content, _, _ = server.build_markdown(data, cfg) + fp1 = os.path.join(no_ow_dir, filename + ".md") + with open(fp1, "w", encoding="utf-8") as f: + f.write(content) + assert os.path.exists(fp1), "First file should exist" + + # 第二次保存同名文件,模拟 _handle_save 的时间戳逻辑 + data["text"] = "Second save" + _, content2, _, _ = server.build_markdown(data, cfg) + fp2 = os.path.join(no_ow_dir, filename + ".md") + if os.path.exists(fp2): + from datetime import datetime + ts = datetime.now().strftime("%H%M%S") + fp2 = os.path.join(no_ow_dir, f"{filename}_{ts}.md") + with open(fp2, "w", encoding="utf-8") as f: + f.write(content2) + assert fp1 != fp2, "Should be different files" + assert os.path.exists(fp1) and os.path.exists(fp2) +test_no_overwrite() + + +@test("覆盖模式: 通过 URL 查找并覆盖旧文件") +def test_overwrite_by_url(): + overwrite_dir = os.path.join(TEMP_DIR, "overwrite_test") + os.makedirs(overwrite_dir, exist_ok=True) + + source_url = "https://x.com/testoverwrite/status/222" + # 先写一个旧文件(模拟之前保存的) + old_file = os.path.join(overwrite_dir, "old_file_2026-03-20.md") + with open(old_file, "w", encoding="utf-8") as f: + f.write(f'---\n标题: "Old"\n源: "{source_url}"\n---\nOld content\n') + + # 查找应该返回旧文件 + found = server.find_existing_file_by_source_url(overwrite_dir, source_url) + assert found == old_file, f"Should find old file, got {found}" + + # 覆盖写入 + with open(found, "w", encoding="utf-8") as f: + f.write(f'---\n标题: "New"\n源: "{source_url}"\n---\nNew content\n') + + with open(old_file, "r", encoding="utf-8") as f: + assert "New content" in f.read() + + # 目录中应只有一个文件 + md_files = [f for f in os.listdir(overwrite_dir) if f.endswith(".md")] + assert len(md_files) == 1, f"Should have 1 file, got {len(md_files)}" +test_overwrite_by_url() + + +@test("覆盖模式: URL 无匹配,同名文件直接覆盖") +def test_overwrite_same_name(): + same_dir = os.path.join(TEMP_DIR, "same_name_test") + os.makedirs(same_dir, exist_ok=True) + + cfg = {**BASE_CFG, "overwrite_existing": True} + data = { + "author": "Same", "handle": "@same", "text": "Content v1", + "url": "https://x.com/same/status/333", "platform": "Twitter/X", + "images": [], "videos": [], + } + filename, content1, _, _ = server.build_markdown(data, cfg) + fp = os.path.join(same_dir, filename + ".md") + with open(fp, "w", encoding="utf-8") as f: + f.write(content1) + + # 第二次保存,覆盖模式下先通过 URL 找文件 + found = server.find_existing_file_by_source_url(same_dir, data["url"]) + if found: + target = found + else: + target = fp # 同名文件直接覆盖 + + data["text"] = "Content v2" + _, content2, _, _ = server.build_markdown(data, cfg) + with open(target, "w", encoding="utf-8") as f: + f.write(content2) + + with open(target, "r", encoding="utf-8") as f: + assert "Content v2" in f.read() +test_overwrite_same_name() + + +# ═══════════════════════════════════════════════ +# 5. 平台文件夹分类测试 +# ═══════════════════════════════════════════════ +print("\n=== 5. 平台文件夹分类测试 ===") + + +@test("平台文件夹: 各平台文件分到对应子目录") +def test_platform_folders(): + pf_dir = os.path.join(TEMP_DIR, "platform_folders") + os.makedirs(pf_dir, exist_ok=True) + + platforms = { + "Twitter/X": "Twitter", + "LinuxDo": "LinuxDo", + "Feishu": "Feishu", + "WeChat": "WeChat", + } + + for platform, folder in platforms.items(): + final_dir = os.path.join(pf_dir, folder) + os.makedirs(final_dir, exist_ok=True) + fp = os.path.join(final_dir, f"test_{folder}.md") + with open(fp, "w", encoding="utf-8") as f: + f.write(f"# Test {platform}\n") + assert os.path.exists(fp), f"File should exist: {fp}" + + # 验证目录结构 + subdirs = set(os.listdir(pf_dir)) + assert subdirs == {"Twitter", "LinuxDo", "Feishu", "WeChat"} +test_platform_folders() + + +# ═══════════════════════════════════════════════ +# 6. 配置读写测试 +# ═══════════════════════════════════════════════ +print("\n=== 6. 配置读写测试 ===") + + +@test("配置: overwrite_existing 默认为 False") +def test_config_default(): + cfg = server.DEFAULT_CONFIG + assert cfg.get("overwrite_existing") is False +test_config_default() + + +@test("配置: overwrite_existing 在白名单中") +def test_config_whitelist(): + assert "overwrite_existing" in server.X2MDHandler.ALLOWED_CONFIG_KEYS +test_config_whitelist() + + +# ═══════════════════════════════════════════════ +# 7. Windows 路径兼容性测试 +# ═══════════════════════════════════════════════ +print("\n=== 7. Windows 路径兼容性测试 ===") + + +@test("Windows 路径: 文件名不含非法字符") +def test_windows_filenames(): + bad_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|'] + for char in bad_chars: + result = server.sanitize_filename(f"file{char}name") + assert char not in result, f"Character {char} should be sanitized, got: {result}" +test_windows_filenames() + + +@test("Windows 路径: 文件名不以点或空格结尾") +def test_windows_trailing(): + assert server.sanitize_filename("file.") == "file" + assert server.sanitize_filename("file ") == "file" + assert server.sanitize_filename("file. .") == "file" # 尾部点和空格都被去掉 +test_windows_trailing() + + +@test("Windows 路径: 超长文件名截断") +def test_windows_long_name(): + long_name = "这是一个非常长的文件名" * 20 + result = server.sanitize_filename(long_name, 60) + assert len(result) <= 60 +test_windows_long_name() + + +# ═══════════════════════════════════════════════ +# 8. Front Matter 格式正确性 +# ═══════════════════════════════════════════════ +print("\n=== 8. Front Matter 测试 ===") + + +@test("Front Matter: 包含所有必需字段") +def test_front_matter_fields(): + data = { + "author": "FM-Test", "handle": "@fmtest", + "text": "Test content", "url": "https://x.com/fmtest/status/555", + "platform": "Twitter/X", "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, BASE_CFG) + required = ['标题:', 'tags:', '源:', '作者主页:', '创建时间:', '发布时间:', '平台:', '类别:', '阅读状态:', '整理:'] + for field in required: + assert field in content, f"Missing front matter field: {field}" +test_front_matter_fields() + + +@test("Front Matter: title 不含换行") +def test_front_matter_no_newline(): + data = { + "author": "NL-Test", "handle": "@nltest", + "text": "Line 1\nLine 2\nLine 3", + "url": "https://x.com/nltest/status/666", + "platform": "Twitter/X", "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, BASE_CFG) + # 提取 title 行 + for line in content.split("\n"): + if line.startswith("标题:"): + assert "\n" not in line.strip() + break +test_front_matter_no_newline() + + +# ═══════════════════════════════════════════════ +# 9. 线程推文测试 +# ═══════════════════════════════════════════════ +print("\n=== 9. 线程推文测试 ===") + + +@test("线程推文: 多条推文拼接") +def test_thread_tweets(): + data = { + "author": "Thread", "handle": "@thread", + "text": "Thread intro", + "url": "https://x.com/thread/status/777", + "platform": "Twitter/X", "images": [], "videos": [], + "thread_tweets": [ + {"text": "Tweet 1 in thread", "images": [], "videos": []}, + {"text": "Tweet 2 in thread", "images": [], "videos": []}, + ], + } + _, content, _, _ = server.build_markdown(data, BASE_CFG) + assert "Tweet 1 in thread" in content + assert "Tweet 2 in thread" in content +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 "![[" in content and "_img_1" in content + assert len(img_tasks) == 1 + + +@test("Obsidian: 视频本地引用格式 ![[video_file]]") +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 "![[" in content and "_video_1.mp4" in content + assert content.count("_video_1.mp4]]") == 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) >= 1, f"预期至少 1 处 SYNC_FIELDS 定义,实际 {len(sync_blocks)}" + + # 确认 SYNC_FIELDS 被实际使用(至少引用 2 次) + usage_count = bg_content.count("SYNC_FIELDS") + assert usage_count >= 3, f"SYNC_FIELDS 应至少被引用 3 次(1 定义 + 2 使用),实际 {usage_count}" + + new_fields = ["discourse_domains", "embed_mode", + "enable_comments", "comments_display", "max_comments", "comment_floor_range", + "enable_wechat_video_channel"] + + for field in new_fields: + assert f'"{field}"' in sync_blocks[0], \ + f"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 new file mode 100644 index 0000000..773d715 --- /dev/null +++ b/test_obsidian_render.py @@ -0,0 +1,990 @@ +#!/usr/bin/env python3 +""" +x2md Obsidian 渲染全面验证测试 +覆盖:各平台输出、Front Matter 格式、图片/视频引用、线程、Article、覆盖逻辑、Windows 兼容 +每个测试都会打印生成的 Markdown 内容,方便人工审阅。 +""" +import json +import os +import re +import shutil +import sys +import tempfile +from datetime import datetime + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import server + +PASS = 0 +FAIL = 0 +ERRORS = [] + +def test(name): + def decorator(fn): + def wrapper(*args, **kwargs): + global PASS, FAIL + try: + fn(*args, **kwargs) + PASS += 1 + print(f" [PASS] {name}") + except Exception as e: + FAIL += 1 + ERRORS.append((name, str(e))) + print(f" [FAIL] {name}: {e}") + return wrapper + return decorator + + +# ═══════════════════════════════════════════════ +# 配置 +# ═══════════════════════════════════════════════ +CFG_NO_DOWNLOAD = { + "filename_format": "{summary}_{date}_{author}", + "max_filename_length": 60, + "download_images": False, + "image_subfolder": "assets", +} +CFG_WITH_DOWNLOAD = { + "filename_format": "{summary}_{date}_{author}", + "max_filename_length": 60, + "download_images": True, + "image_subfolder": "assets", +} + +TEMP_DIR = tempfile.mkdtemp(prefix="x2md_ob_test_") + + +# ═══════════════════════════════════════════════ +# 1. Front Matter 格式验证(Obsidian YAML 合规性) +# ═══════════════════════════════════════════════ +print("\n=== 1. Front Matter YAML 合规性 ===") + +@test("FM: 以 --- 开头和结尾") +def test_fm_delimiters(): + data = { + "author": "Test", "handle": "@test", "text": "Hello", + "url": "https://x.com/test/status/1", "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + lines = content.split("\n") + assert lines[0] == "---", f"First line should be '---', got '{lines[0]}'" + # 找第二个 --- + fm_end = None + for i, line in enumerate(lines[1:], 1): + if line == "---": + fm_end = i + break + assert fm_end is not None, "No closing --- found" +test_fm_delimiters() + + +@test("FM: title 中的双引号被转义") +def test_fm_quotes(): + data = { + "author": "Test", "handle": "@test", + "text": 'He said "hello" to me', + "url": "https://x.com/test/status/2", "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + for line in content.split("\n"): + if line.startswith("标题:"): + # 双引号包裹的 title 内部不应有未转义的双引号 + val = line[len("标题:"):].strip() + # val 应该是 "'He said 'hello' to me'" + assert val.startswith('"') and val.endswith('"'), f"title not quoted: {val}" + inner = val[1:-1] + assert '"' not in inner, f"Unescaped quote in title: {inner}" + break +test_fm_quotes() + + +@test("FM: title 不含换行符") +def test_fm_no_newline(): + data = { + "author": "Test", "handle": "@test", + "text": "Line1\nLine2\nLine3", + "url": "https://x.com/test/status/3", "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + for line in content.split("\n"): + if line.startswith("标题:"): + assert "\n" not in line.strip(), "Title contains newline" + break +test_fm_no_newline() + + +@test("FM: 所有必需字段存在") +def test_fm_all_fields(): + data = { + "author": "Tester", "handle": "@tester", "text": "Check fields", + "url": "https://x.com/tester/status/4", "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + required = ['标题:', 'tags:', '源:', '作者主页:', '创建时间:', '发布时间:', '平台:', '类别:', '阅读状态:', '整理:'] + for field in required: + assert field in content, f"Missing: {field}" +test_fm_all_fields() + + +@test("FM: 类别使用 Obsidian wiki-link 格式") +def test_fm_wikilink(): + data = { + "author": "A", "handle": "@a", "text": "Test", + "url": "https://x.com/a/status/5", "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert '类别: "[[剪报]]"' in content, "类别 should use [[wiki-link]]" +test_fm_wikilink() + + +@test("FM: 阅读状态和整理为 boolean(非字符串)") +def test_fm_booleans(): + data = { + "author": "A", "handle": "@a", "text": "Test", + "url": "https://x.com/a/status/6", "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "阅读状态: false" in content, "阅读状态 should be boolean false" + assert "整理: false" in content, "整理 should be boolean false" +test_fm_booleans() + + +# ═══════════════════════════════════════════════ +# 2. 各平台 Markdown 输出验证 +# ═══════════════════════════════════════════════ +print("\n=== 2. 各平台 Markdown 输出验证 ===") + +@test("Twitter 推文: 正文+图片渲染") +def test_twitter_tweet(): + data = { + "author": "Elon", "handle": "@elonmusk", + "text": "This is a **test** tweet with some content!", + "url": "https://x.com/elonmusk/status/123", + "platform": "Twitter/X", + "images": ["https://pbs.twimg.com/media/abc.jpg?name=small"], + "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert '平台: "Twitter/X"' in content + assert "**test**" in content, "Bold should be preserved" + assert "![1](https://pbs.twimg.com/media/abc.jpg?name=orig)" in content, \ + "Image should use name=orig" +test_twitter_tweet() + + +@test("Twitter 推文(图片下载模式): 本地路径引用") +def test_twitter_local_img(): + data = { + "author": "User", "handle": "@user", + "text": "Tweet with local image", + "url": "https://x.com/user/status/200", + "platform": "Twitter/X", + "images": ["https://pbs.twimg.com/media/xyz.jpg"], + "videos": [], + } + _, content, tasks, _ = server.build_markdown(data, CFG_WITH_DOWNLOAD) + assert "![[" in content, "Should use Obsidian wiki-link format" + assert len(tasks) >= 1, "Should have image download task" + assert tasks[0][0].endswith("name=orig"), "Download URL should be normalized" +test_twitter_local_img() + + +@test("X Article: 标题+正文渲染") +def test_x_article(): + data = { + "author": "Writer", "handle": "@writer", + "text": "Short preview", + "url": "https://x.com/writer/status/300", + "platform": "Twitter/X", + "type": "article", + "article_title": "My Amazing Article", + "article_content": "# Introduction\n\nThis is a long article.\n\n## Section 2\n\nMore content here.", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert '标题: "My Amazing Article"' in content, "Article title in FM" + assert "# Introduction" in content, "H1 preserved" + assert "## Section 2" in content, "H2 preserved" + assert "This is a long article." in content +test_x_article() + + +@test("X Article: 图片内嵌+本地化") +def test_article_images(): + data = { + "author": "A", "handle": "@a", + "text": "Preview", + "url": "https://x.com/a/status/301", + "platform": "Twitter/X", + "type": "article", + "article_title": "Image Article", + "article_content": "Intro\n\n![pic](https://pbs.twimg.com/media/test.jpg)\n\nEnd", + "images": [], "videos": [], + } + _, content, tasks, _ = server.build_markdown(data, CFG_WITH_DOWNLOAD) + assert "![[" in content, "Inline image should be localized as wiki-link" + assert len(tasks) >= 1, "Should have image task" +test_article_images() + + +@test("LinuxDo 帖子: 正确渲染") +def test_linuxdo(): + data = { + "author": "linux_user", "handle": "", + "text": "这是一个 Linux Do 帖子\n\n包含多段内容\n\n**加粗文字**", + "url": "https://linux.do/t/topic/12345", + "platform": "LinuxDo", + "images": ["https://linux.do/uploads/default/abc.png"], + "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert '平台: "LinuxDo"' in content + assert "**加粗文字**" in content + assert "![1](https://linux.do/uploads/default/abc.png)" in content +test_linuxdo() + + +@test("飞书文档: article 类型渲染") +def test_feishu(): + data = { + "author": "飞书用户", "handle": "", + "text": "", + "url": "https://xxx.feishu.cn/wiki/abc123", + "platform": "Feishu", + "type": "article", + "article_title": "飞书知识库文档标题", + "article_content": "# 第一章\n\n这是正文\n\n## 第二节\n\n- 列表项1\n- 列表项2\n\n```python\nprint('hello')\n```", + "images": [], "videos": [], + "author_url": "", + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert '平台: "Feishu"' in content + assert '飞书知识库文档标题' in content + assert "# 第一章" in content + assert "- 列表项1" in content + assert "```python" in content +test_feishu() + + +@test("微信公众号: article 类型渲染") +def test_wechat(): + data = { + "author": "公众号作者", "handle": "", + "text": "", + "url": "https://mp.weixin.qq.com/s/abc123", + "platform": "WeChat", + "type": "article", + "article_title": "微信公众号文章标题", + "article_content": "# 标题\n\n正文段落\n\n> 引用内容\n\n![img](https://mmbiz.qpic.cn/abc.jpg?wx_fmt=jpeg)", + "images": [], "videos": [], + "author_url": "", + } + _, content, tasks, _ = server.build_markdown(data, CFG_WITH_DOWNLOAD) + assert '平台: "WeChat"' in content + assert "正文段落" in content + assert "> 引用内容" in content + # 图片应被本地化为 wiki-link 格式 + assert "![[" in content +test_wechat() + + +# ═══════════════════════════════════════════════ +# 3. 视频渲染验证 +# ═══════════════════════════════════════════════ +print("\n=== 3. 视频渲染验证 ===") + +@test("视频: 不下载模式显示链接") +def test_video_link(): + data = { + "author": "V", "handle": "@v", + "text": "Tweet with video", + "url": "https://x.com/v/status/400", + "platform": "Twitter/X", + "images": [], "videos": ["https://video.twimg.com/ext/abc.mp4"], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "[视频:点击播放](https://video.twimg.com/ext/abc.mp4)" in content +test_video_link() + + +@test("视频: 下载模式显示本地路径") +def test_video_download(): + data = { + "author": "V", "handle": "@v", + "text": "Tweet with video", + "url": "https://x.com/v/status/401", + "platform": "Twitter/X", + "images": [], "videos": ["https://video.twimg.com/ext/def.mp4"], + "download_video": True, + } + _, content, _, vid_tasks = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "![[" in content, "Video should use wiki-link format for local path" + assert any("video" in t[2] for t in vid_tasks), "Should have video download task" +test_video_download() + + +@test("视频占位符: [MEDIA_VIDEO_URL:xxx] 替换") +def test_video_placeholder(): + vid_url = "https://video.twimg.com/ext/placeholder.mp4" + data = { + "author": "V", "handle": "@v", + "text": "Before\n\n[MEDIA_VIDEO_URL:" + vid_url + "]\n\nAfter", + "url": "https://x.com/v/status/402", + "platform": "Twitter/X", + "images": [], "videos": [vid_url], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "[MEDIA_VIDEO_URL:" not in content, \ + f"Placeholder should be replaced everywhere (including title)" + assert vid_url in content, "Video URL should appear in content" +test_video_placeholder() + + +# ═══════════════════════════════════════════════ +# 4. 线程推文渲染 +# ═══════════════════════════════════════════════ +print("\n=== 4. 线程推文渲染 ===") + +@test("线程: 分隔线 + 各条推文内容") +def test_thread(): + data = { + "author": "Thread", "handle": "@thread", + "text": "Thread start (1/3)", + "url": "https://x.com/thread/status/500", + "platform": "Twitter/X", + "images": ["https://pbs.twimg.com/media/main.jpg"], + "videos": [], + "thread_tweets": [ + { + "text": "Second tweet in thread (2/3)", + "images": ["https://pbs.twimg.com/media/second.jpg"], + "videos": [], + }, + { + "text": "Third tweet in thread (3/3)", + "images": [], + "videos": ["https://video.twimg.com/ext/thread.mp4"], + }, + ], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "Thread start" in content + assert "---" in content, "Thread should have separator" + assert "Second tweet" in content + assert "Third tweet" in content + assert "thread.mp4" in content +test_thread() + + +@test("线程: 空推文(仅图片)不崩溃") +def test_thread_empty_text(): + data = { + "author": "T", "handle": "@t", + "text": "Main", + "url": "https://x.com/t/status/501", + "platform": "Twitter/X", + "images": [], "videos": [], + "thread_tweets": [ + {"text": "", "images": ["https://pbs.twimg.com/media/only.jpg"], "videos": []}, + {"text": "", "images": [], "videos": []}, # 完全为空应跳过 + ], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "only.jpg" in content +test_thread_empty_text() + + +# ═══════════════════════════════════════════════ +# 5. 覆盖/去重逻辑验证 +# ═══════════════════════════════════════════════ +print("\n=== 5. 覆盖/去重逻辑验证 ===") + +@test("非覆盖: 同名文件添加时间戳后缀") +def test_no_overwrite(): + d = os.path.join(TEMP_DIR, "no_ow") + os.makedirs(d, exist_ok=True) + cfg = {**CFG_NO_DOWNLOAD, "overwrite_existing": False} + data = { + "author": "Dup", "handle": "@dup", "text": "First", + "url": "https://x.com/dup/status/600", "platform": "Twitter/X", + "images": [], "videos": [], + } + fn, c1, _, _ = server.build_markdown(data, cfg) + fp1 = os.path.join(d, fn + ".md") + with open(fp1, "w", encoding="utf-8") as f: + f.write(c1) + + data["text"] = "Second" + fn2, c2, _, _ = server.build_markdown(data, cfg) + fp2 = os.path.join(d, fn2 + ".md") + if os.path.exists(fp2): + ts = datetime.now().strftime("%H%M%S") + fp2 = os.path.join(d, f"{fn2}_{ts}.md") + with open(fp2, "w", encoding="utf-8") as f: + f.write(c2) + + assert fp1 != fp2, "Should be different files" + assert os.path.exists(fp1) and os.path.exists(fp2) +test_no_overwrite() + + +@test("覆盖: URL 匹配覆盖旧文件") +def test_overwrite(): + d = os.path.join(TEMP_DIR, "ow") + os.makedirs(d, exist_ok=True) + url = "https://x.com/ow/status/601" + old_file = os.path.join(d, "old_name.md") + with open(old_file, "w", encoding="utf-8") as f: + f.write(f'---\ntitle: "Old"\n源: "{url}"\n---\nOld\n') + + found = server.find_existing_file_by_source_url(d, url) + assert found == old_file + with open(found, "w", encoding="utf-8") as f: + f.write(f'---\ntitle: "New"\n源: "{url}"\n---\nNew\n') + with open(old_file, "r", encoding="utf-8") as f: + assert "New" in f.read() + assert len([f for f in os.listdir(d) if f.endswith(".md")]) == 1 +test_overwrite() + + +@test("覆盖: URL 不匹配时正常新建") +def test_overwrite_no_match(): + d = os.path.join(TEMP_DIR, "ow_nomatch") + os.makedirs(d, exist_ok=True) + old = os.path.join(d, "existing.md") + with open(old, "w", encoding="utf-8") as f: + f.write('---\n源: "https://x.com/other"\n---\n') + + result = server.find_existing_file_by_source_url(d, "https://x.com/new_url") + assert result is None +test_overwrite_no_match() + + +# ═══════════════════════════════════════════════ +# 6. Windows 路径兼容性 +# ═══════════════════════════════════════════════ +print("\n=== 6. Windows 路径兼容性 ===") + +@test("Win: 文件名不含 \\/:*?\"<>| 字符") +def test_win_chars(): + bad = 'test\\file/name:with*bad?"chars<>|end' + result = server.sanitize_filename(bad) + for c in '\\/:*?"<>|': + assert c not in result, f"Character '{c}' found in: {result}" +test_win_chars() + + +@test("Win: 文件名不以 . 或空格结尾") +def test_win_trailing(): + assert server.sanitize_filename("file.") == "file" + assert server.sanitize_filename("file ") == "file" + assert server.sanitize_filename("...") == "untitled" +test_win_trailing() + + +@test("Win: 超长文件名正确截断") +def test_win_long(): + long_name = "这是一个非常长的文件名" * 20 + result = server.sanitize_filename(long_name, 60) + assert len(result) <= 60 + # 确保截断后不以 . 或空格结尾 + assert not result.endswith('.') and not result.endswith(' ') +test_win_long() + + +@test("Win: 全中文文件名正确处理") +def test_win_chinese(): + name = "关于人工智能未来发展的思考" + result = server.sanitize_filename(name, 60) + assert result == name, f"Chinese filename broken: {result}" +test_win_chinese() + + +@test("Win: Emoji 文件名不崩溃") +def test_win_emoji(): + name = "测试🚀文件📝名称" + result = server.sanitize_filename(name, 60) + assert result, "Should not be empty" + assert len(result) <= 60 +test_win_emoji() + + +# ═══════════════════════════════════════════════ +# 7. Obsidian 渲染特定验证 +# ═══════════════════════════════════════════════ +print("\n=== 7. Obsidian 渲染特定验证 ===") + +@test("OB: Front Matter 和正文之间有空行") +def test_ob_blank_line(): + data = { + "author": "A", "handle": "@a", "text": "Body text", + "url": "https://x.com/a/status/700", "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + # 找到 FM 结束的 --- + lines = content.split("\n") + fm_end_idx = None + for i, line in enumerate(lines[1:], 1): + if line == "---": + fm_end_idx = i + break + assert fm_end_idx is not None, "No FM end found" + # FM 结束后应该有空行 + assert lines[fm_end_idx + 1] == "", f"Expected blank line after FM, got: '{lines[fm_end_idx + 1]}'" +test_ob_blank_line() + + +@test("OB: 图片引用格式正确(Obsidian 标准 Markdown)") +def test_ob_image_format(): + data = { + "author": "A", "handle": "@a", "text": "Check images", + "url": "https://x.com/a/status/701", "platform": "Twitter/X", + "images": ["https://pbs.twimg.com/media/test.jpg"], + "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + # Obsidian 标准 Markdown 图片格式: ![alt](url) + img_pattern = re.compile(r'!\[.*?\]\(.+?\)') + assert img_pattern.search(content), "No valid image reference found" +test_ob_image_format() + + +@test("OB: 本地图片用相对路径(无前导 ./)") +def test_ob_local_path(): + data = { + "author": "A", "handle": "@a", "text": "Local imgs", + "url": "https://x.com/a/status/702", "platform": "Twitter/X", + "images": ["https://pbs.twimg.com/media/local.jpg"], + "videos": [], + } + _, content, tasks, _ = server.build_markdown(data, CFG_WITH_DOWNLOAD) + # Obsidian 本地引用使用 wiki-link 格式 ![[filename]] + assert "![[" in content, "Should use Obsidian wiki-link format ![[filename]]" +test_ob_local_path() + + +@test("OB: YAML tags 是数组格式") +def test_ob_tags_array(): + data = { + "author": "A", "handle": "@a", "text": "Tags test", + "url": "https://x.com/a/status/703", "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "tags: []" in content, "tags should be empty array" +test_ob_tags_array() + + +@test("OB: 多图片不在同一行") +def test_ob_multiline_images(): + data = { + "author": "A", "handle": "@a", "text": "Multi images", + "url": "https://x.com/a/status/704", "platform": "Twitter/X", + "images": [ + "https://pbs.twimg.com/media/img1.jpg", + "https://pbs.twimg.com/media/img2.jpg", + "https://pbs.twimg.com/media/img3.jpg", + ], + "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + # 每张图片应该在自己的行上 + img_lines = [l for l in content.split("\n") if l.startswith("![")] + assert len(img_lines) == 3, f"Expected 3 image lines, got {len(img_lines)}" +test_ob_multiline_images() + + +@test("OB: 正文中的 Markdown 语法保持不变(不被转义)") +def test_ob_markdown_preserved(): + data = { + "author": "A", "handle": "@a", + "text": "**bold** *italic* `code` [link](https://example.com)", + "url": "https://x.com/a/status/705", "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "**bold**" in content + assert "*italic*" in content + assert "`code`" in content + assert "[link](https://example.com)" in content +test_ob_markdown_preserved() + + +@test("OB: Article 中的代码块保留") +def test_ob_code_block(): + data = { + "author": "A", "handle": "@a", "text": "Preview", + "url": "https://x.com/a/status/706", "platform": "Twitter/X", + "type": "article", "article_title": "Code Demo", + "article_content": "Intro\n\n```python\ndef hello():\n print('world')\n```\n\nEnd", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "```python" in content + assert "def hello():" in content + assert "```" in content +test_ob_code_block() + + +@test("OB: 飞书文档类型正确(article 而非 tweet)") +def test_ob_feishu_type(): + data = { + "author": "飞书", "handle": "", + "text": "", + "url": "https://xxx.feishu.cn/wiki/abc", + "platform": "Feishu", + "type": "article", + "article_title": "飞书测试", + "article_content": "# 标题\n正文", + "images": [], "videos": [], + "author_url": "", + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + # article 模式下正文应直接输出 article_content + assert "# 标题" in content + assert "正文" in content + # FM 中平台正确 + assert '平台: "Feishu"' in content +test_ob_feishu_type() + + +@test("OB: 微信图片 wx_fmt 参数正确识别扩展名") +def test_ob_wechat_image_ext(): + wx_url = "https://mmbiz.qpic.cn/mmbiz_jpg/xxx?wx_fmt=jpeg" + ext = server._guess_image_ext(wx_url) + assert ext == ".jpg", f"Expected .jpg for wx_fmt=jpeg, got {ext}" + + wx_url2 = "https://mmbiz.qpic.cn/mmbiz_png/xxx?wx_fmt=png" + ext2 = server._guess_image_ext(wx_url2) + assert ext2 == ".png", f"Expected .png, got {ext2}" +test_ob_wechat_image_ext() + + +# ═══════════════════════════════════════════════ +# 8. 交互逻辑验证(配置白名单 + 默认值) +# ═══════════════════════════════════════════════ +print("\n=== 8. 交互逻辑验证 ===") + +@test("配置: 所有 V1.2 字段在白名单中") +def test_config_whitelist(): + required_keys = { + "port", "save_paths", "filename_format", "max_filename_length", + "show_site_save_icon", "enable_platform_folders", "platform_folder_names", + "download_images", "image_subfolder", "overwrite_existing", + "enable_video_download", "video_save_path", "video_duration_threshold", + } + missing = required_keys - server.X2MDHandler.ALLOWED_CONFIG_KEYS + assert not missing, f"Missing from whitelist: {missing}" +test_config_whitelist() + + +@test("配置: 默认值完整性") +def test_config_defaults(): + cfg = server.DEFAULT_CONFIG + assert cfg.get("overwrite_existing") is False + assert cfg.get("download_images") is True + assert cfg.get("enable_platform_folders") is True + assert "image_subfolder" in cfg + assert "platform_folder_names" in cfg +test_config_defaults() + + +@test("配置: platform_folder_names 覆盖所有支持平台") +def test_config_platforms(): + folders = server.DEFAULT_CONFIG.get("platform_folder_names", {}) + required_platforms = ["Twitter/X", "LinuxDo", "Feishu", "WeChat"] + for p in required_platforms: + assert p in folders, f"Missing platform folder: {p}" +test_config_platforms() + + +# ═══════════════════════════════════════════════ +# 9. 边界情况 +# ═══════════════════════════════════════════════ +print("\n=== 9. 边界情况 ===") + +@test("边界: 空推文(无文字无图片)") +def test_empty_tweet(): + data = { + "author": "E", "handle": "@e", "text": "", + "url": "https://x.com/e/status/900", "platform": "Twitter/X", + "images": [], "videos": [], + } + fn, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert fn, "Filename should not be empty" + assert "---" in content, "FM should still exist" +test_empty_tweet() + + +@test("边界: 超长推文不截断正文") +def test_long_text(): + long_text = "这是一段很长的文字。" * 500 + data = { + "author": "L", "handle": "@l", "text": long_text, + "url": "https://x.com/l/status/901", "platform": "Twitter/X", + "images": [], "videos": [], + } + fn, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert long_text in content, "Full text should be preserved" + assert len(fn) <= 60, f"Filename too long: {len(fn)}" +test_long_text() + + +@test("边界: URL 包含特殊字符") +def test_special_url(): + data = { + "author": "S", "handle": "@s", + "text": "Special URL test", + "url": "https://x.com/s/status/902?ref=abc&lang=zh", + "platform": "Twitter/X", + "images": [], "videos": [], + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert "?ref=abc&lang=zh" in content, "URL should preserve query params" +test_special_url() + + +@test("边界: published 时间字段为空不崩溃") +def test_no_published(): + data = { + "author": "P", "handle": "@p", "text": "No time", + "url": "https://x.com/p/status/903", "platform": "Twitter/X", + "images": [], "videos": [], + "published": "", + } + _, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + assert '发布时间: ""' in content +test_no_published() + + +@test("边界: 特殊字符在 title 中(引号、反斜杠、尖括号)") +def test_special_title(): + data = { + "author": "Sp", "handle": "@sp", + "text": 'He said "hello" and & back\\slash', + "url": "https://x.com/sp/status/904", "platform": "Twitter/X", + "images": [], "videos": [], + } + fn, content, _, _ = server.build_markdown(data, CFG_NO_DOWNLOAD) + # FM 中 title 的双引号应被替换为单引号 + for line in content.split("\n"): + if line.startswith("标题:"): + inner = line[len('title: "'):-1] + assert '"' not in inner, f"Unescaped quote in title: {inner}" + break + # 文件名中特殊字符应被清理 + for c in '\\/:*?"<>|': + assert c not in fn, f"Bad char '{c}' in filename: {fn}" +test_special_title() + + +# ═══════════════════════════════════════════════ +# 10. 打印一份完整的示例输出供人工审阅 +# ═══════════════════════════════════════════════ +print("\n=== 10. 示例输出(人工审阅) ===") +print("-" * 60) + +sample_data = { + "author": "示例用户", "handle": "@example", + "text": "这是一条示例推文 **包含加粗** 和 `代码`\n\n第二段内容", + "url": "https://x.com/example/status/9999", + "platform": "Twitter/X", + "published": "2026-03-25 10:30", + "images": ["https://pbs.twimg.com/media/sample1.jpg", "https://pbs.twimg.com/media/sample2.jpg"], + "videos": ["https://video.twimg.com/ext/sample.mp4"], + "thread_tweets": [ + {"text": "这是线程的第二条推文", "images": ["https://pbs.twimg.com/media/thread2.jpg"], "videos": []}, + ], +} +fn, md, tasks, _ = server.build_markdown(sample_data, CFG_NO_DOWNLOAD) +print(f"文件名: {fn}.md") +print(f"图片/视频下载任务数: {len(tasks)}") +print() +print(md) +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 " 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: - script = os.path.join(APP_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 @@ -101,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 @@ -116,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 @@ -136,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("服务已重启") # ───────────────────────────────────────────── @@ -153,6 +296,7 @@ def restart_server(): # ───────────────────────────────────────────── def create_tray_icon_image(): """生成托盘图标(程序化绘制,无需外部图片文件)""" + logger.debug("正在创建托盘图标...") from PIL import Image, ImageDraw, ImageFont size = 64 @@ -164,12 +308,14 @@ def create_tray_icon_image(): # 白色 "X" 字样 try: - font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 36) - except Exception: - try: + if sys.platform == "darwin": + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 36) + elif sys.platform == "win32": + font = ImageFont.truetype(os.path.join(os.environ.get("WINDIR", "C:\\Windows"), "Fonts", "arial.ttf"), 36) + else: font = ImageFont.truetype("arial.ttf", 36) - except Exception: - font = ImageFont.load_default() + except Exception: + font = ImageFont.load_default() bbox = draw.textbbox((0, 0), "X", font=font) tw = bbox[2] - bbox[0] @@ -182,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() @@ -227,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("托盘事件循环已退出") # ───────────────────────────────────────────── @@ -250,26 +404,90 @@ def run_cli_mode(): # 主函数 # ───────────────────────────────────────────── def ensure_extension_accessible(): - """确保 extension 文件夹在用户可访问的位置""" + """确保 extension 文件夹在用户可访问的位置,并在版本升级时自动更新。 + 使用 .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 = "" + 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 包内的配置。升级时自动补全新增字段。""" + logger.info(f"检查配置文件可访问性: {CONFIG_FILE}") if os.path.exists(CONFIG_FILE): + # 已有配置:合并新版本可能新增的默认字段 + _merge_config_defaults() return # 优先迁移旧版遗留在 MacOS/ 目录内的配置(用户升级场景) @@ -279,6 +497,7 @@ def ensure_config_accessible(): import shutil shutil.copy2(old_config, CONFIG_FILE) logger.info(f"已迁移旧版配置: {old_config} -> {CONFIG_FILE}") + _merge_config_defaults() return # 从打包资源中复制默认配置 @@ -290,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) diff --git a/x2md.spec b/x2md.spec index aa81918..71561b4 100644 --- a/x2md.spec +++ b/x2md.spec @@ -6,9 +6,15 @@ x2md PyInstaller 打包配置 import sys import os +from PyInstaller.utils.hooks import collect_data_files block_cipher = None +# 收集 tzdata 时区数据文件(Windows 无系统时区数据,依赖此包) +tzdata_datas = collect_data_files('tzdata') +# 收集 certifi CA 证书 +certifi_datas = collect_data_files('certifi') + a = Analysis( ['tray_app.py'], pathex=[], @@ -16,17 +22,26 @@ a = Analysis( datas=[ ('extension', 'extension'), # Chrome 扩展文件夹 ('config.json', '.'), # 默认配置 - ], + ] + tzdata_datas + certifi_datas, hiddenimports=[ 'server', 'setup_wizard', 'pystray', - 'pystray._darwin' if sys.platform == 'darwin' else 'pystray._win32', + 'pystray._darwin' if sys.platform == 'darwin' else ('pystray._win32' if sys.platform == 'win32' else 'pystray._xorg'), 'PIL', 'PIL.Image', 'PIL.ImageDraw', 'PIL.ImageFont', 'certifi', + # zoneinfo:server.py 中用于北京时间转换(函数内 lazy import,PyInstaller 无法自动检测) + 'zoneinfo', + 'zoneinfo._common', + 'zoneinfo._tzpath', + # tzdata:Windows 无系统时区数据,需要此包提供 + 'tzdata', + # tkinter:setup_wizard.py 使用 + 'tkinter', + 'tkinter.filedialog', ], hookspath=[], hooksconfig={}, @@ -73,9 +88,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', ) @@ -89,7 +110,7 @@ if sys.platform == 'darwin': info_plist={ 'CFBundleName': 'X2MD', 'CFBundleDisplayName': 'X2MD', - 'CFBundleShortVersionString': '1.0.8', + 'CFBundleShortVersionString': '1.6.0', 'LSUIElement': True, # 无 Dock 图标,仅菜单栏显示 }, )