diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml
index 92151c9..015df6b 100644
--- a/.github/workflows/deploy-website.yml
+++ b/.github/workflows/deploy-website.yml
@@ -130,8 +130,22 @@ jobs:
name: website-build
path: ${{ env.WEBSITE_DIR }}
+ - name: Resolve preview deploy eligibility
+ id: preview_eligibility
+ shell: bash
+ env:
+ CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ if [[ -n "${CF_API_TOKEN}" && -n "${CF_ACCOUNT_ID}" ]]; then
+ echo "can_deploy=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "can_deploy=false" >> "$GITHUB_OUTPUT"
+ fi
+
- name: Deploy Preview to Cloudflare
id: deploy
+ if: ${{ steps.preview_eligibility.outputs.can_deploy == 'true' }}
working-directory: ${{ env.WEBSITE_DIR }}
run: pnpm dlx @opennextjs/cloudflare deploy --env preview
env:
@@ -139,25 +153,57 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Upsert PR comment with preview URL (no spam)
+ if: ${{ always() && (steps.deploy.outcome == 'success' || steps.deploy.outcome == 'skipped') && github.event.pull_request.head.repo.fork == false }}
uses: actions/github-script@v8
env:
MARKER: ${{ env.PREVIEW_COMMENT_MARKER }}
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
+ DEPLOY_OUTCOME: ${{ steps.deploy.outcome }}
+ HEAD_IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
+ HAS_CF_SECRETS: ${{ steps.preview_eligibility.outputs.can_deploy }}
with:
script: |
const marker = process.env.MARKER;
- const url = process.env.DEPLOYMENT_URL || "Check the Cloudflare dashboard for the preview URL.";
+ const url = process.env.DEPLOYMENT_URL || "";
+ const deployOutcome = (process.env.DEPLOY_OUTCOME || "").toLowerCase();
+ const isFork = (process.env.HEAD_IS_FORK || "").toLowerCase() === "true";
+ const hasCfSecrets = (process.env.HAS_CF_SECRETS || "").toLowerCase() === "true";
+
+ const baseMeta = `- Branch: \`${context.payload.pull_request.head.ref}\`
+ - Commit: \`${context.sha}\``;
+
+ let body;
+ if (deployOutcome === "success") {
+ body = `${marker}
+ ## 🚀 Preview Deployment Ready!
+
+ Preview URL: ${url || "Check the Cloudflare dashboard for the preview URL."}
- const body = `${marker}
- ## 🚀 Preview Deployment Ready!
+ ${baseMeta}
- Preview URL: ${url}
+ (This comment will be updated on new pushes.)
+ `;
+ } else if (!hasCfSecrets && isFork) {
+ body = `${marker}
+ ## ℹ️ Preview Deployment Skipped
- - Branch: \`${context.payload.pull_request.head.ref}\`
- - Commit: \`${context.sha}\`
+ This PR comes from a fork, and repository Cloudflare secrets are not exposed to \`pull_request\` workflows.
- (This comment will be updated on new pushes.)
- `;
+ ${baseMeta}
+
+ (This comment will be updated on new pushes.)
+ `;
+ } else {
+ body = `${marker}
+ ## ℹ️ Preview Deployment Skipped
+
+ Preview deployment was skipped because required Cloudflare secrets are missing.
+
+ ${baseMeta}
+
+ (This comment will be updated on new pushes.)
+ `;
+ }
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
diff --git a/README.md b/README.md
index 0bc4cb5..13a356f 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ Your AI conversations deserve a better clipboard.
[](https://gemini.google.com)
[](https://chat.deepseek.com)
[](https://grok.com)
+[](https://www.doubao.com)
[](https://github.com)
@@ -167,7 +168,7 @@ Then load the unpacked extension from `apps/browser-extension/dist/chrome-mv3-de
### Usage
-1. Navigate to any supported platform (ChatGPT, Claude, Gemini, DeepSeek, Grok, GitHub)
+1. Navigate to any supported platform (ChatGPT, Claude, Gemini, DeepSeek, Grok, Doubao, GitHub)
2. Start or open a conversation
3. Click the **CtxPort copy button** that appears in the chat, or press `Alt+Shift+C`
4. Paste your Context Bundle wherever you need it
@@ -183,6 +184,7 @@ For sidebar list copy: hover over any conversation in the left sidebar to reveal
- [x] Gemini support
- [x] DeepSeek support
- [x] Grok support
+- [x] Doubao (豆包) support
- [x] GitHub Issues & PRs support
- [x] Sidebar list copy
- [x] Multiple copy formats
diff --git a/README_cn.md b/README_cn.md
index 3904c37..fe79b9b 100644
--- a/README_cn.md
+++ b/README_cn.md
@@ -22,6 +22,7 @@
[](https://gemini.google.com)
[](https://chat.deepseek.com)
[](https://grok.com)
+[](https://www.doubao.com)
[](https://github.com)
@@ -167,7 +168,7 @@ pnpm dev:ext
### 使用方法
-1. 打开任何支持的平台(ChatGPT, Claude, Gemini, DeepSeek, Grok, GitHub)
+1. 打开任何支持的平台(ChatGPT, Claude, Gemini, DeepSeek, Grok, 豆包, GitHub)
2. 开始或打开一段对话
3. 点击对话中出现的 **CtxPort 复制按钮**,或按 `Alt+Shift+C`
4. 将 Context Bundle 粘贴到任何你需要的地方
@@ -183,6 +184,7 @@ pnpm dev:ext
- [x] Gemini 支持
- [x] DeepSeek 支持
- [x] Grok 支持
+- [x] 豆包 (Doubao) 支持
- [x] GitHub Issues & PRs 支持
- [x] 侧边栏列表复制
- [x] 多种复制格式
diff --git a/apps/web/content/en/supported-platforms.mdx b/apps/web/content/en/supported-platforms.mdx
index d3a3327..c238eb1 100644
--- a/apps/web/content/en/supported-platforms.mdx
+++ b/apps/web/content/en/supported-platforms.mdx
@@ -1,6 +1,6 @@
---
title: "Supported Platforms"
-description: "CtxPort supported platforms: ChatGPT, Claude, Gemini, DeepSeek, Grok, and GitHub. Full feature matrix for each platform."
+description: "CtxPort supported platforms: ChatGPT, Claude, Gemini, DeepSeek, Grok, Doubao, and GitHub. Full feature matrix for each platform."
---
# Supported Platforms
@@ -54,6 +54,16 @@ CtxPort works with the most popular AI chat platforms and GitHub. Here's what's
- All four copy formats (Full, User Only, Code Only, Compact)
- Context menu support
+## Doubao
+
+**URL:** [www.doubao.com](https://www.doubao.com)
+
+- In-chat copy button
+- Sidebar list copy (hover to copy without opening)
+- Keyboard shortcut (Alt+Shift+C)
+- All four copy formats (Full, User Only, Code Only, Compact)
+- Context menu support
+
## GitHub
**URL:** [github.com](https://github.com)
@@ -65,12 +75,12 @@ CtxPort works with the most popular AI chat platforms and GitHub. Here's what's
## Feature Support Matrix
-| Feature | ChatGPT | Claude | Gemini | DeepSeek | Grok | GitHub |
-|---------|---------|--------|--------|----------|------|--------|
-| In-chat copy button | Yes | Yes | Yes | Yes | Yes | Yes |
-| Sidebar list copy | Yes | Yes | — | — | — | — |
-| Keyboard shortcut | Yes | Yes | Yes | Yes | Yes | Yes |
-| Full format | Yes | Yes | Yes | Yes | Yes | Yes |
-| User Only format | Yes | Yes | Yes | Yes | Yes | Yes |
-| Code Only format | Yes | Yes | Yes | Yes | Yes | Yes |
-| Compact format | Yes | Yes | Yes | Yes | Yes | Yes |
+| Feature | ChatGPT | Claude | Gemini | DeepSeek | Grok | Doubao | GitHub |
+|---------|---------|--------|--------|----------|------|--------|--------|
+| In-chat copy button | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| Sidebar list copy | Yes | Yes | — | — | — | Yes | — |
+| Keyboard shortcut | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| Full format | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| User Only format | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| Code Only format | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| Compact format | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
diff --git a/apps/web/content/zh/supported-platforms.mdx b/apps/web/content/zh/supported-platforms.mdx
index 6fb91c9..2d81898 100644
--- a/apps/web/content/zh/supported-platforms.mdx
+++ b/apps/web/content/zh/supported-platforms.mdx
@@ -1,6 +1,6 @@
---
title: "支持平台"
-description: "CtxPort 支持的平台:ChatGPT、Claude、Gemini、DeepSeek、Grok 和 GitHub。各平台功能支持详情。"
+description: "CtxPort 支持的平台:ChatGPT、Claude、Gemini、DeepSeek、Grok、豆包和 GitHub。各平台功能支持详情。"
---
# 支持平台
@@ -54,6 +54,16 @@ CtxPort 支持最主流的 AI 聊天平台和 GitHub。以下是各平台的功
- 四种复制格式(Full、User Only、Code Only、Compact)
- 右键菜单支持
+## 豆包
+
+**地址:** [www.doubao.com](https://www.doubao.com)
+
+- 对话内复制按钮
+- 侧边栏列表复制(悬停即可复制,无需打开对话)
+- 键盘快捷键(Alt+Shift+C)
+- 四种复制格式(Full、User Only、Code Only、Compact)
+- 右键菜单支持
+
## GitHub
**地址:** [github.com](https://github.com)
@@ -65,12 +75,12 @@ CtxPort 支持最主流的 AI 聊天平台和 GitHub。以下是各平台的功
## 功能支持对照表
-| 功能 | ChatGPT | Claude | Gemini | DeepSeek | Grok | GitHub |
-|------|---------|--------|--------|----------|------|--------|
-| 对话内复制按钮 | Yes | Yes | Yes | Yes | Yes | Yes |
-| 侧边栏列表复制 | Yes | Yes | — | — | — | — |
-| 键盘快捷键 | Yes | Yes | Yes | Yes | Yes | Yes |
-| Full 格式 | Yes | Yes | Yes | Yes | Yes | Yes |
-| User Only 格式 | Yes | Yes | Yes | Yes | Yes | Yes |
-| Code Only 格式 | Yes | Yes | Yes | Yes | Yes | Yes |
-| Compact 格式 | Yes | Yes | Yes | Yes | Yes | Yes |
+| 功能 | ChatGPT | Claude | Gemini | DeepSeek | Grok | 豆包 | GitHub |
+|------|---------|--------|--------|----------|------|------|--------|
+| 对话内复制按钮 | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| 侧边栏列表复制 | Yes | Yes | — | — | — | Yes | — |
+| 键盘快捷键 | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| Full 格式 | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| User Only 格式 | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| Code Only 格式 | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
+| Compact 格式 | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
diff --git a/apps/web/src/components/home/landing-page.tsx b/apps/web/src/components/home/landing-page.tsx
index 35789d3..d91b073 100644
--- a/apps/web/src/components/home/landing-page.tsx
+++ b/apps/web/src/components/home/landing-page.tsx
@@ -43,6 +43,7 @@ const PLATFORMS = [
"Gemini",
"DeepSeek",
"Grok",
+ "Doubao",
"GitHub",
] as const;
@@ -621,6 +622,10 @@ function PlatformsSection() {
name: t("web.home.platforms.grok.name"),
desc: t("web.home.platforms.grok.desc"),
},
+ {
+ name: t("web.home.platforms.doubao.name"),
+ desc: t("web.home.platforms.doubao.desc"),
+ },
{
name: t("web.home.platforms.github.name"),
desc: t("web.home.platforms.github.desc"),
diff --git a/apps/web/src/components/structured-data.tsx b/apps/web/src/components/structured-data.tsx
index b5658dd..42a8ce6 100644
--- a/apps/web/src/components/structured-data.tsx
+++ b/apps/web/src/components/structured-data.tsx
@@ -5,7 +5,7 @@ const softwareApp = {
"@type": "SoftwareApplication",
name: "CtxPort",
description:
- "Copy AI conversations as structured Markdown Context Bundles. One-click copy from ChatGPT, Claude, Gemini, DeepSeek, Grok and more.",
+ "Copy AI conversations as structured Markdown Context Bundles. One-click copy from ChatGPT, Claude, Gemini, DeepSeek, Grok, Doubao and more.",
url: siteConfig.url,
applicationCategory: "BrowserApplication",
operatingSystem: "Chrome",
diff --git a/packages/core-plugins/src/index.ts b/packages/core-plugins/src/index.ts
index 6873007..0076938 100644
--- a/packages/core-plugins/src/index.ts
+++ b/packages/core-plugins/src/index.ts
@@ -25,6 +25,7 @@ export {
chatgptPlugin,
claudePlugin,
deepseekPlugin,
+ doubaoPlugin,
geminiPlugin,
githubPlugin,
grokPlugin,
@@ -34,6 +35,7 @@ export {
import { chatgptPlugin } from "./plugins/chatgpt/plugin";
import { claudePlugin } from "./plugins/claude/plugin";
import { deepseekPlugin } from "./plugins/deepseek/plugin";
+import { doubaoPlugin } from "./plugins/doubao/plugin";
import { geminiPlugin } from "./plugins/gemini/plugin";
import { githubPlugin } from "./plugins/github/plugin";
import { grokPlugin } from "./plugins/grok/plugin";
@@ -41,6 +43,7 @@ export const EXTENSION_HOST_PERMISSIONS = [
...chatgptPlugin.urls.hosts,
...claudePlugin.urls.hosts,
...deepseekPlugin.urls.hosts,
+ ...doubaoPlugin.urls.hosts,
...geminiPlugin.urls.hosts,
...githubPlugin.urls.hosts,
...grokPlugin.urls.hosts,
diff --git a/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts b/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts
new file mode 100644
index 0000000..bfc2794
--- /dev/null
+++ b/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts
@@ -0,0 +1,481 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { PluginContext } from "../../../types";
+import { doubaoPlugin } from "../plugin";
+
+describe("doubaoPlugin", () => {
+ describe("urls.match", () => {
+ it("matches doubao.com chat URL", () => {
+ expect(doubaoPlugin.urls.match("https://www.doubao.com/chat/12345")).toBe(
+ true,
+ );
+ });
+
+ it("matches doubao.com root URL", () => {
+ expect(doubaoPlugin.urls.match("https://www.doubao.com/")).toBe(true);
+ });
+
+ it("does not match other domains", () => {
+ expect(doubaoPlugin.urls.match("https://chat.openai.com/")).toBe(false);
+ expect(doubaoPlugin.urls.match("https://doubao.com/")).toBe(false);
+ });
+ });
+
+ describe("extract", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("throws on non-conversation URL", async () => {
+ const ctx: PluginContext = {
+ url: "https://www.doubao.com/",
+ document: {} as Document,
+ };
+ await expect(doubaoPlugin.extract(ctx)).rejects.toThrow();
+ });
+
+ it("extracts conversation from API responses", async () => {
+ const titleResponse = {
+ cmd: 1110,
+ downlink_body: {
+ get_conv_info_downlink_body: {
+ conversation_info: { name: "测试对话" },
+ },
+ },
+ };
+
+ const chainResponse = {
+ cmd: 3100,
+ downlink_body: {
+ pull_singe_chain_downlink_body: {
+ messages: [
+ {
+ conversation_id: "123",
+ message_id: "m1",
+ sender_id: "u1",
+ user_type: 1,
+ status: 0,
+ content_type: 0,
+ content: '{"text":"你好"}',
+ content_status: 0,
+ index_in_conv: "1",
+ create_time: "1700000000",
+ thinking_content: "",
+ },
+ {
+ conversation_id: "123",
+ message_id: "m2",
+ sender_id: "bot1",
+ user_type: 2,
+ status: 0,
+ content_type: 0,
+ content: "",
+ content_status: 0,
+ index_in_conv: "2",
+ create_time: "1700000001",
+ thinking_content: "",
+ content_block: [
+ {
+ block_type: 10000,
+ block_id: "b1",
+ parent_id: "",
+ content: {
+ text_block: { text: "你好!有什么可以帮你的?" },
+ },
+ },
+ ],
+ },
+ ],
+ has_more: false,
+ },
+ },
+ };
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/im/conversation/info")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(titleResponse),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(chainResponse),
+ });
+ }),
+ );
+
+ const ctx: PluginContext = {
+ url: "https://www.doubao.com/chat/conv_abc-123?foo=bar#history",
+ document: {} as Document,
+ };
+
+ const bundle = await doubaoPlugin.extract(ctx);
+
+ expect(bundle.title).toBe("测试对话");
+ expect(bundle.participants).toHaveLength(2);
+ expect(bundle.nodes).toHaveLength(2);
+ expect(bundle.nodes[0]!.content).toBe("你好");
+ expect(bundle.nodes[0]!.participantId).toBe("user");
+ expect(bundle.nodes[1]!.content).toBe("你好!有什么可以帮你的?");
+ expect(bundle.nodes[1]!.participantId).toBe("assistant");
+ expect(bundle.source.platform).toBe("doubao");
+ });
+
+ it("groups consecutive same-role messages", async () => {
+ const chainResponse = {
+ cmd: 3100,
+ downlink_body: {
+ pull_singe_chain_downlink_body: {
+ messages: [
+ {
+ conversation_id: "123",
+ message_id: "m1",
+ sender_id: "bot1",
+ user_type: 2,
+ status: 0,
+ content_type: 0,
+ content: "",
+ content_status: 0,
+ index_in_conv: "1",
+ create_time: "1700000000",
+ thinking_content: "",
+ content_block: [
+ {
+ block_type: 10000,
+ block_id: "b1",
+ parent_id: "",
+ content: { text_block: { text: "第一段" } },
+ },
+ ],
+ },
+ {
+ conversation_id: "123",
+ message_id: "m2",
+ sender_id: "bot1",
+ user_type: 2,
+ status: 0,
+ content_type: 0,
+ content: "",
+ content_status: 0,
+ index_in_conv: "2",
+ create_time: "1700000001",
+ thinking_content: "",
+ content_block: [
+ {
+ block_type: 10000,
+ block_id: "b2",
+ parent_id: "",
+ content: { text_block: { text: "第二段" } },
+ },
+ ],
+ },
+ ],
+ has_more: false,
+ },
+ },
+ };
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/im/conversation/info")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(chainResponse),
+ });
+ }),
+ );
+
+ const ctx: PluginContext = {
+ url: "https://www.doubao.com/chat/123",
+ document: {} as Document,
+ };
+
+ const bundle = await doubaoPlugin.extract(ctx);
+ expect(bundle.nodes).toHaveLength(1);
+ expect(bundle.nodes[0]!.content).toBe("第一段\n第二段");
+ });
+
+ it("handles JSON-encoded user content", async () => {
+ const chainResponse = {
+ cmd: 3100,
+ downlink_body: {
+ pull_singe_chain_downlink_body: {
+ messages: [
+ {
+ conversation_id: "123",
+ message_id: "m1",
+ sender_id: "u1",
+ user_type: 1,
+ status: 0,
+ content_type: 0,
+ content: '{"text":"这是用户消息"}',
+ content_status: 0,
+ index_in_conv: "1",
+ create_time: "1700000000",
+ thinking_content: "",
+ },
+ ],
+ has_more: false,
+ },
+ },
+ };
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/im/conversation/info")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(chainResponse),
+ });
+ }),
+ );
+
+ const ctx: PluginContext = {
+ url: "https://www.doubao.com/chat/123",
+ document: {} as Document,
+ };
+
+ const bundle = await doubaoPlugin.extract(ctx);
+ expect(bundle.nodes[0]!.content).toBe("这是用户消息");
+ });
+
+ it("paginates when has_more is true", async () => {
+ // FETCH_LIMIT is 20 — must return >= 20 messages to trigger pagination
+ const makeMessages = (
+ startIndex: number,
+ count: number,
+ userType: number,
+ ) =>
+ Array.from({ length: count }, (_, i) => ({
+ conversation_id: "123",
+ message_id: `m${startIndex + i}`,
+ sender_id: userType === 1 ? "u1" : "bot1",
+ user_type: userType,
+ status: 0,
+ content_type: 0,
+ content: userType === 1 ? `{"text":"msg-${startIndex + i}"}` : "",
+ content_status: 0,
+ index_in_conv: String(startIndex + i),
+ create_time: "1700000000",
+ thinking_content: "",
+ ...(userType === 2
+ ? {
+ content_block: [
+ {
+ block_type: 10000,
+ block_id: `b${startIndex + i}`,
+ parent_id: "",
+ content: { text_block: { text: `msg-${startIndex + i}` } },
+ },
+ ],
+ }
+ : {}),
+ }));
+
+ // Page 1: indices 21-40 (20 user messages), has_more=true
+ const page1 = {
+ cmd: 3100,
+ downlink_body: {
+ pull_singe_chain_downlink_body: {
+ messages: makeMessages(21, 20, 1),
+ has_more: true,
+ },
+ },
+ };
+ // Page 2: indices 1-20 (20 assistant messages), has_more=false
+ const page2 = {
+ cmd: 3100,
+ downlink_body: {
+ pull_singe_chain_downlink_body: {
+ messages: makeMessages(1, 20, 2),
+ has_more: false,
+ },
+ },
+ };
+
+ let chainCallCount = 0;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/im/conversation/info")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }),
+ });
+ }
+ chainCallCount++;
+ const page = chainCallCount === 1 ? page1 : page2;
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(page),
+ });
+ }),
+ );
+
+ const ctx: PluginContext = {
+ url: "https://www.doubao.com/chat/123",
+ document: {} as Document,
+ };
+
+ const bundle = await doubaoPlugin.extract(ctx);
+ expect(chainCallCount).toBe(2);
+ // 20 assistant messages (indices 1-20) grouped into 1 node,
+ // then 20 user messages (indices 21-40) grouped into 1 node
+ expect(bundle.nodes).toHaveLength(2);
+ expect(bundle.nodes[0]!.participantId).toBe("assistant");
+ expect(bundle.nodes[1]!.participantId).toBe("user");
+ });
+
+ it("throws on API error response", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/im/conversation/info")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }),
+ });
+ }
+ return Promise.resolve({ ok: false, status: 500 });
+ }),
+ );
+
+ const ctx: PluginContext = {
+ url: "https://www.doubao.com/chat/123",
+ document: {} as Document,
+ };
+
+ await expect(doubaoPlugin.extract(ctx)).rejects.toMatchObject({
+ code: "E-PARSE-005",
+ detail: "Doubao API responded with 500",
+ });
+ });
+
+ it("falls back to raw content when not JSON", async () => {
+ const chainResponse = {
+ cmd: 3100,
+ downlink_body: {
+ pull_singe_chain_downlink_body: {
+ messages: [
+ {
+ conversation_id: "123",
+ message_id: "m1",
+ sender_id: "u1",
+ user_type: 1,
+ status: 0,
+ content_type: 0,
+ content: "纯文本内容",
+ content_status: 0,
+ index_in_conv: "1",
+ create_time: "1700000000",
+ thinking_content: "",
+ },
+ ],
+ has_more: false,
+ },
+ },
+ };
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/im/conversation/info")) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(chainResponse),
+ });
+ }),
+ );
+
+ const ctx: PluginContext = {
+ url: "https://www.doubao.com/chat/123",
+ document: {} as Document,
+ };
+
+ const bundle = await doubaoPlugin.extract(ctx);
+ expect(bundle.nodes[0]!.content).toBe("纯文本内容");
+ });
+ });
+
+ describe("fetchById", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("fetches conversation by ID and builds correct URL", async () => {
+ const chainResponse = {
+ cmd: 3100,
+ downlink_body: {
+ pull_singe_chain_downlink_body: {
+ messages: [
+ {
+ conversation_id: "456",
+ message_id: "m1",
+ sender_id: "u1",
+ user_type: 1,
+ status: 0,
+ content_type: 0,
+ content: '{"text":"fetchById 测试"}',
+ content_status: 0,
+ index_in_conv: "1",
+ create_time: "1700000000",
+ thinking_content: "",
+ },
+ ],
+ has_more: false,
+ },
+ },
+ };
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/im/conversation/info")) {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ cmd: 1110,
+ downlink_body: {
+ get_conv_info_downlink_body: {
+ conversation_info: { name: "远程对话" },
+ },
+ },
+ }),
+ });
+ }
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(chainResponse),
+ });
+ }),
+ );
+
+ const bundle = await doubaoPlugin.fetchById!("456");
+
+ expect(bundle.title).toBe("远程对话");
+ expect(bundle.nodes).toHaveLength(1);
+ expect(bundle.nodes[0]!.content).toBe("fetchById 测试");
+ expect(bundle.source.url).toBe("https://www.doubao.com/chat/456");
+ });
+ });
+});
diff --git a/packages/core-plugins/src/plugins/doubao/plugin.ts b/packages/core-plugins/src/plugins/doubao/plugin.ts
new file mode 100644
index 0000000..5a667ec
--- /dev/null
+++ b/packages/core-plugins/src/plugins/doubao/plugin.ts
@@ -0,0 +1,296 @@
+import type { ContentBundle } from "@ctxport/core-schema";
+import { createAppError } from "@ctxport/core-schema";
+import type { Plugin, PluginContext } from "../../types";
+import { generateId } from "../../utils";
+import { createChatInjector } from "../shared/chat-injector";
+import type {
+ DoubaoChainResponse,
+ DoubaoConversationInfoResponse,
+ DoubaoMessage,
+} from "./types";
+
+const HOST_PATTERN = /^https:\/\/www\.doubao\.com\//i;
+const CONVERSATION_PATTERN =
+ /^https?:\/\/www\.doubao\.com\/chat\/([a-zA-Z0-9_-]+)(?:[/?#]|$)/;
+
+const API_BASE = "https://www.doubao.com";
+const API_PARAMS =
+ "version_code=20800&language=zh&device_platform=web&aid=497858&real_aid=497858&pkg_type=release_version&samantha_web=1&use-olympus-account=1";
+
+const FETCH_LIMIT = 20;
+const MAX_PAGINATION_PAGES = 100;
+
+export const doubaoPlugin: Plugin = {
+ id: "doubao",
+ version: "1.0.0",
+ name: "豆包",
+
+ urls: {
+ hosts: ["https://www.doubao.com/*"],
+ match: (url) => HOST_PATTERN.test(url),
+ },
+
+ async extract(ctx: PluginContext): Promise {
+ const conversationId = extractConversationId(ctx.url);
+ if (!conversationId)
+ throw createAppError("E-PARSE-001", "Not a Doubao conversation page");
+
+ return fetchAndParse(conversationId, ctx.url);
+ },
+
+ async fetchById(conversationId: string): Promise {
+ const url = `https://www.doubao.com/chat/${conversationId}`;
+ return fetchAndParse(conversationId, url);
+ },
+
+ injector: createChatInjector({
+ platform: "doubao",
+ copyButtonSelectors: [
+ // Right-aligned container in the header row (next to share button)
+ 'main div[class*="header-height"] > .justify-end',
+ ],
+ copyButtonPosition: "prepend",
+ listItemLinkSelector: 'nav a[href^="/chat/"]',
+ listItemIdPattern: /\/chat\/([a-zA-Z0-9_-]+)(?:[/?#]|$)/,
+ mainContentSelector: "main",
+ sidebarSelector: "nav",
+ }),
+
+ theme: {
+ light: {
+ primary: "#4e6ef2",
+ secondary: "#eef1ff",
+ fg: "#ffffff",
+ secondaryFg: "#6366f1",
+ },
+ dark: {
+ primary: "#4e6ef2",
+ secondary: "#1a1a2e",
+ fg: "#ffffff",
+ secondaryFg: "#a5b4fc",
+ },
+ },
+};
+
+// --- Internal helpers ---
+
+function extractConversationId(url: string): string | null {
+ const match = CONVERSATION_PATTERN.exec(url);
+ return match?.[1] ?? null;
+}
+
+async function fetchAndParse(
+ conversationId: string,
+ url: string,
+): Promise {
+ const [title, messages] = await Promise.all([
+ fetchConversationTitle(conversationId),
+ fetchAllMessages(conversationId),
+ ]);
+
+ return parseConversation(messages, title, url);
+}
+
+// --- API: Conversation info ---
+
+async function fetchConversationTitle(
+ conversationId: string,
+): Promise {
+ try {
+ const response = await fetch(
+ `${API_BASE}/im/conversation/info?${API_PARAMS}`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json; encoding=utf-8",
+ Accept: "application/json, text/plain, */*",
+ "agw-js-conv": "str",
+ },
+ credentials: "include",
+ body: JSON.stringify({
+ cmd: 1110,
+ uplink_body: {
+ get_conv_info_uplink_body: {
+ conversation_id: conversationId,
+ ext: {},
+ bot_id: "",
+ conversation_type: 3,
+ option: { need_bot_info: false },
+ },
+ },
+ sequence_id: crypto.randomUUID(),
+ channel: 2,
+ version: "1",
+ }),
+ },
+ );
+
+ if (!response.ok) return undefined;
+
+ const data = (await response.json()) as DoubaoConversationInfoResponse;
+ return data.downlink_body?.get_conv_info_downlink_body?.conversation_info
+ ?.name;
+ } catch {
+ return undefined;
+ }
+}
+
+// --- API: Fetch all messages with pagination ---
+
+async function fetchAllMessages(
+ conversationId: string,
+): Promise {
+ const allMessages: DoubaoMessage[] = [];
+ let anchorIndex = Number.MAX_SAFE_INTEGER;
+ let hasMore = true;
+
+ for (let page = 0; page < MAX_PAGINATION_PAGES && hasMore; page++) {
+ const response = await fetch(`${API_BASE}/im/chain/single?${API_PARAMS}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json; encoding=utf-8",
+ Accept: "application/json, text/plain, */*",
+ "agw-js-conv": "str",
+ },
+ credentials: "include",
+ body: JSON.stringify({
+ cmd: 3100,
+ uplink_body: {
+ pull_singe_chain_uplink_body: {
+ conversation_id: conversationId,
+ anchor_index: anchorIndex,
+ conversation_type: 3,
+ direction: 1,
+ limit: FETCH_LIMIT,
+ ext: {},
+ filter: { index_list: [] },
+ },
+ },
+ sequence_id: crypto.randomUUID(),
+ channel: 2,
+ version: "1",
+ }),
+ });
+
+ if (!response.ok) {
+ throw createAppError(
+ "E-PARSE-005",
+ `Doubao API responded with ${response.status}`,
+ );
+ }
+
+ const data = (await response.json()) as DoubaoChainResponse;
+ const messages =
+ data.downlink_body?.pull_singe_chain_downlink_body?.messages ?? [];
+
+ if (messages.length === 0) break;
+
+ allMessages.push(...messages);
+
+ // Find smallest index_in_conv for next pagination anchor
+ const indices = messages
+ .map((m) => Number.parseInt(m.index_in_conv, 10))
+ .filter((n) => !Number.isNaN(n));
+ if (indices.length === 0) break;
+
+ const minIndex = Math.min(...indices);
+ if (minIndex >= anchorIndex) break; // no progress — avoid infinite loop
+ anchorIndex = minIndex;
+
+ hasMore =
+ data.downlink_body?.pull_singe_chain_downlink_body?.has_more !== false &&
+ messages.length >= FETCH_LIMIT;
+ }
+
+ return allMessages;
+}
+
+// --- Parse conversation into ContentBundle ---
+
+function extractMessageText(message: DoubaoMessage): string {
+ // Primary: content_block text (assistant messages)
+ if (message.content_block?.length) {
+ const texts = message.content_block.flatMap((block) => {
+ const text = block.content?.text_block?.text;
+ return text ? [text] : [];
+ });
+ if (texts.length > 0) return texts.join("\n\n");
+ }
+ // Fallback: top-level content field (user messages are JSON-encoded: {"text":"..."})
+ if (message.content?.trim()) {
+ const raw = message.content.trim();
+ try {
+ const parsed = JSON.parse(raw) as { text?: string };
+ if (parsed.text) return parsed.text;
+ } catch {
+ // Not JSON — use raw content
+ }
+ return raw;
+ }
+ return "";
+}
+
+function parseConversation(
+ messages: DoubaoMessage[],
+ title: string | undefined,
+ url: string,
+): ContentBundle {
+ // Sort by index_in_conv ascending (chronological)
+ const sorted = [...messages].sort(
+ (a, b) =>
+ Number.parseInt(a.index_in_conv, 10) -
+ Number.parseInt(b.index_in_conv, 10),
+ );
+
+ // Group consecutive same-role messages
+ interface GroupedMessage {
+ role: "user" | "assistant";
+ text: string;
+ }
+
+ const grouped: GroupedMessage[] = [];
+ for (const message of sorted) {
+ const role = message.user_type === 1 ? "user" : "assistant";
+ const text = extractMessageText(message);
+ if (!text) continue;
+
+ const last = grouped[grouped.length - 1];
+ if (last?.role === role) {
+ last.text = `${last.text}\n${text}`.trim();
+ } else {
+ grouped.push({ role, text });
+ }
+ }
+
+ if (grouped.length === 0) {
+ throw createAppError(
+ "E-PARSE-005",
+ "No messages found in Doubao conversation",
+ );
+ }
+
+ const contentNodes: ContentBundle["nodes"] = grouped.map((msg, index) => ({
+ id: generateId(),
+ participantId: msg.role === "user" ? "user" : "assistant",
+ content: msg.text,
+ order: index,
+ type: "message",
+ }));
+
+ return {
+ id: generateId(),
+ title,
+ participants: [
+ { id: "user", name: "User", role: "user" },
+ { id: "assistant", name: "豆包", role: "assistant" },
+ ],
+ nodes: contentNodes,
+ source: {
+ platform: "doubao",
+ url,
+ extractedAt: new Date().toISOString(),
+ pluginId: "doubao",
+ pluginVersion: "1.0.0",
+ },
+ };
+}
diff --git a/packages/core-plugins/src/plugins/doubao/types.ts b/packages/core-plugins/src/plugins/doubao/types.ts
new file mode 100644
index 0000000..c36ffac
--- /dev/null
+++ b/packages/core-plugins/src/plugins/doubao/types.ts
@@ -0,0 +1,61 @@
+/** Doubao IM chain single response — fetches conversation messages */
+export interface DoubaoChainResponse {
+ cmd: number;
+ sequence_id: string;
+ downlink_body?: {
+ pull_singe_chain_downlink_body?: {
+ messages?: DoubaoMessage[];
+ has_more?: boolean;
+ };
+ };
+}
+
+/** Doubao conversation info response */
+export interface DoubaoConversationInfoResponse {
+ cmd: number;
+ sequence_id: string;
+ downlink_body?: {
+ get_conv_info_downlink_body?: {
+ conversation_info?: {
+ conversation_id?: string;
+ name?: string;
+ create_time?: string;
+ update_time?: string;
+ };
+ };
+ };
+}
+
+/** Single message from Doubao IM chain */
+export interface DoubaoMessage {
+ conversation_id: string;
+ message_id: string;
+ sender_id: string;
+ /** 1 = user, 2 = bot/assistant */
+ user_type: number;
+ status: number;
+ content_type: number;
+ content: string;
+ content_status: number;
+ /** Position in conversation (numeric string) */
+ index_in_conv: string;
+ create_time: string;
+ thinking_content: string;
+ content_block?: DoubaoContentBlock[];
+ section_id?: string;
+}
+
+/** Content block within a Doubao message */
+export interface DoubaoContentBlock {
+ block_type: number;
+ block_id: string;
+ parent_id: string;
+ content?: {
+ text_block?: {
+ text: string;
+ icon_url?: string;
+ icon_url_dark?: string;
+ summary?: string;
+ };
+ };
+}
diff --git a/packages/core-plugins/src/plugins/index.ts b/packages/core-plugins/src/plugins/index.ts
index ce00365..f0f8b7a 100644
--- a/packages/core-plugins/src/plugins/index.ts
+++ b/packages/core-plugins/src/plugins/index.ts
@@ -2,6 +2,7 @@ import { registerPlugin } from "../registry";
import { chatgptPlugin } from "./chatgpt/plugin";
import { claudePlugin } from "./claude/plugin";
import { deepseekPlugin } from "./deepseek/plugin";
+import { doubaoPlugin } from "./doubao/plugin";
import { geminiPlugin } from "./gemini/plugin";
import { githubPlugin } from "./github/plugin";
import { grokPlugin } from "./grok/plugin";
@@ -10,6 +11,7 @@ export function registerBuiltinPlugins(): void {
registerPlugin(chatgptPlugin);
registerPlugin(claudePlugin);
registerPlugin(deepseekPlugin);
+ registerPlugin(doubaoPlugin);
registerPlugin(geminiPlugin);
registerPlugin(githubPlugin);
registerPlugin(grokPlugin);
@@ -18,6 +20,7 @@ export function registerBuiltinPlugins(): void {
export { chatgptPlugin } from "./chatgpt/plugin";
export { claudePlugin } from "./claude/plugin";
export { deepseekPlugin } from "./deepseek/plugin";
+export { doubaoPlugin } from "./doubao/plugin";
export { geminiPlugin } from "./gemini/plugin";
export { githubPlugin } from "./github/plugin";
export { grokPlugin } from "./grok/plugin";
diff --git a/packages/shared-ui/src/i18n/locales/en.ts b/packages/shared-ui/src/i18n/locales/en.ts
index ada5bb9..eec264e 100644
--- a/packages/shared-ui/src/i18n/locales/en.ts
+++ b/packages/shared-ui/src/i18n/locales/en.ts
@@ -185,6 +185,8 @@ const en = {
"Copy conversations from chat.deepseek.com",
"web.home.platforms.grok.name": "Grok",
"web.home.platforms.grok.desc": "Copy conversations from grok.com",
+ "web.home.platforms.doubao.name": "Doubao",
+ "web.home.platforms.doubao.desc": "Copy conversations from www.doubao.com",
"web.home.platforms.github.name": "GitHub Copilot",
"web.home.platforms.github.desc":
"Copy conversations from github.com Copilot chat",
diff --git a/packages/shared-ui/src/i18n/locales/zh.ts b/packages/shared-ui/src/i18n/locales/zh.ts
index 418986d..bc8dd1c 100644
--- a/packages/shared-ui/src/i18n/locales/zh.ts
+++ b/packages/shared-ui/src/i18n/locales/zh.ts
@@ -165,6 +165,8 @@ const zh: LocaleMessages = {
"web.home.platforms.deepseek.desc": "支持 chat.deepseek.com",
"web.home.platforms.grok.name": "Grok",
"web.home.platforms.grok.desc": "支持 grok.com",
+ "web.home.platforms.doubao.name": "豆包",
+ "web.home.platforms.doubao.desc": "支持 www.doubao.com",
"web.home.platforms.github.name": "GitHub Copilot",
"web.home.platforms.github.desc": "支持 github.com Copilot 聊天",