Repository: doocs/cose Branch: main Commit: 4e32015064b3 Files: 90 Total size: 372.0 KB Directory structure: gitextract_uy5ndwh6/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── 01-bug-report.yml │ │ ├── 02-feature-request.yml │ │ └── 03-platform-request.yml │ └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── PRIVACY.md ├── README.md ├── apps/ │ └── extension/ │ ├── manifest.json │ ├── package.json │ ├── scripts/ │ │ ├── cli.ts │ │ ├── convert-icons.mjs │ │ └── reload-extension.mjs │ ├── src/ │ │ ├── background.js │ │ ├── content.js │ │ ├── inject.js │ │ ├── offscreen.html │ │ ├── offscreen.js │ │ ├── popup.html │ │ └── popup.js │ ├── tsconfig.json │ └── vite.config.js ├── package.json ├── packages/ │ ├── core/ │ │ ├── index.js │ │ ├── package.json │ │ └── src/ │ │ ├── platforms/ │ │ │ ├── alipayopen.js │ │ │ ├── aliyun.js │ │ │ ├── baijiahao.js │ │ │ ├── bilibili.js │ │ │ ├── cnblogs.js │ │ │ ├── common.js │ │ │ ├── csdn.js │ │ │ ├── cto51.js │ │ │ ├── douban.js │ │ │ ├── douyin.js │ │ │ ├── elecfans.js │ │ │ ├── huaweicloud.js │ │ │ ├── huaweidev.js │ │ │ ├── index.js │ │ │ ├── infoq.js │ │ │ ├── jianshu.js │ │ │ ├── juejin.js │ │ │ ├── medium.js │ │ │ ├── modelscope.js │ │ │ ├── oschina.js │ │ │ ├── qianfan.js │ │ │ ├── segmentfault.js │ │ │ ├── sohu.js │ │ │ ├── sspai.js │ │ │ ├── tencentcloud.js │ │ │ ├── toutiao.js │ │ │ ├── twitter.js │ │ │ ├── volcengine.js │ │ │ ├── wangyihao.js │ │ │ ├── wechat.js │ │ │ ├── weibo.js │ │ │ ├── xiaohongshu.js │ │ │ └── zhihu.js │ │ └── utils.js │ └── detection/ │ ├── index.js │ ├── package.json │ └── src/ │ ├── configs.js │ ├── detect.js │ ├── platforms/ │ │ ├── alipay.js │ │ ├── aliyun.js │ │ ├── bilibili.js │ │ ├── cnblogs.js │ │ ├── csdn.js │ │ ├── cto51.js │ │ ├── douban.js │ │ ├── elecfans.js │ │ ├── huaweicloud.js │ │ ├── huaweidev.js │ │ ├── infoq.js │ │ ├── jianshu.js │ │ ├── medium.js │ │ ├── modelscope.js │ │ ├── oschina.js │ │ ├── qianfan.js │ │ ├── segmentfault.js │ │ ├── sohu.js │ │ ├── sspai.js │ │ ├── tencentcloud.js │ │ ├── twitter.js │ │ ├── volcengine.js │ │ ├── wangyihao.js │ │ ├── wechat.js │ │ ├── weibo.js │ │ └── xiaohongshu.js │ └── utils.js └── pnpm-workspace.yaml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/01-bug-report.yml ================================================ name: Bug Report / 问题报告 description: Report a bug or issue with the extension / 报告扩展的 bug 或问题 title: "[Bug]: " labels: ["bug", "triage"] body: - type: markdown attributes: value: | Thanks for taking the time to report this bug! Please fill out the information below to help us resolve the issue. 感谢您花时间报告此问题!请填写以下信息以帮助我们解决问题。 - type: textarea id: description attributes: label: Description / 问题描述 description: A clear and concise description of the bug / 清晰简洁地描述问题 placeholder: Describe the bug you encountered / 描述您遇到的问题 validations: required: true - type: textarea id: reproduction-steps attributes: label: Steps to Reproduce / 复现步骤 description: Detailed steps to reproduce the behavior / 详细的复现步骤 placeholder: | 1. Go to '...' / 前往 '...' 2. Click on '...' / 点击 '...' 3. Scroll down to '...' / 滚动到 '...' 4. See error / 看到错误 validations: required: true - type: textarea id: expected-behavior attributes: label: Expected Behavior / 预期行为 description: What you expected to happen / 您期望发生什么 placeholder: Describe the expected behavior / 描述预期行为 validations: required: true - type: textarea id: suggested-implementation attributes: label: Suggested Implementation / 实现建议 description: Any suggestions on how to fix this issue (optional) / 对如何修复此问题的建议 placeholder: Describe your suggested implementation / 描述您建议的实现方式 validations: required: false - type: input id: platform attributes: label: Platform / 平台 description: Which platform is affected? / 哪个平台受到影响? placeholder: e.g., CSDN, Zhihu, Juejin, Toutiao / 如:CSDN、知乎、掘金、头条 validations: required: true - type: dropdown id: browser attributes: label: Browser / 浏览器 description: Which browser are you using? / 您使用的是哪个浏览器? options: - Chrome - Firefox - Edge - Safari - Other / 其他 validations: required: true - type: input id: extension-version attributes: label: Extension Version / 扩展版本 description: What version of the extension are you running? / 您运行的扩展版本是多少? placeholder: e.g., 1.2.0 / 如:1.2.0 validations: required: true - type: textarea id: environment attributes: label: Environment Details / 环境信息 description: Any additional environment information / 其他环境信息 placeholder: | - OS / 操作系统: [e.g., Windows 10, macOS 13.0] - Browser Version / 浏览器版本: [e.g., Chrome 120.0] - Additional context / 其他信息 validations: required: false - type: textarea id: screenshots attributes: label: Screenshots / 截图 description: If applicable, add screenshots to help explain your problem / 如有需要,请添加截图帮助说明问题 placeholder: Drag and drop images here / 拖放图片到这里 validations: required: false - type: textarea id: additional-context attributes: label: Additional Context / 补充信息 description: Any other context about the problem / 关于此问题的其他补充信息 validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/02-feature-request.yml ================================================ name: Feature Request / 功能请求 description: Suggest a new feature or enhancement / 建议新功能或改进 title: "[Feature]: " labels: ["enhancement"] body: - type: markdown attributes: value: | Thanks for your interest in improving this project! Please describe your feature request below. 感谢您对改进本项目的兴趣!请在下方描述您的功能请求。 - type: textarea id: problem-description attributes: label: Problem Description / 问题描述 description: Is your feature request related to a problem? Describe what the problem is / 您的功能请求是否与某个问题相关?请描述问题 placeholder: "I'm frustrated when... / 我感到困扰的是..." validations: required: true - type: textarea id: proposed-solution attributes: label: Proposed Solution / 建议的解决方案 description: Describe the solution you'd like to see / 描述您希望看到的解决方案 placeholder: A clear and concise description of what you want to happen / 清晰简洁地描述您希望实现的功能 validations: required: true - type: textarea id: alternatives attributes: label: Alternatives Considered / 考虑过的替代方案 description: Describe any alternative solutions or features you've considered / 描述您考虑过的其他解决方案或功能 placeholder: What other approaches could solve this problem? / 还有哪些方法可以解决这个问题? validations: required: false - type: dropdown id: feature-type attributes: label: Feature Type / 功能类型 description: What type of feature is this? / 这是什么类型的功能? options: - New platform support / 新平台支持 - UI/UX improvement / 界面/体验改进 - Performance enhancement / 性能优化 - Content formatting / 内容格式化 - Authentication/Login / 认证/登录 - Other / 其他 validations: required: true - type: textarea id: additional-context attributes: label: Additional Context / 补充信息 description: Add any other context, screenshots, or examples about the feature request / 添加其他相关信息、截图或示例 placeholder: Any mockups, examples, or additional details / 任何原型图、示例或其他细节 validations: required: false - type: checkboxes id: willingness attributes: label: Implementation / 实现意愿 description: Would you be willing to help implement this feature? / 您是否愿意帮助实现此功能? options: - label: I'm willing to submit a PR for this feature / 我愿意为此功能提交 PR required: false - label: I'd prefer to wait for community implementation / 我希望等待社区的实现 required: false ================================================ FILE: .github/ISSUE_TEMPLATE/03-platform-request.yml ================================================ name: Platform Request / 平台支持 description: Request support for a new platform / 请求支持新平台 title: "[Platform]: " labels: ["enhancement", "new platform"] body: - type: input id: platform-name attributes: label: Platform Name / 平台名称 description: Name of the platform you want supported / 您希望支持的平台名称 placeholder: e.g., 简书、博客园 / e.g., Jianshu, cnblogs validations: required: true - type: input id: platform-url attributes: label: Platform URL / 平台网址 description: Main URL of the platform / 平台的主要网址 placeholder: e.g., https://www.jianshu.com validations: required: true - type: textarea id: reason attributes: label: Why this platform? / 为什么需要支持此平台? description: Brief reason for supporting this platform / 简要说明支持此平台的原因 placeholder: e.g., Popular blogging platform in China / 如:国内热门博客平台 validations: required: false - type: checkboxes id: willingness attributes: label: Implementation / 实现意愿 description: Would you be willing to help implement this feature? / 您是否愿意帮助实现此功能? options: - label: I'm willing to submit a PR for this feature / 我愿意为此功能提交 PR required: false - label: I'd prefer to wait for community implementation / 我希望等待社区的实现 required: false ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Summary / 概述 ## Related Issue / 关联 Issue Closes # ## Type of Change / 更改类型 - [ ] Bug fix / 修复 Bug (non-breaking change that fixes an issue / 修复问题的非破坏性更改) - [ ] New feature / 新功能 (non-breaking change that adds functionality / 添加功能的非破坏性更改) - [ ] Breaking change / 破坏性更改 (fix or feature that would cause existing functionality to not work as expected / 会导致现有功能无法正常工作的修复或功能) - [ ] Documentation update / 文档更新 - [ ] Performance improvement / 性能优化 - [ ] Code refactoring / 代码重构 - [ ] Other / 其他 (please describe / 请描述): ## Changes Made / 更改内容 - - - ## Implementation Details / 实现细节 **Key Changes / 主要更改:** - **Technical Notes / 技术说明:** - ## Testing / 测试 ### Testing Checklist / 测试清单 - [ ] I have tested this code locally / 我已在本地测试此代码 - [ ] All existing tests pass / 所有现有测试通过 - [ ] I have added tests for new functionality / 我已为新功能添加测试 - [ ] I have tested on the affected platform(s) / 我已在受影响的平台上测试 - [ ] I have verified the changes work in the target browser(s) / 我已验证更改在目标浏览器中有效 ### Manual Testing Steps / 手动测试步骤 1. 2. 3. ## Screenshots/Videos / 截图/视频 ## Reviewer Checklist / 审阅者清单 - [ ] Code follows the project's style guidelines / 代码遵循项目的风格指南 - [ ] Changes are well-documented / 更改有良好的文档说明 - [ ] No breaking changes or clearly documented if present / 无破坏性更改,或已清楚记录 - [ ] Security implications have been considered / 已考虑安全影响 - [ ] Performance impact has been evaluated / 已评估性能影响 - [ ] All discussions have been resolved / 所有讨论已解决 ## Additional Notes / 补充说明 ================================================ FILE: .gitignore ================================================ # misc .DS_Store *.zip .vscode .idea .cursor .fleet .zed .windsurf .kiro dist/ node_modules/ ================================================ FILE: PRIVACY.md ================================================ # Privacy Policy for COSE - 多平台文章同步 **Last Updated: December 13, 2025** ## Overview COSE (Create Once, Sync Everywhere) is a browser extension that helps users sync articles from the md.doocs.org Markdown editor to multiple publishing platforms. We are committed to protecting your privacy. ## Data Collection **We do not collect any personal data.** COSE operates entirely locally within your browser. The extension: - Does **NOT** collect personally identifiable information - Does **NOT** collect health, financial, or authentication information - Does **NOT** track your browsing history or web activity - Does **NOT** send any data to external servers - Does **NOT** use analytics or tracking services ## Data Usage All data processed by COSE remains on your local device: - **Article Content**: Your article title, body, and formatting are only read from md.doocs.org and transferred directly to the target publishing platforms within your browser. - **Login Status**: The extension checks login cookies on target platforms (CSDN, Juejin, WeChat, etc.) solely to verify if you are logged in. This information is not stored or transmitted. - **User Preferences**: COSE does not persist user preferences or settings. ## Permissions Explained | Permission | Purpose | |------------|---------| | `tabGroups` | Organize sync tabs into groups | | `activeTab` | Temporarily access the current tab when you initiate a sync | | `scripting` | Fill article content into platform editors | | `cookies` | Check platform login status | | `debugger` | Simulate paste events for WeChat editor | | `clipboardRead` | Read formatted content (HTML) from the clipboard for syncing | | `clipboardWrite` | Write content to the clipboard when needed for syncing | ## Third-Party Services COSE interacts with the following third-party publishing platforms only when you explicitly initiate a sync: - CSDN (csdn.net) - Juejin (juejin.cn) - WeChat Official Account (mp.weixin.qq.com) - And other supported platforms These interactions are solely for the purpose of publishing your content. We have no control over the privacy practices of these platforms. ## Data Security Since no data is collected or transmitted to our servers, there is no risk of data breach from our end. All operations occur locally in your browser. ## Children's Privacy COSE is not directed at children under 13 years of age, and we do not knowingly collect information from children. ## Changes to This Policy We may update this Privacy Policy from time to time. Any changes will be posted on this page with an updated revision date. ## Contact If you have questions about this Privacy Policy, please open an issue at: https://github.com/doocs/cose/issues ## Open Source COSE is open source. You can review the complete source code at: https://github.com/doocs/cose ================================================ FILE: README.md ================================================
COSE _**C**reate **O**nce **S**ync **E**verywhere_ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Chrome Web Store](https://img.shields.io/badge/Install-Chrome%20Web%20Store-4285F4?logo=googlechrome&logoColor=white)](https://chromewebstore.google.com/detail/ilhikcdphhpjofhlnbojifbihhfmmhfk) [![YouTube](https://img.shields.io/badge/Video-YouTube-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=KTskiA8Xaj4) [![Bilibili](https://img.shields.io/badge/Video-Bilibili-00A1D6?logo=bilibili&logoColor=white)](https://www.bilibili.com/video/BV1ZxqnB1E2C/)
配合 [doocs/md](https://github.com/doocs/md) Markdown 编辑器使用的浏览器扩展,支持一键将文章同步到多个内容平台。 > 本插件完全本地运行,不收集、不存储任何用户信息。**如需添加更多平台或改善同步准确度,欢迎提 [Issue](https://github.com/doocs/cose/issues) 或 [PR](https://github.com/doocs/cose/pulls)**。 ## 使用方法 > 点击观看视频:[![Bilibili](https://img.shields.io/badge/Video-Bilibili-00A1D6?logo=bilibili&logoColor=white)](https://www.bilibili.com/video/BV1ZxqnB1E2C/) [![YouTube](https://img.shields.io/badge/Video-YouTube-FF0000?logo=youtube&logoColor=white)](https://www.youtube.com/watch?v=KTskiA8Xaj4) 1. 先点击安装扩展 [![Chrome Web Store](https://img.shields.io/badge/Install-Chrome%20Web%20Store-4285F4?logo=googlechrome&logoColor=white)](https://chromewebstore.google.com/detail/ilhikcdphhpjofhlnbojifbihhfmmhfk) 然后打开 [md.doocs.org](https://md.doocs.org) 或本地开发环境 2. 编辑 Markdown 内容 3. 点击顶部的 **发布** 按钮 4. 在弹出的对话框中选择要同步的平台 5. 点击 **确定** 开始同步 ## 特性 - 编辑一次,同步到多个平台 - 自动检测各平台登录状态 - 同步的标签页自动归入分组,便于管理 - 微信公众号同步时完整保留渲染样式并自动保存为草稿 ## 已支持的平台 > 更多想要添加的平台欢迎提 [Issue](https://github.com/doocs/cose/issues) ! > >
> 已支持平台速查表(点击展开) > > | 字母 | 平台 | > |:---:|:---| > | A | 阿里云社区 | > | B | B站专栏、百度云千帆、百家号、博客园 | > | C | CSDN | > | D | 豆瓣、电子发烧友、抖音文章 | > | H | 华为开发者文章、华为云博客、火山引擎社区 | > | I | InfoQ | > | J | 简书、掘金、今日头条 | > | K | 开源中国 | > | M | Medium、ModelScope 魔搭社区 | > | S | 少数派、搜狐号、思否 | > | T | 腾讯云 | > | W | 网易号、微博文章、微信公众号 | > | X | 小红书长文、X(Formerly Twitter) Articles | > | Z | 支付宝开放平台、知乎 | > | 5 | 51CTO | > >
### 媒体平台

微信公众号 今日头条 知乎 抖音文章 小红书 百家号 网易号 搜狐号 微博文章 B站专栏 豆瓣 少数派 X(Formerly Twitter) Articles

### 博客平台

CSDN 博客园 掘金 Medium 思否 InfoQ 简书 开源中国 51CTO

### 云平台及其开发者社区

腾讯云 腾讯云 阿里云社区 阿里云社区 华为云博客 华为云博客 华为开发者文章 华为开发者文章 百度云千帆 百度云千帆
支付宝开放平台 魔搭社区 魔搭社区 火山引擎 火山引擎 电子发烧友

## 开发者模式测试 1. 克隆或下载本项目 2. 打开 Chrome 浏览器,进入 `chrome://extensions/` 3. 开启右上角的 **开发者模式** 4. 点击 **加载已解压的扩展程序** 5. 选择 `cose` 目录 ================================================ FILE: apps/extension/manifest.json ================================================ { "manifest_version": 3, "name": "COSE - 多平台文章同步", "description": "Create Once, Sync Everywhere. 一键将文章同步到多个平台", "permissions": [ "tabGroups", "activeTab", "scripting", "cookies", "debugger", "clipboardRead", "storage", "declarativeNetRequest", "offscreen" ], "host_permissions": [ "https://*.csdn.net/*", "https://*.juejin.cn/*", "https://*.jianshu.com/*", "https://*.segmentfault.com/*", "https://*.toutiao.com/*", "https://*.douban.com/*", "https://*.bilibili.com/*", "https://*.weibo.com/*", "https://*.sinaimg.cn/*", "https://mp.weixin.qq.com/*", "https://*.zhihu.com/*", "https://*.sspai.com/*", "https://cdnfile.sspai.com/*", "https://*.xueqiu.com/*", "https://*.eastmoney.com/*", "https://*.wordpress.com/*", "https://*.wordpress.org/*", "https://md.doocs.org/*", "https://*.cnblogs.com/*", "https://*.oschina.net/*", "https://*.51cto.com/*", "https://*.infoq.cn/*", "https://*.baijiahao.baidu.com/*", "https://*.163.com/*", "https://*.cloud.tencent.com/*", "https://*.medium.com/*", "https://*.sohu.com/*", "https://*.aliyun.com/*", "https://*.huaweicloud.com/*", "https://*.huawei.com/*", "https://*.hicloud.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://qianfan.cloud.baidu.com/*", "https://*.alipay.com/*", "https://*.modelscope.cn/*", "https://*.alicdn.com/*", "https://*.volcengine.com/*", "https://*.byteacctimg.com/*", "https://*.douyin.com/*", "https://*.xiaohongshu.com/*", "https://*.elecfans.com/*" ], "action": { "default_icon": { "16": "icons/cose_16.png", "48": "icons/cose_48.png", "128": "icons/cose_128.png" }, "default_title": "COSE", "default_popup": "popup.html" }, "background": { "service_worker": "bundles/background.js", "type": "module" }, "content_scripts": [ { "matches": [ "http://*/*", "https://*/*" ], "js": [ "bundles/content.js" ], "run_at": "document_idle" } ], "icons": { "16": "icons/cose_16.png", "48": "icons/cose_48.png", "128": "icons/cose_128.png" }, "web_accessible_resources": [ { "resources": [ "bundles/inject.js", "bundles/platforms/*.js" ], "matches": [ "" ] } ] } ================================================ FILE: apps/extension/package.json ================================================ { "name": "cose-extension", "version": "1.3.4", "description": "Create Once, Sync Everywhere. 一键将文章同步到多个平台", "type": "module", "scripts": { "dev": "mkdir -p /tmp/chrome-debug-profile && web-ext run --source-dir ./dist --target=chromium --chromium-binary \"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\" --chromium-profile /tmp/chrome-debug-profile --keep-profile-changes", "dev:chrome": "mkdir -p /tmp/chrome-debug-profile && '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug-profile --load-extension=$(pwd)/dist", "dev:watch": "node scripts/reload-extension.mjs", "build": "tsx scripts/cli.ts build", "build:firefox": "tsx scripts/cli.ts build --target firefox", "build:safari": "tsx scripts/cli.ts build --target safari", "build:release": "tsx scripts/cli.ts build --release", "watch": "tsx scripts/cli.ts build --watch", "lint": "web-ext lint --source-dir ./dist" }, "dependencies": { "@cose/core": "workspace:*", "@cose/detection": "workspace:*" }, "devDependencies": { "cac": "^6.7.14", "execa": "^9.6.1", "rimraf": "^5.0.5", "tsx": "^4.21.0", "vite": "^5.0.12", "vite-plugin-static-copy": "^1.0.1", "web-ext": "^7.11.0", "ws": "^8.19.0" } } ================================================ FILE: apps/extension/scripts/cli.ts ================================================ import { cac } from 'cac' import { execa } from 'execa' import { build as viteBuild } from 'vite' import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' const dirname = fileURLToPath(new URL('./', import.meta.url)) const rootDir = path.join(dirname, '..') // Read package.json for version const packageJsonPath = path.join(rootDir, 'package.json') const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) // Firefox background uses scripts array, Chrome uses service_worker interface FirefoxBackgroundOptions { scripts: string[] type: 'module' } interface ChromeBackgroundOptions { service_worker: string type: 'module' } // Full manifest type for COSE extension interface Manifest { manifest_version: number name: string version: string description: string permissions: string[] host_permissions: string[] action: { default_icon: Record default_title: string } background: FirefoxBackgroundOptions | ChromeBackgroundOptions content_scripts: Array<{ matches: string[] js: string[] run_at: string }> icons: Record web_accessible_resources: Array<{ resources: string[] matches: string[] }> browser_specific_settings?: { gecko: { id: string } } } const readManifest = async (manifestPath: string): Promise => { try { const fileContent = await fs.readFile(manifestPath, 'utf8') const json = JSON.parse(fileContent) as Manifest return json } catch (error) { console.error(error) return undefined } } interface BuildOptions { watch: boolean release: boolean target: 'chromium' | 'firefox' | 'safari' bundleId?: string } const buildWithVite = async (options: BuildOptions) => { console.log('Building with Vite...') await viteBuild({ root: rootDir, mode: options.release ? 'production' : 'development', build: { minify: options.release, watch: options.watch ? {} : null, }, }) } const copyResources = async () => { console.log('Copying resources...') interface CopyEntry { from: string to: string } const copyEntries: CopyEntry[] = [ { from: path.join(rootDir, 'icons'), to: path.join(rootDir, 'dist/icons'), }, { from: path.join(rootDir, 'assets'), to: path.join(rootDir, 'dist/assets'), }, { // Copy platform scripts from core package from: path.resolve(rootDir, '../../packages/core/src/platforms'), to: path.join(rootDir, 'dist/bundles/platforms'), }, ] for (const entry of copyEntries) { try { // Check if source exists await fs.access(entry.from) // Remove destination if exists await fs.rm(entry.to, { recursive: true, force: true }) // Copy await fs.cp(entry.from, entry.to, { recursive: true }) console.log(` ✓ Copied ${path.basename(entry.from)}`) } catch (error) { // Source doesn't exist, skip console.log(` ⚠ Skipped ${path.basename(entry.from)} (not found)`) } } } const genManifest = async (options: BuildOptions) => { console.log('Generating manifest.json...') const manifest = await readManifest(path.join(rootDir, 'manifest.json')) if (!manifest) { throw new Error('manifest.json not found') } if (!manifest.background) { throw new Error('manifest.background not found') } // Firefox-specific adjustments if (options.target === 'firefox' && 'service_worker' in manifest.background) { // Convert service_worker to scripts array for Firefox manifest.background = { scripts: [manifest.background.service_worker], type: 'module', } // Add Firefox-specific settings manifest.browser_specific_settings = { gecko: { id: 'cose@doocs.org', }, } console.log(' ✓ Converted to Firefox manifest format') } // Sync version from package.json manifest.version = packageJson.version // Write manifest to dist const outputPath = path.join(rootDir, 'dist/manifest.json') await fs.writeFile( outputPath, JSON.stringify(manifest, null, options.release ? undefined : 2) ) console.log(` ✓ Generated manifest.json (version: ${manifest.version})`) } const buildSafariExtension = async (options: BuildOptions) => { console.log('\nConverting to Safari extension...') // Check if xcrun is available (macOS only) try { await execa('xcrun', ['--version']) } catch { throw new Error( 'xcrun not found. Safari extension conversion requires:\n' + ' 1. macOS\n' + ' 2. Xcode installed (with Command Line Tools)\n' + ' 3. Run: xcode-select --install' ) } const safariProjectDir = path.join(rootDir, 'safari-extension') const bundleId = options.bundleId || 'org.doocs.cose' // Remove existing Safari project await fs.rm(safariProjectDir, { recursive: true, force: true }) console.log(` Bundle ID: ${bundleId}`) console.log(` Project location: ${safariProjectDir}`) try { const result = await execa('xcrun', [ 'safari-web-extension-converter', path.join(rootDir, 'dist'), '--project-location', safariProjectDir, '--app-name', 'COSE', '--bundle-identifier', bundleId, '--swift', '--no-prompt', '--no-open' ]) console.log(result.stdout) console.log('\n ✓ Safari extension project created!') console.log(` ✓ Open in Xcode: open ${safariProjectDir}/COSE/COSE.xcodeproj`) } catch (error: unknown) { const err = error as { stderr?: string; message?: string } console.error('Safari conversion failed:', err.stderr || err.message) throw error } } // CLI setup const cli = cac('cose-build') cli.help().version(packageJson.version) cli .command('build', 'Build the COSE browser extension') .option('-w, --watch', 'Watch mode', { default: false }) .option('-r, --release', 'Build in release mode with optimizations', { default: false }) .option('--target ', 'Browser target: "chromium", "firefox", or "safari"', { default: 'chromium' }) .option('--bundle-id ', 'Bundle ID for Safari (default: org.doocs.cose)') .action(async (options: BuildOptions) => { const validTargets = ['chromium', 'firefox', 'safari'] if (!validTargets.includes(options.target)) { throw new Error(`Invalid target: ${options.target}. Use "chromium", "firefox", or "safari".`) } console.log(`\n=== COSE Build (target: ${options.target}, release: ${options.release}) ===\n`) // Step 1: Build with Vite await buildWithVite(options) // Step 2: Copy resources await copyResources() // Step 3: Generate manifest await genManifest(options) // Step 4: Target-specific post-processing if (options.target === 'firefox') { console.log('\nRunning web-ext lint...') try { const result = await execa('pnpm', ['exec', 'web-ext', 'lint', '--source-dir', 'dist']) console.log(result.stdout) } catch (error) { console.error('web-ext lint failed:', error) } } else if (options.target === 'safari') { await buildSafariExtension(options) } console.log(`\n=== Build complete! ===\n`) }) cli.parse(process.argv, { run: false }) await cli.runMatchedCommand() ================================================ FILE: apps/extension/scripts/convert-icons.mjs ================================================ #!/usr/bin/env node /** * 将 SVG 图标转换为 PNG * 需要安装: npm install sharp */ import { readFileSync, writeFileSync, existsSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) const iconsDir = join(__dirname, '..', 'icons') // 简单的 SVG 转 PNG 占位符生成(纯绿色方块带 M 字母) function createPlaceholderPng(size) { // 创建一个简单的 PNG 占位符 // 这是一个最小的有效 PNG 文件(绿色方块) const header = Buffer.from([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature ]) console.log(`请使用以下命令安装 sharp 并重新运行,或手动转换 SVG:`) console.log(` npm install sharp`) console.log(` 或使用在线工具: https://svgtopng.com/`) return null } async function main() { const sizes = [16, 48, 128] console.log('SVG 图标转换工具') console.log('=================') console.log('') try { // 尝试动态导入 sharp const sharp = await import('sharp') for (const size of sizes) { const svgPath = join(iconsDir, `icon${size}.svg`) const pngPath = join(iconsDir, `icon${size}.png`) if (!existsSync(svgPath)) { console.log(`跳过: ${svgPath} 不存在`) continue } const svgBuffer = readFileSync(svgPath) await sharp.default(svgBuffer) .resize(size, size) .png() .toFile(pngPath) console.log(`✓ 已转换: icon${size}.svg -> icon${size}.png`) } console.log('') console.log('✔ 图标转换完成') } catch (e) { if (e.code === 'ERR_MODULE_NOT_FOUND') { console.log('sharp 模块未安装,请运行:') console.log(' npm install sharp') console.log('') console.log('或者手动转换 SVG 文件:') sizes.forEach(size => { console.log(` - icons/icon${size}.svg -> icons/icon${size}.png`) }) console.log('') console.log('在线工具: https://svgtopng.com/') } else { throw e } } } main().catch(console.error) ================================================ FILE: apps/extension/scripts/reload-extension.mjs ================================================ #!/usr/bin/env node /** * 监听 dist 目录变化,自动刷新 Chrome 扩展 */ import { watch } from 'fs' import { join, dirname } from 'path' import { fileURLToPath } from 'url' import WebSocket from 'ws' const __dirname = dirname(fileURLToPath(import.meta.url)) const distDir = join(__dirname, '..', 'dist') const CDP_URL = 'http://127.0.0.1:9222' let reloadTimeout = null function ts() { return new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) } async function reloadExtension() { try { const res = await fetch(`${CDP_URL}/json/list`) const pages = await res.json() const extPage = pages.find(p => p.url.includes('chrome://extensions')) if (!extPage) { console.log(`[reload ${ts()}] 未找到 chrome://extensions 页面,请打开该页面`) return } console.log(`[reload ${ts()}] 正在重新加载扩展...`) const ws = new WebSocket(extPage.webSocketDebuggerUrl) await new Promise((resolve) => { ws.on('open', () => { // 在 extensions 页面中找到 COSE 扩展并点击刷新按钮 ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression: ` (async () => { // 获取 extensions-manager const manager = document.querySelector('extensions-manager'); if (!manager) return 'no-manager'; // 获取 extensions-item-list const itemList = manager.shadowRoot.querySelector('extensions-item-list'); if (!itemList) return 'no-item-list'; // 获取所有扩展卡片 const items = itemList.shadowRoot.querySelectorAll('extensions-item'); for (const item of items) { const name = item.shadowRoot.querySelector('#name')?.textContent || ''; if (name.includes('COSE') || name.includes('多平台')) { // 找到刷新按钮并点击 const reloadBtn = item.shadowRoot.querySelector('#dev-reload-button'); if (reloadBtn) { reloadBtn.click(); return 'ok'; } return 'no-reload-btn'; } } return 'not-found'; })() `, awaitPromise: true } })) }) ws.on('message', (data) => { const msg = JSON.parse(data.toString()) if (msg.id === 1) { const result = msg.result?.result?.value if (result === 'ok') { console.log(`[reload ${ts()}] ✓ 扩展已重新加载`) } else if (result === 'no-reload-btn') { console.log(`[reload ${ts()}] ⚠ 未找到刷新按钮,请开启 Developer mode`) } else if (result === 'not-found') { console.log(`[reload ${ts()}] ⚠ 未找到 COSE 扩展`) } else { console.log(`[reload ${ts()}] ⚠ 刷新失败:`, result) } ws.close() resolve() } }) ws.on('error', (e) => { console.log(`[reload ${ts()}] 连接错误:`, e.message) resolve() }) setTimeout(() => { ws.close(); resolve() }, 3000) }) } catch (e) { console.log(`[reload ${ts()}] 失败:`, e.message) } } function debounceReload() { if (reloadTimeout) clearTimeout(reloadTimeout) reloadTimeout = setTimeout(reloadExtension, 800) } console.log(`[reload ${ts()}] 监听 ${distDir} 目录变化...`) console.log('[reload] 确保:') console.log(' 1. Chrome 已用 --remote-debugging-port=9222 启动') console.log(' 2. chrome://extensions 页面已打开') console.log(' 3. Developer mode 已开启') watch(distDir, { recursive: true }, (eventType, filename) => { if (filename && !filename.includes('.DS_Store') && !filename.includes('_metadata')) { console.log(`[reload ${ts()}] 检测到变化: ${filename}`) debounceReload() } }) process.on('SIGINT', () => { console.log(`\n[reload ${ts()}] 已停止`) process.exit(0) }) ================================================ FILE: apps/extension/src/background.js ================================================ // 平台配置 import { PLATFORMS, LOGIN_CHECK_CONFIG, SYNC_HANDLERS } from '@cose/core/src/platforms/index.js' import { qianfanIntercept } from '@cose/core/src/platforms/qianfan.js' import { convertAvatarToBase64 } from '@cose/detection/src/utils.js' // [DISABLED] import { fillAlipayOpenContent } from '@cose/core/src/platforms/alipayopen.js' // ===== Offscreen helper ===== // Used for login detection in document context (cookies sent automatically) async function ensureOffscreen() { try { const existing = await chrome.offscreen.hasDocument() if (!existing) { await chrome.offscreen.createDocument({ url: 'offscreen.html', reasons: ['DOM_SCRAPING'], justification: 'Fetch with credentials in document context for login detection', }) // Wait for the offscreen document's scripts to load and register listeners. // createDocument resolves when the document is created, but scripts may not // have executed yet. Ping until the offscreen listener responds. const ready = await _waitForOffscreenReady(3000) if (!ready) { console.warn('[COSE] ensureOffscreen: offscreen document did not become ready in time') } } } catch (e) { console.log('[COSE] ensureOffscreen error:', e.message) } } /** * Ping the offscreen document until it responds, confirming its listener is active. */ async function _waitForOffscreenReady(timeoutMs = 3000) { const start = Date.now() while (Date.now() - start < timeoutMs) { try { const resp = await chrome.runtime.sendMessage({ type: 'OFFSCREEN_PING' }) if (resp && resp.pong) return true } catch (e) { // listener not ready yet } await new Promise(r => setTimeout(r, 50)) } return false } /** * Warm-up fetch via offscreen document. * This triggers the browser's cookie restoration (SSO, session cookies) * by making a fetch with credentials: 'include' in a document context. */ async function warmUpFetch(url) { try { const result = await sendOffscreenMessage({ type: 'OFFSCREEN_WARM_FETCH', payload: { url }, }) console.log(`[COSE] Warm-up fetch ${url}: status=${result?.data?.status}`) return result } catch (e) { console.log(`[COSE] Warm-up fetch failed for ${url}:`, e.message) return null } } /** * API fetch via offscreen document. * Makes a fetch with credentials: 'include' in a document context, * so cookies are automatically attached (unlike service worker fetch which strips Cookie headers). */ async function offscreenApiFetch(url, options = {}) { try { const result = await sendOffscreenMessage({ type: 'OFFSCREEN_API_FETCH', payload: { url, ...options }, }) console.log(`[COSE] Offscreen API fetch ${url}: status=${result?.data?.status}`) return result?.data || null } catch (e) { console.log(`[COSE] Offscreen API fetch failed for ${url}:`, e.message) return null } } // Export for use by detection modules globalThis.__coseWarmUpFetch = warmUpFetch globalThis.__coseOffscreenApiFetch = offscreenApiFetch /** * Serialized message sender for offscreen document. * chrome.runtime.sendMessage is broadcast-based; when multiple OFFSCREEN_* * messages are sent concurrently, responses can get mixed up or lost. * This queue ensures only one offscreen message is in-flight at a time. * Includes a timeout to prevent the queue from getting stuck if the offscreen * document is garbage-collected or fails to respond. */ let _offscreenQueue = Promise.resolve() function sendOffscreenMessage(msg, timeoutMs = 15000) { const p = _offscreenQueue.then(async () => { await ensureOffscreen() // Race the actual message against a timeout so the queue never gets stuck return Promise.race([ chrome.runtime.sendMessage(msg), new Promise((_, reject) => setTimeout(() => reject(new Error(`Offscreen message timeout (${msg.type})`)), timeoutMs) ), ]) }) // Chain but don't let errors break the queue _offscreenQueue = p.catch(() => {}) return p } /** * Execute a fetch in the context of a target site's tab. * This is needed for sites whose auth cookies are SameSite=Lax (default), * which won't be sent from cross-site contexts like offscreen documents. * * Strategy: find an existing tab for the domain, or create a temporary one, * then inject a script that makes the fetch with credentials: 'include'. */ async function tabContextFetch(siteUrl, apiUrl, options = {}) { const { responseType = 'json', timeout = 15000 } = options let createdTabId = null try { const urlObj = new URL(siteUrl) const pattern = `*://*.${urlObj.hostname.replace(/^www\./, '')}/*` console.log(`[COSE] tabContextFetch: looking for tabs matching ${pattern}`) // Find existing tab let tabs = await chrome.tabs.query({ url: pattern }) let tab = tabs.find(t => t.id && !t.discarded) console.log(`[COSE] tabContextFetch: found ${tabs.length} tabs, usable: ${tab ? tab.id : 'none'}`) if (!tab) { // Create a background tab (not active, for other platforms that need it) const newTab = await chrome.tabs.create({ url: siteUrl, active: false }) tab = newTab createdTabId = tab.id console.log(`[COSE] tabContextFetch: created background tab ${tab.id}`) // Wait for the tab to finish loading const currentTab = await chrome.tabs.get(tab.id) if (currentTab.status !== 'complete') { await new Promise((resolve, reject) => { const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener) reject(new Error('Tab load timeout')) }, timeout) const listener = (tabId, info) => { if (tabId === tab.id && info.status === 'complete') { chrome.tabs.onUpdated.removeListener(listener) clearTimeout(timer) resolve() } } chrome.tabs.onUpdated.addListener(listener) }) } } // Inject script to make the fetch in the page's main world // so that credentials: 'include' sends the page's cookies const results = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: async (fetchUrl, respType) => { try { const resp = await fetch(fetchUrl, { method: 'GET', credentials: 'include', headers: { 'Accept': respType === 'json' ? 'application/json' : 'text/html' }, }) const status = resp.status const finalUrl = resp.url let body = null if (respType === 'json') { try { body = await resp.json() } catch (e) { body = null } } else { body = await resp.text() } return { status, url: finalUrl, body } } catch (e) { return { error: e.message } } }, args: [apiUrl, responseType], world: 'MAIN', }) // Clean up created tab if (createdTabId) { try { await chrome.tabs.remove(createdTabId) } catch (e) { /* ignore */ } } console.log(`[COSE] tabContextFetch result:`, JSON.stringify(results?.[0]?.result).substring(0, 200)) return results?.[0]?.result || null } catch (e) { // Clean up on error if (createdTabId) { try { await chrome.tabs.remove(createdTabId) } catch (e2) { /* ignore */ } } console.log(`[COSE] tabContextFetch failed for ${apiUrl}:`, e.message) return null } } globalThis.__coseTabContextFetch = tabContextFetch /** * 51CTO detection via offscreen document (爱贝壳 approach). * Offscreen has document context so DOMParser is available. * Includes retry logic in case the offscreen document wasn't ready on first attempt. */ async function detectCto51ViaOffscreen() { for (let attempt = 1; attempt <= 2; attempt++) { try { console.log(`[COSE] 51CTO: Sending OFFSCREEN_DETECT_CTO51 (attempt ${attempt})...`) const result = await sendOffscreenMessage({ type: 'OFFSCREEN_DETECT_CTO51', }) console.log('[COSE] 51CTO: Offscreen response:', JSON.stringify(result)) if (result === undefined || result === null) { // No listener responded — offscreen might not be ready console.warn('[COSE] 51CTO: Got empty response, offscreen may not be ready') if (attempt < 2) { await new Promise(r => setTimeout(r, 500)) continue } } return result?.data || null } catch (e) { console.log(`[COSE] 51CTO offscreen detection failed (attempt ${attempt}):`, e.message) if (attempt < 2) { await new Promise(r => setTimeout(r, 500)) continue } return null } } return null } globalThis.__coseDetectCto51 = detectCto51ViaOffscreen /** * Cnblogs detection via offscreen document. * Offscreen has document context so cookies are sent automatically. */ async function detectCnblogsViaOffscreen() { try { console.log('[COSE] Cnblogs: Sending OFFSCREEN_DETECT_CNBLOGS message...') const result = await sendOffscreenMessage({ type: 'OFFSCREEN_DETECT_CNBLOGS', }) console.log('[COSE] Cnblogs: Offscreen response:', JSON.stringify(result)) return result?.data || null } catch (e) { console.log('[COSE] Cnblogs offscreen detection failed:', e.message) return null } } globalThis.__coseDetectCnblogs = detectCnblogsViaOffscreen /** * Xiaohongshu detection via offscreen document. * Offscreen has document context so cookies are sent automatically. */ async function detectXiaohongshuViaOffscreen() { try { console.log('[COSE] Xiaohongshu: Sending OFFSCREEN_DETECT_XIAOHONGSHU message...') const result = await sendOffscreenMessage({ type: 'OFFSCREEN_DETECT_XIAOHONGSHU', }) console.log('[COSE] Xiaohongshu: Offscreen response:', JSON.stringify(result)) return result?.data || null } catch (e) { console.log('[COSE] Xiaohongshu offscreen detection failed:', e.message) return null } } globalThis.__coseDetectXiaohongshu = detectXiaohongshuViaOffscreen // 初始化动态规则:为 sinaimg 和 sspai 头像添加 CORS 头 async function initDynamicRules() { try { // 先移除已有的规则 const existingRules = await chrome.declarativeNetRequest.getDynamicRules() const existingIds = existingRules.map(r => r.id) if (existingIds.length > 0) { await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds: existingIds }) } // 添加新规则 await chrome.declarativeNetRequest.updateDynamicRules({ addRules: [ { id: 1, priority: 100, action: { type: 'modifyHeaders', requestHeaders: [ { header: 'Referer', operation: 'set', value: 'https://weibo.com/' }, { header: 'Origin', operation: 'set', value: 'https://weibo.com' } ], responseHeaders: [ { header: 'Access-Control-Allow-Origin', operation: 'set', value: '*' } ] }, condition: { urlFilter: '*sinaimg.cn*', resourceTypes: ['image', 'xmlhttprequest'] } }, { id: 2, priority: 100, action: { type: 'modifyHeaders', requestHeaders: [ { header: 'Referer', operation: 'set', value: 'https://sspai.com/' }, { header: 'Origin', operation: 'set', value: 'https://sspai.com' } ], responseHeaders: [ { header: 'Access-Control-Allow-Origin', operation: 'set', value: '*' } ] }, condition: { urlFilter: '*cdnfile.sspai.com*', resourceTypes: ['image', 'xmlhttprequest'] } } ] }) console.log('[COSE] 动态规则初始化完成') } catch (e) { console.error('[COSE] 动态规则初始化失败:', e) } } // 扩展安装/更新/启动时初始化规则 chrome.runtime.onInstalled.addListener(() => { initDynamicRules() }) chrome.runtime.onStartup.addListener(() => { initDynamicRules() }) // 当前同步任务的 Tab Group ID let currentSyncGroupId = null // 存储平台用户信息 const PLATFORM_USER_INFO = {} // 获取或创建同步标签组 async function getOrCreateSyncGroup(windowId) { // 如果已有 group 且仍然有效,直接返回 if (currentSyncGroupId !== null) { try { const groups = await chrome.tabGroups.query({ windowId }) const existingGroup = groups.find(g => g.id === currentSyncGroupId) if (existingGroup) { return currentSyncGroupId } } catch (e) { // Group 不存在,需要创建新的 } } // 创建新的标签组(先创建一个空组是不行的,需要先有 tab) currentSyncGroupId = null return null } // 将标签添加到同步组 async function addTabToSyncGroup(tabId, windowId) { try { if (currentSyncGroupId === null) { // 创建新组 currentSyncGroupId = await chrome.tabs.group({ tabIds: tabId }) // 设置组的样式,使用时间戳作为标题 const now = new Date() const timestamp = `${now.getMonth() + 1}/${now.getDate()} ${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}` await chrome.tabGroups.update(currentSyncGroupId, { title: `${timestamp}`, color: 'blue', collapsed: false, }) } else { // 添加到现有组 await chrome.tabs.group({ tabIds: tabId, groupId: currentSyncGroupId }) } } catch (error) { console.error('[COSE] 添加标签到组失败:', error) } } // 登录检测配置 // 登录检测配置由 import 导入 async function logToStorage(msg, data = null) { try { const timestamp = new Date().toISOString() const logMsg = data ? `${msg} ${JSON.stringify(data)}` : msg const { debug_logs = [] } = await chrome.storage.local.get('debug_logs') debug_logs.push(`[${timestamp}] ${logMsg}`) if (debug_logs.length > 500) debug_logs.shift() await chrome.storage.local.set({ debug_logs }) } catch (e) { console.error('Error logging to storage:', e) } console.log(msg, data || '') } // 消息监听 chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { // Let OFFSCREEN_* messages pass through to the offscreen document listener. // If we handle them here, sendResponse fires before the offscreen doc can reply. if (request.type && request.type.startsWith('OFFSCREEN_')) { return false } if (request.type === 'GET_DEBUG_LOGS') { chrome.storage.local.get('debug_logs', (result) => { sendResponse({ logs: result.debug_logs || [] }) }) return true } (async () => { try { const result = await handleMessage(request, sender) sendResponse(result) } catch (err) { console.error('[COSE] 消息处理错误:', err) sendResponse({ error: err.message || '未知错误' }) } })() return true // 表示异步响应 }) async function handleMessage(request, sender) { console.log(`[COSE] handleMessage received type: ${request.type}`, request) switch (request.type) { case 'GET_PLATFORMS': return { platforms: PLATFORMS } case 'CHECK_PLATFORM_STATUS': return { status: await checkAllPlatforms(request.platforms || PLATFORMS) } case 'CHECK_PLATFORM_STATUS_PROGRESSIVE': // 渐进式检测:每个平台检测完成后立即返回结果 checkAllPlatformsProgressive(request.platforms || PLATFORMS, sender.tab?.id) return { started: true, total: (request.platforms || PLATFORMS).length } case 'START_SYNC_BATCH': // 开始新的同步批次,重置 tab group currentSyncGroupId = null return { success: true } case 'SYNC_TO_PLATFORM': return await syncToPlatform(request.platformId, request.content) case 'CACHE_USER_INFO': // 缓存用户信息 if (request.platform === 'xiaohongshu' && request.userInfo) { await chrome.storage.local.set({ xiaohongshu_user: request.userInfo }) console.log('[COSE] 小红书用户信息已缓存:', request.userInfo.username) } else if (request.platform === 'alipayopen' && request.userInfo) { await chrome.storage.local.set({ alipayopen_user: request.userInfo }) console.log('[COSE] 支付宝用户信息已缓存:', request.userInfo.username) } else if (request.platform === 'huaweicloud' && request.userInfo) { const hwcInfo = { ...request.userInfo } if (hwcInfo.avatar && hwcInfo.avatar.startsWith('http')) { hwcInfo.avatar = await convertAvatarToBase64(hwcInfo.avatar, 'https://bbs.huaweicloud.com/') } await chrome.storage.local.set({ huaweicloud_user: hwcInfo }) console.log('[COSE] 华为云用户信息已缓存:', hwcInfo.username) } else if (request.platform === 'huaweidev' && request.userInfo) { const hwdInfo = { ...request.userInfo } if (hwdInfo.avatar && hwdInfo.avatar.startsWith('http')) { hwdInfo.avatar = await convertAvatarToBase64(hwdInfo.avatar, 'https://developer.huawei.com/') } await chrome.storage.local.set({ huaweidev_user: hwdInfo }) console.log('[COSE] 华为开发者用户信息已缓存:', hwdInfo.username) } return { success: true } default: return { error: 'Unknown message type' } } } // 检查所有平台登录状态 async function checkAllPlatforms(platforms) { const status = {} try { // 过滤掉无效的平台配置 const validPlatforms = (platforms || []).filter(p => p && p.id) const results = await Promise.allSettled( validPlatforms.map(async (platform) => { try { const result = await checkPlatformLogin(platform) return { id: platform.id, result } } catch (e) { return { id: platform.id, result: { loggedIn: false, error: e.message } } } }) ) results.forEach((res) => { if (res.status === 'fulfilled' && res.value?.id) { status[res.value.id] = res.value.result } }) } catch (e) { console.error('[COSE] 检查平台状态失败:', e) } return status } // 渐进式检查所有平台登录状态(每个平台完成后立即返回结果) async function checkAllPlatformsProgressive(platforms, tabId) { const validPlatforms = (platforms || []).filter(p => p && p.id) let completed = 0 const total = validPlatforms.length // 并行检查所有平台,每个完成后立即发送结果 const promises = validPlatforms.map(async (platform) => { try { const result = await checkPlatformLogin(platform) completed++ // 通过 content script 发送单个平台结果回页面 if (tabId) { try { await chrome.tabs.sendMessage(tabId, { type: 'PLATFORM_STATUS_UPDATE', platformId: platform.id, platform: platform, result: result, completed: completed, total: total }) } catch (e) { console.log('[COSE] 发送平台状态更新失败:', platform.id, e.message) } } return { id: platform.id, result } } catch (e) { completed++ const errorResult = { loggedIn: false, error: e.message } if (tabId) { try { await chrome.tabs.sendMessage(tabId, { type: 'PLATFORM_STATUS_UPDATE', platformId: platform.id, platform: platform, result: errorResult, completed: completed, total: total }) } catch (e2) { console.log('[COSE] 发送平台状态更新失败:', platform.id, e2.message) } } return { id: platform.id, result: errorResult } } }) // 等待所有完成后发送完成消息 await Promise.allSettled(promises) if (tabId) { try { await chrome.tabs.sendMessage(tabId, { type: 'PLATFORM_STATUS_COMPLETE', total: total }) } catch (e) { console.log('[COSE] 发送完成消息失败:', e.message) } } } import { detectUser } from '@cose/detection' // 检查单个平台登录状态 async function checkPlatformLogin(platform) { if (!platform || !platform.id) { return { loggedIn: false, error: '无效的平台配置' } } return await detectUser(platform.id) } async function pasteWithDebugger(tabId) { const debuggee = { tabId } try { // 附加调试器 await chrome.debugger.attach(debuggee, '1.3') console.log('[COSE] Debugger attached') // 发送 Ctrl/Cmd 按下 await chrome.debugger.sendCommand(debuggee, 'Input.dispatchKeyEvent', { type: 'keyDown', modifiers: 2, // Ctrl windowsVirtualKeyCode: 17, code: 'ControlLeft', key: 'Control' }) // 发送 V 按下(带 Ctrl 修饰符) await chrome.debugger.sendCommand(debuggee, 'Input.dispatchKeyEvent', { type: 'keyDown', modifiers: 2, // Ctrl windowsVirtualKeyCode: 86, code: 'KeyV', key: 'v' }) // 发送 V 释放 await chrome.debugger.sendCommand(debuggee, 'Input.dispatchKeyEvent', { type: 'keyUp', modifiers: 2, windowsVirtualKeyCode: 86, code: 'KeyV', key: 'v' }) // 发送 Ctrl 释放 await chrome.debugger.sendCommand(debuggee, 'Input.dispatchKeyEvent', { type: 'keyUp', modifiers: 0, windowsVirtualKeyCode: 17, code: 'ControlLeft', key: 'Control' }) console.log('[COSE] Paste command sent via debugger') // 等待粘贴完成 await new Promise(resolve => setTimeout(resolve, 1000)) } catch (error) { console.error('[COSE] Debugger paste failed:', error) } finally { // 分离调试器 try { await chrome.debugger.detach(debuggee) console.log('[COSE] Debugger detached') } catch (e) { // 忽略分离错误 } } } // 同步到平台 async function syncToPlatform(platformId, content) { const platform = PLATFORMS.find(p => p && p.id === platformId) if (!platform || !platform.publishUrl) { return { success: false, message: '暂不支持该平台' } } try { let tab // 检查是否有平台特定的同步处理器 const syncHandler = SYNC_HANDLERS[platformId] if (syncHandler) { console.log(`[COSE] 使用 ${platformId} 平台特定同步处理器`) // 创建新标签页(对于微信等需要特殊处理的平台,使用首页) const initialUrl = platformId === 'wechat' ? 'https://mp.weixin.qq.com/' : platform.publishUrl tab = await chrome.tabs.create({ url: initialUrl, active: false }) await addTabToSyncGroup(tab.id, tab.windowId) // 调用平台特定处理器 const helpers = { chrome, waitForTab, addTabToSyncGroup, PLATFORMS, } return await syncHandler(tab, content, helpers) } // ==== 以下是原有的平台特定逻辑(待迁移)==== if (platformId === 'infoq') { // InfoQ:需要先调用 API 创建草稿获取 ID,不能直接访问 /draft/write try { // 调用创建草稿 API const response = await fetch('https://xie.infoq.cn/api/v1/draft/create', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', } }) const data = await response.json() if (data.code === 0 && data.data?.id) { const draftId = data.data.id const targetUrl = `https://xie.infoq.cn/draft/${draftId}` console.log('[COSE] InfoQ 创建草稿成功,ID:', draftId) tab = await chrome.tabs.create({ url: targetUrl, active: false }) await addTabToSyncGroup(tab.id, tab.windowId) await waitForTab(tab.id) } else { console.error('[COSE] InfoQ 创建草稿失败:', data) return { success: false, message: 'InfoQ 创建草稿失败,请确保已登录' } } } catch (e) { console.error('[COSE] InfoQ API 调用失败:', e) return { success: false, message: 'InfoQ API 调用失败: ' + e.message } } } else if (platformId === 'jianshu') { // 简书:需要先获取文集列表,然后创建新文章 try { // 获取用户的文集列表 const notebooksResp = await fetch('https://www.jianshu.com/author/notebooks', { method: 'GET', credentials: 'include', headers: { 'Accept': 'application/json', } }) const notebooks = await notebooksResp.json() if (!notebooks || notebooks.length === 0) { return { success: false, message: '简书未找到文集,请先创建一个文集' } } // 使用第一个文集 const notebookId = notebooks[0].id console.log('[COSE] 简书使用文集:', notebooks[0].name, 'ID:', notebookId) // 创建新文章 const createResp = await fetch('https://www.jianshu.com/author/notes', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ notebook_id: String(notebookId), title: content.title || '无标题', at_bottom: false }) }) const noteData = await createResp.json() if (noteData && noteData.id) { const noteId = noteData.id const targetUrl = `https://www.jianshu.com/writer#/notebooks/${notebookId}/notes/${noteId}` console.log('[COSE] 简书创建文章成功,ID:', noteId) tab = await chrome.tabs.create({ url: targetUrl, active: false }) await addTabToSyncGroup(tab.id, tab.windowId) await waitForTab(tab.id) } else { console.error('[COSE] 简书创建文章失败:', noteData) return { success: false, message: '简书创建文章失败,请确保已登录' } } } catch (e) { console.error('[COSE] 简书 API 调用失败:', e) return { success: false, message: '简书 API 调用失败: ' + e.message } } } else if (platformId === 'xiaohongshu') { // 小红书:需要先点击"新的创作"按钮,等待编辑器加载后填充 console.log('[COSE] 开始处理小红书同步...') // 打开发布页面 tab = await chrome.tabs.create({ url: platform.publishUrl, active: false }) await addTabToSyncGroup(tab.id, tab.windowId) await waitForTab(tab.id) // 等待页面加载完成 await new Promise(resolve => setTimeout(resolve, 3000)) // 在页面中执行:点击"新的创作"并等待编辑器加载 const clickResult = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: async () => { const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) // 查找"新的创作"按钮 const createBtn = Array.from(document.querySelectorAll('button')) .find(el => el.textContent.includes('新的创作')) if (createBtn) { createBtn.click() console.log('[COSE] 小红书已点击"新的创作"按钮') // 等待编辑器加载(等待富文本编辑器出现) const waitForEditor = async (timeout = 10000) => { const start = Date.now() while (Date.now() - start < timeout) { // 查找编辑器元素,可能是 contenteditable 或 textarea const editor = document.querySelector('[contenteditable="true"]') || document.querySelector('textarea') || document.querySelector('.editor') || document.querySelector('.content-editor') if (editor) return true await sleep(200) } return false } const editorLoaded = await waitForEditor() return { success: editorLoaded, message: editorLoaded ? 'Editor loaded' : 'Editor timeout' } } return { success: false, message: 'Create button not found' } } }) if (!clickResult[0]?.result?.success) { return { success: false, message: '小红书创建文章失败: ' + (clickResult[0]?.result?.message || '未知错误') } } // 等待页面稳定 await new Promise(resolve => setTimeout(resolve, 1000)) // 使用剪贴板 HTML(带完整样式)或降级到 body const htmlContent = content.wechatHtml || content.body console.log('[COSE] 小红书 HTML 内容长度:', htmlContent?.length || 0) // 填充标题和内容 const fillResult = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: async (title, htmlBody) => { const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) // 等待元素出现的工具函数 const waitForElement = (selector, timeout = 15000) => { return new Promise((resolve) => { const el = document.querySelector(selector) if (el) return resolve(el) const observer = new MutationObserver(() => { const el = document.querySelector(selector) if (el) { observer.disconnect() resolve(el) } }) observer.observe(document.body, { childList: true, subtree: true }) setTimeout(() => { observer.disconnect() resolve(document.querySelector(selector)) }, timeout) }) } try { console.log('[COSE] 小红书开始填充内容...') // 等待并查找标题输入框 const titleInput = await waitForElement('input[placeholder*="标题"], textarea[placeholder*="标题"], .title-input', 5000) if (titleInput && title) { titleInput.focus() // 使用 native setter 确保 React/Vue 等框架能检测到变化 const nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set if (nativeSetter) { nativeSetter.call(titleInput, title) } else { titleInput.value = title } titleInput.dispatchEvent(new Event('input', { bubbles: true })) titleInput.dispatchEvent(new Event('change', { bubbles: true })) console.log('[COSE] 小红书标题已填充:', title) } // 稍等一下让标题生效 await new Promise(r => setTimeout(r, 300)) // 等待并查找内容编辑器 const contentEditor = await waitForElement('[contenteditable="true"], .editor-content, .content-editor', 5000) if (contentEditor && htmlBody) { contentEditor.focus() // 清空现有占位符内容 if (contentEditor.textContent.includes('从这里开始写正文') || contentEditor.textContent.includes('请输入正文') || contentEditor.textContent.includes('写点什么')) { contentEditor.innerHTML = '' } // 使用 ClipboardEvent + DataTransfer 注入 HTML const dt = new DataTransfer() dt.setData('text/html', htmlBody) dt.setData('text/plain', htmlBody.replace(/<[^>]*>/g, '')) const pasteEvent = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt }) contentEditor.dispatchEvent(pasteEvent) console.log('[COSE] 小红书内容已通过 paste 事件注入') // 等待内容渲染 await new Promise(r => setTimeout(r, 500)) // 验证内容是否注入成功 const wordCount = contentEditor.textContent?.length || 0 if (wordCount === 0) { // 备用方案:直接设置 innerHTML console.log('[COSE] paste 事件未生效,尝试备用方案') contentEditor.innerHTML = htmlBody } return { success: true, method: 'paste-html', length: htmlBody.length } } return { success: false, error: 'Content editor not found' } } catch (e) { console.error('[COSE] 小红书同步失败:', e) return { success: false, error: e.message } } }, args: [content.title, htmlContent], world: 'MAIN', }) console.log('[COSE] 小红书填充结果:', fillResult[0]?.result) // 等待内容注入完成 await new Promise(resolve => setTimeout(resolve, 1000)) return { success: true, message: '已同步到小红书', tabId: tab.id } } else if (platformId === 'twitter') { // Twitter Articles:需要先打开草稿列表页,然后点击 create 按钮创建新文章 // 注意:Twitter 使用 Page Visibility API,后台标签页不会渲染编辑器 // 解决方案:短暂激活 Tab,等编辑器加载后切回原 Tab // 记录当前活动的 Tab const [currentTab] = await chrome.tabs.query({ active: true, currentWindow: true }) // 第一步:打开草稿列表页(激活状态,触发编辑器渲染) tab = await chrome.tabs.create({ url: platform.publishUrl, active: true }) await addTabToSyncGroup(tab.id, tab.windowId) await waitForTab(tab.id) // 等待页面加载 await new Promise(resolve => setTimeout(resolve, 1000)) // 第二步:点击 create 按钮并等待编辑器加载完成 const clickResult = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: async () => { const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) // 查找 create 按钮 const createBtn = document.querySelector('button[aria-label="create"]') || Array.from(document.querySelectorAll('button')).find(b => b.getAttribute('aria-label')?.toLowerCase() === 'create' ) if (createBtn) { createBtn.click() console.log('[COSE] Twitter Articles 已点击 create 按钮') // 等待编辑器加载(等待标题输入框出现) const waitForEditor = async (timeout = 10000) => { const start = Date.now() while (Date.now() - start < timeout) { const titleInput = document.querySelector('textarea[placeholder="Add a title"]') if (titleInput) return true await sleep(200) } return false } const editorLoaded = await waitForEditor() return { success: editorLoaded, message: editorLoaded ? 'Editor loaded' : 'Editor timeout' } } return { success: false, message: 'Create button not found' } }, world: 'MAIN', }) console.log('[COSE] Twitter Articles create 结果:', clickResult[0]?.result) // 编辑器加载完成后,切回原 Tab if (currentTab?.id) { try { await chrome.tabs.update(currentTab.id, { active: true }) console.log('[COSE] Twitter 已切回原 Tab') } catch (e) { // 原 Tab 可能已关闭,忽略 } } if (!clickResult[0]?.result?.success) { return { success: false, message: 'Twitter Articles 创建文章失败: ' + (clickResult[0]?.result?.message || '未知错误') } } // 等待页面稳定 await new Promise(resolve => setTimeout(resolve, 500)) // 使用 Markdown 内容 const markdownContent = content.markdown || content.body || '' console.log('[COSE] Twitter Articles Markdown 内容长度:', markdownContent?.length || 0) // 第三步:填充标题和内容 const fillResult = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: async (title, markdown) => { // ========== 工具函数 ========== const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) const waitForElement = async (selector, timeout = 10000) => { const start = Date.now() while (Date.now() - start < timeout) { const el = document.querySelector(selector) if (el) return el await sleep(200) } return null } // ========== 内置 Markdown 解析器(支持代码块和公式)========== // 使用占位符保护机制,避免正则冲突 // 代码块使用样式化 HTML,公式使用 CodeCogs API 渲染为图片 function parseMarkdownToHtml(md) { if (!md) return '' // 存储需要保护的内容 const codeBlocks = [] const inlineCodes = [] const blockFormulas = [] const inlineFormulas = [] let html = md // ========== 第一阶段:提取并保护特殊内容 ========== // 1. 提取代码块 ```...``` html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => { const index = codeBlocks.length codeBlocks.push({ lang: lang || '', code: code }) return `__CODE_BLOCK_${index}__` }) // 2. 提取行内代码 `...` html = html.replace(/`([^`\n]+)`/g, (match, code) => { const index = inlineCodes.length inlineCodes.push(code) return `__INLINE_CODE_${index}__` }) // 3. 提取块级公式 $$...$$ html = html.replace(/\$\$([\s\S]+?)\$\$/g, (match, formula) => { const index = blockFormulas.length blockFormulas.push(formula.trim()) return `__BLOCK_FORMULA_${index}__` }) // 4. 提取行内公式 $...$ html = html.replace(/\$([^\$\n]+)\$/g, (match, formula) => { const index = inlineFormulas.length inlineFormulas.push(formula.trim()) return `__INLINE_FORMULA_${index}__` }) // ========== 第二阶段:处理标准 Markdown 语法 ========== // 处理标题 html = html.replace(/^#### (.+)$/gm, '

$1

') html = html.replace(/^### (.+)$/gm, '

$1

') html = html.replace(/^## (.+)$/gm, '

$1

') html = html.replace(/^# (.+)$/gm, '

$1

') // 处理引用块 html = html.replace(/^> (.+)$/gm, '
$1
') // 处理水平分割线 // 注意: X Articles 忽略
标签,需要通过 Insert > Divider 菜单插入 // 自动同步无法使用菜单,这里保留 hr 但用户可能需要手动调整 // 或者可以考虑用视觉分隔符如 --- 文本替代 html = html.replace(/^---$/gm, '

---

') html = html.replace(/^\*\*\*$/gm, '

***

') // 处理图片 html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1') // 处理链接 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') // 处理粗体、斜体、删除线 html = html.replace(/\*\*([^*]+)\*\*/g, '$1') html = html.replace(/\*([^*]+)\*/g, '$1') html = html.replace(/~~([^~]+)~~/g, '$1') // ========== 第三阶段:恢复保护的内容 ========== // 恢复代码块 - X Articles 不支持
,转换为 blockquote
            // 参考 x-article-publisher skill 的实现
            codeBlocks.forEach((block, index) => {
              const escapedCode = block.code
                .replace(/&/g, '&')
                .replace(//g, '>')
                .replace(/"/g, '"')
                .replace(/'/g, ''')

              // 将代码行用 
连接,包装在 blockquote 中 // X Articles 原生支持 blockquote,这是最可靠的代码块显示方式 const lines = escapedCode.split('\n').filter(line => line.trim()) const formattedCode = lines.join('
') const langPrefix = block.lang ? `${block.lang}
` : '' const codeHtml = `
${langPrefix}${formattedCode}
` html = html.replace(`__CODE_BLOCK_${index}__`, codeHtml) }) // 恢复行内代码 - X Articles 对 inline style 支持有限 // 使用简单的 标签,依赖平台默认样式 inlineCodes.forEach((code, index) => { const escapedCode = code .replace(/&/g, '&') .replace(//g, '>') // 简化为纯 code 标签,X Articles 会应用默认样式 const codeHtml = `${escapedCode}` html = html.replace(`__INLINE_CODE_${index}__`, codeHtml) }) // 恢复块级公式(使用 CodeCogs API 渲染为图片) blockFormulas.forEach((formula, index) => { const encodedFormula = encodeURIComponent(formula) const formulaHtml = `
${formula.replace(/
` html = html.replace(`__BLOCK_FORMULA_${index}__`, formulaHtml) }) // 恢复行内公式 inlineFormulas.forEach((formula, index) => { const encodedFormula = encodeURIComponent(formula) const formulaHtml = `${formula.replace(/` html = html.replace(`__INLINE_FORMULA_${index}__`, formulaHtml) }) // ========== 第四阶段:处理列表和段落 ========== // 处理无序列表项 html = html.replace(/^[\*\-\+] (.+)$/gm, '
  • $1
  • ') // 处理有序列表项 html = html.replace(/^\d+[\.\)] (.+)$/gm, '
  • $1
  • ') // 将连续的
  • 包装成