mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 13:59:47 +08:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60363fc2df | |||
| 28ec1053f6 | |||
| 5a6afd576f | |||
| ebc74a10ad | |||
| ea84f21439 | |||
| 0b9e51d6cc | |||
| 7cc9c92722 | |||
| fc2f0d042a | |||
| cf76a0d4d5 | |||
| 4a25bc2651 | |||
| f407c950c3 | |||
| 93e54a7b86 | |||
| c1f8a9ad07 | |||
| 8b8ecf61a6 | |||
| 4be6f3682a | |||
| b89ef02221 | |||
| 55356c8820 | |||
| f23d4eebc3 | |||
| 3b795b6ef3 | |||
| c62d9d5a1b | |||
| 84c56b0da3 | |||
| 462d080a0e | |||
| bd01c370c9 | |||
| b7bcc8c094 | |||
| d9f9da639d | |||
| 126b3040e6 | |||
| 682888cc95 | |||
| e31294415d | |||
| 926f74e9c9 | |||
| 339990e87e | |||
| 3c5c3e006d | |||
| 2aa9790789 | |||
| 38fc733b99 | |||
| 4d2b95d1d1 | |||
| ac2ce0b8b6 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.66.1"
|
||||
"version": "1.73.1"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,64 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.73.1 - 2026-03-18
|
||||
|
||||
### Refactor
|
||||
- `baoyu-danger-x-to-markdown`: migrate tests from bun:test to node:test
|
||||
|
||||
## 1.73.0 - 2026-03-18
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-x-to-markdown`: add video media support for X articles with poster image and video link rendering
|
||||
|
||||
## 1.72.0 - 2026-03-18
|
||||
|
||||
### Features
|
||||
- `baoyu-danger-x-to-markdown`: add MARKDOWN entity support for rendering embedded markdown/code blocks in X articles
|
||||
|
||||
## 1.71.0 - 2026-03-17
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add Seedream reference image support for 5.0/4.5/4.0 models with model-specific size validation
|
||||
|
||||
## 1.70.0 - 2026-03-17
|
||||
|
||||
### Features
|
||||
- `baoyu-format-markdown`: optimize title generation with formula-based recommendations and straightforward alternatives
|
||||
- `baoyu-format-markdown`: auto-generate dual summaries (`summary` + `description`) in frontmatter
|
||||
|
||||
## 1.69.1 - 2026-03-16
|
||||
|
||||
### Fixes
|
||||
- `baoyu-chrome-cdp`: tighten chrome auto-connect logic to reduce false positives
|
||||
|
||||
## 1.69.0 - 2026-03-16
|
||||
|
||||
### Features
|
||||
- `baoyu-chrome-cdp`: support connecting to existing Chrome session (by @bviews)
|
||||
|
||||
### Fixes
|
||||
- `baoyu-chrome-cdp`: support Chrome 146 native remote debugging in approval mode (by @bviews)
|
||||
- `baoyu-chrome-cdp`: keep HTTP validation in findExistingChromeDebugPort (by @bviews)
|
||||
- `baoyu-danger-gemini-web`: reuse openPageSession and fix orphaned tab leak (by @bviews)
|
||||
- `baoyu-danger-gemini-web`: respect explicit profile config over auto-discovery (by @bviews)
|
||||
- `baoyu-danger-gemini-web`: respect BAOYU_CHROME_PROFILE_DIR in auto-discovery skip (by @bviews)
|
||||
- `baoyu-post-to-wechat`: improve browser publishing reliability (by @cfh-7598)
|
||||
|
||||
### Documentation
|
||||
- `baoyu-cover-image`: clarify people reference image workflow and interactive confirmation
|
||||
|
||||
## 1.68.0 - 2026-03-14
|
||||
|
||||
### Features
|
||||
- `baoyu-article-illustrator`: add configurable output directory (`default_output_dir`) with 4 options — `imgs-subdir`, `same-dir`, `illustrations-subdir`, `independent`
|
||||
- `baoyu-cover-image`: add character preservation from reference images — use `usage: direct` to pass people references to model for stylized likeness
|
||||
|
||||
## 1.67.0 - 2026-03-13
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add qwen-image-2.0-pro model support for DashScope provider with free-form sizes and text rendering (by @JianJang2017)
|
||||
|
||||
## 1.66.1 - 2026-03-13
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -2,6 +2,64 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.73.1 - 2026-03-18
|
||||
|
||||
### 重构
|
||||
- `baoyu-danger-x-to-markdown`:测试从 bun:test 迁移至 node:test
|
||||
|
||||
## 1.73.0 - 2026-03-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-x-to-markdown`:支持 X 文章中的视频媒体,渲染封面图和视频链接
|
||||
|
||||
## 1.72.0 - 2026-03-18
|
||||
|
||||
### 新功能
|
||||
- `baoyu-danger-x-to-markdown`:支持渲染 X 文章中嵌入的 MARKDOWN 实体(代码块等)
|
||||
|
||||
## 1.71.0 - 2026-03-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:为 Seedream 5.0/4.5/4.0 模型添加参考图支持,并增加模型特定的尺寸校验
|
||||
|
||||
## 1.70.0 - 2026-03-17
|
||||
|
||||
### 新功能
|
||||
- `baoyu-format-markdown`:优化标题生成,基于公式智能推荐并提供平实风格备选
|
||||
- `baoyu-format-markdown`:自动生成双版本摘要(`summary` + `description`),写入 frontmatter
|
||||
|
||||
## 1.69.1 - 2026-03-16
|
||||
|
||||
### 修复
|
||||
- `baoyu-chrome-cdp`:收紧 Chrome 自动连接逻辑,减少误连接
|
||||
|
||||
## 1.69.0 - 2026-03-16
|
||||
|
||||
### 新功能
|
||||
- `baoyu-chrome-cdp`:支持连接到已有的 Chrome 会话 (by @bviews)
|
||||
|
||||
### 修复
|
||||
- `baoyu-chrome-cdp`:支持 Chrome 146 原生远程调试(审批模式)(by @bviews)
|
||||
- `baoyu-chrome-cdp`:保留 findExistingChromeDebugPort 中的 HTTP 验证 (by @bviews)
|
||||
- `baoyu-danger-gemini-web`:复用 openPageSession 并修复孤立标签页泄漏 (by @bviews)
|
||||
- `baoyu-danger-gemini-web`:显式配置优先于自动发现 (by @bviews)
|
||||
- `baoyu-danger-gemini-web`:自动发现跳过时也遵循 BAOYU_CHROME_PROFILE_DIR (by @bviews)
|
||||
- `baoyu-post-to-wechat`:提升浏览器发布可靠性 (by @cfh-7598)
|
||||
|
||||
### 文档
|
||||
- `baoyu-cover-image`:完善人物参考图片工作流和交互式确认说明
|
||||
|
||||
## 1.68.0 - 2026-03-14
|
||||
|
||||
### 新功能
|
||||
- `baoyu-article-illustrator`:新增可配置输出目录(`default_output_dir`),支持 4 种选项——`imgs-subdir`、`same-dir`、`illustrations-subdir`、`independent`
|
||||
- `baoyu-cover-image`:新增参考图片人物保留功能——当参考图包含人物时使用 `usage: direct` 传递给模型,风格化保留人物特征
|
||||
|
||||
## 1.67.0 - 2026-03-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增 DashScope qwen-image-2.0-pro 模型支持,支持自由尺寸和文字渲染 (by @JianJang2017)
|
||||
|
||||
## 1.66.1 - 2026-03-13
|
||||
|
||||
### 测试
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.66.1**.
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.73.1**.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -695,7 +695,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
# Seedream (豆包)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# With reference images (Google, OpenAI, OpenRouter, or Replicate)
|
||||
# With reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 5.0/4.5/4.0)
|
||||
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
|
||||
```
|
||||
|
||||
@@ -710,7 +710,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size` | Size (e.g., `1024x1024`) |
|
||||
| `--quality` | `normal` or `2k` (default: `2k`) |
|
||||
| `--ref` | Reference images (Google, OpenAI, OpenRouter or Replicate) |
|
||||
| `--ref` | Reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 5.0/4.5/4.0) |
|
||||
|
||||
**Environment Variables** (see [Environment Configuration](#environment-configuration) for setup):
|
||||
| Variable | Description | Default |
|
||||
@@ -726,7 +726,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `qwen-image-2.0-pro` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
||||
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
|
||||
@@ -996,7 +996,7 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||
|
||||
# DashScope (Aliyun Tongyi Wanxiang)
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
|
||||
# Replicate
|
||||
|
||||
+4
-4
@@ -695,7 +695,7 @@ AI 驱动的生成后端。
|
||||
# 豆包(Seedream)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# 带参考图(Google、OpenAI、OpenRouter 或 Replicate)
|
||||
# 带参考图(Google、OpenAI、OpenRouter、Replicate 或 Seedream 5.0/4.5/4.0)
|
||||
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
|
||||
```
|
||||
|
||||
@@ -710,7 +710,7 @@ AI 驱动的生成后端。
|
||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||
| `--size` | 尺寸(如 `1024x1024`) |
|
||||
| `--quality` | `normal` 或 `2k`(默认:`2k`) |
|
||||
| `--ref` | 参考图片(Google、OpenAI、OpenRouter 或 Replicate) |
|
||||
| `--ref` | 参考图片(Google、OpenAI、OpenRouter、Replicate 或 Seedream 5.0/4.5/4.0) |
|
||||
|
||||
**环境变量**(配置方法见[环境配置](#环境配置)):
|
||||
| 变量 | 说明 | 默认值 |
|
||||
@@ -726,7 +726,7 @@ AI 驱动的生成后端。
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `qwen-image-2.0-pro` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
||||
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
||||
@@ -996,7 +996,7 @@ GOOGLE_IMAGE_MODEL=gemini-3-pro-image-preview
|
||||
|
||||
# DashScope(阿里通义万相)
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
DASHSCOPE_IMAGE_MODEL=qwen-image-2.0-pro
|
||||
# DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
|
||||
|
||||
# Replicate
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
@@ -153,6 +189,106 @@ test("findExistingChromeDebugPort reads DevToolsActivePort and validates it agai
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
|
||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||
|
||||
const render = (md: string) => {
|
||||
const r = initRenderer();
|
||||
return renderMarkdown(md, r).html;
|
||||
};
|
||||
|
||||
test("bold with inline code (no underscore)", () => {
|
||||
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code (contains underscore)", () => {
|
||||
const html = render("**变成 `input_ids`。**");
|
||||
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code", () => {
|
||||
const html = render("*查看 `hidden_states`*");
|
||||
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||
});
|
||||
|
||||
test("plain inline code (regression)", () => {
|
||||
const html = render("`lm_head`");
|
||||
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||
});
|
||||
|
||||
test("bold without code (regression)", () => {
|
||||
const html = render("**纯粗体文本**");
|
||||
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||
assert.doesNotMatch(html, /<code/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing backticks", () => {
|
||||
const html = render("**``a`b``**");
|
||||
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only spaces", () => {
|
||||
const oneSpace = render("**`` ``**");
|
||||
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||
|
||||
const twoSpaces = render("**`` ``**");
|
||||
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||
});
|
||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
function wrapInlineCode(value: string): string {
|
||||
const runs = value.match(/`+/g);
|
||||
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||
const padding = /^ *$/.test(value) ? "" : " ";
|
||||
return `${fence}${padding}${value}${padding}${fence}`;
|
||||
}
|
||||
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
||||
const tree = processor.parse(markdown);
|
||||
const extractText = (node: any): string => {
|
||||
if (node.type === "text") return node.value;
|
||||
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||
if (node.children) return node.children.map(extractText).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -133,7 +133,7 @@ Full procedures: [references/workflow.md](references/workflow.md#step-5-generate
|
||||
|
||||
### Step 6: Finalize
|
||||
|
||||
Insert `` after paragraphs.
|
||||
Insert `` after paragraphs. Path computed relative to article file based on output directory setting.
|
||||
|
||||
```
|
||||
Article Illustration Complete!
|
||||
@@ -143,15 +143,27 @@ Images: X/N generated
|
||||
|
||||
## Output Directory
|
||||
|
||||
Output directory is determined by `default_output_dir` in EXTEND.md (set during first-time setup):
|
||||
|
||||
| `default_output_dir` | Output Path | Markdown Insert Path |
|
||||
|----------------------|-------------|----------------------|
|
||||
| `imgs-subdir` (default) | `{article-dir}/imgs/` | `imgs/NN-{type}-{slug}.png` |
|
||||
| `same-dir` | `{article-dir}/` | `NN-{type}-{slug}.png` |
|
||||
| `illustrations-subdir` | `{article-dir}/illustrations/` | `illustrations/NN-{type}-{slug}.png` |
|
||||
| `independent` | `illustrations/{topic-slug}/` | `illustrations/{topic-slug}/NN-{type}-{slug}.png` (relative to cwd) |
|
||||
|
||||
All auxiliary files (outline, prompts) are saved inside the output directory:
|
||||
|
||||
```
|
||||
illustrations/{topic-slug}/
|
||||
├── source-{slug}.{ext}
|
||||
├── references/ # if provided
|
||||
{output-dir}/
|
||||
├── outline.md
|
||||
├── prompts/
|
||||
│ └── NN-{type}-{slug}.md
|
||||
└── NN-{type}-{slug}.png
|
||||
```
|
||||
|
||||
When input is **pasted content** (no file path), always uses `illustrations/{topic-slug}/` with `source-{slug}.{ext}` saved alongside.
|
||||
|
||||
**Slug**: 2-4 words, kebab-case. **Conflict**: append `-YYYYMMDD-HHMMSS`.
|
||||
|
||||
## Modification
|
||||
|
||||
@@ -69,7 +69,23 @@ options:
|
||||
description: "Friendly, approachable, personal"
|
||||
```
|
||||
|
||||
### Question 3: Save Location
|
||||
### Question 3: Output Directory
|
||||
|
||||
```
|
||||
header: "Output Directory"
|
||||
question: "Where to save generated illustrations when illustrating a file?"
|
||||
options:
|
||||
- label: "imgs-subdir (Recommended)"
|
||||
description: "{article-dir}/imgs/ — images in a subdirectory next to the article"
|
||||
- label: "same-dir"
|
||||
description: "{article-dir}/ — images alongside the article file"
|
||||
- label: "illustrations-subdir"
|
||||
description: "{article-dir}/illustrations/ — separate illustrations subdirectory"
|
||||
- label: "independent"
|
||||
description: "illustrations/{topic-slug}/ — standalone directory in cwd"
|
||||
```
|
||||
|
||||
### Question 4: Save Location
|
||||
|
||||
```
|
||||
header: "Save"
|
||||
@@ -108,6 +124,7 @@ watermark:
|
||||
preferred_style:
|
||||
name: [selected style or null]
|
||||
description: ""
|
||||
default_output_dir: imgs-subdir # same-dir | imgs-subdir | illustrations-subdir | independent
|
||||
language: null
|
||||
custom_styles: []
|
||||
---
|
||||
|
||||
@@ -55,7 +55,7 @@ Reference Style Extracted (no file):
|
||||
|
||||
| Input | Output Directory | Next |
|
||||
|-------|------------------|------|
|
||||
| File path | Ask user (1.2) | → 1.2 |
|
||||
| File path | EXTEND.md `default_output_dir` (default: `imgs-subdir`). If not configured, confirm in 1.2. | → 1.2 |
|
||||
| Pasted content | `illustrations/{topic-slug}/` | → 1.4 |
|
||||
|
||||
**Backup rule for pasted content**: If `source.md` exists in target directory, rename to `source-backup-YYYYMMDD-HHMMSS.md` before saving.
|
||||
@@ -68,7 +68,7 @@ Check preferences and existing state, then ask ALL needed questions in ONE AskUs
|
||||
|
||||
| Question | When to Ask | Options |
|
||||
|----------|-------------|---------|
|
||||
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/`, `{article-dir}/imgs/` (Recommended), `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
|
||||
| Output directory | No `default_output_dir` in EXTEND.md | `{article-dir}/imgs/` (Recommended), `{article-dir}/`, `{article-dir}/illustrations/`, `illustrations/{topic-slug}/` |
|
||||
| Existing images | Target dir has `.png/.jpg/.webp` files | `supplement`, `overwrite`, `regenerate` |
|
||||
| Article update | Always (file path input) | `update`, `copy` |
|
||||
|
||||
@@ -237,7 +237,7 @@ Reference Images:
|
||||
|
||||
## Step 4: Generate Outline
|
||||
|
||||
Save as `outline.md`:
|
||||
Save as `{output-dir}/outline.md` (all paths below are relative to the output directory determined in Step 1.1/1.2):
|
||||
|
||||
```yaml
|
||||
---
|
||||
@@ -285,7 +285,7 @@ references: # Only if references provided
|
||||
|
||||
For each illustration in the outline:
|
||||
|
||||
1. **Create prompt file**: `prompts/NN-{type}-{slug}.md`
|
||||
1. **Create prompt file**: `{output-dir}/prompts/NN-{type}-{slug}.md`
|
||||
2. **Include YAML frontmatter**:
|
||||
```yaml
|
||||
---
|
||||
@@ -381,10 +381,14 @@ Add: `Include a subtle watermark "[content]" at [position].`
|
||||
|
||||
### 6.1 Update Article
|
||||
|
||||
Insert after corresponding paragraph:
|
||||
```markdown
|
||||

|
||||
```
|
||||
Insert after corresponding paragraph, using path relative to article file:
|
||||
|
||||
| `default_output_dir` | Insert Path |
|
||||
|----------------------|-------------|
|
||||
| `imgs-subdir` | `` |
|
||||
| `same-dir` | `` |
|
||||
| `illustrations-subdir` | `` |
|
||||
| `independent` | `` (relative to cwd) |
|
||||
|
||||
Alt text: concise description in article's language.
|
||||
|
||||
|
||||
@@ -162,9 +162,21 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
|
||||
5. **Detect language**: Compare source, user input, EXTEND.md preference
|
||||
6. **Determine output directory**: Per File Structure rules
|
||||
|
||||
**⚠️ People in Reference Images — MUST follow all 3 rules:**
|
||||
|
||||
If reference images contain **people** who should appear in the cover:
|
||||
|
||||
1. **`usage: direct`** — MUST set in refs description file. NEVER use `style` or `palette` when people need to appear
|
||||
2. **Per-character description** — MUST describe each person's distinctive features (hair, glasses, skin tone, clothing) in `refs/ref-NN-{slug}.md`. Vague descriptions like "a man" will fail
|
||||
3. **`--ref` flag** — MUST pass reference image via `--ref` in Step 4 so the model sees actual faces
|
||||
|
||||
See [reference-images.md § Character Analysis](references/workflow/reference-images.md) for description format.
|
||||
|
||||
### Step 2: Confirm Options ⚠️
|
||||
|
||||
Full confirmation flow: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
**MUST use `AskUserQuestion` tool** to present options as interactive selection — NOT plain text tables. Present up to 4 questions in a single `AskUserQuestion` call (Type, Palette, Rendering, Font + Settings). Each question shows the recommended option first with reason, followed by alternatives.
|
||||
|
||||
Full confirmation flow and question format: [references/workflow/confirm-options.md](references/workflow/confirm-options.md)
|
||||
|
||||
| Condition | Skipped | Still Asked |
|
||||
|-----------|---------|-------------|
|
||||
|
||||
@@ -83,12 +83,17 @@ Full library: [references/visual-elements.md](visual-elements.md)
|
||||
|
||||
### Character Handling
|
||||
|
||||
When people are needed:
|
||||
**Default (no reference with people)**:
|
||||
- Use simplified silhouettes or abstract stick figures
|
||||
- Symbolic representations (head + shoulders outline)
|
||||
- NO realistic faces, detailed anatomy, or photographic representations
|
||||
- Cartoon/icon style consistent with rendering choice
|
||||
|
||||
**When reference images contain people**:
|
||||
- Reference image is passed to model (`usage: direct`) — model must visually reference it to preserve character likeness
|
||||
- Stylize to match chosen rendering (cartoon/vector), preserving distinctive features (hair, clothing, pose)
|
||||
- NEVER photorealistic
|
||||
|
||||
## Mood Application
|
||||
|
||||
Apply mood adjustments to the base palette:
|
||||
|
||||
@@ -201,6 +201,12 @@ CRITICAL: The generated cover MUST visually reference the provided images. The c
|
||||
- [Typography]: [Specific treatment, e.g., "Uppercase text with wide letter-spacing"]
|
||||
- [Layout element]: [Specific spatial element, e.g., "Bottom banner strip in dark color"]
|
||||
|
||||
## From Ref 1 ([filename]) — Characters (if people present):
|
||||
- **Character 1**: [Appearance, e.g., "Woman, long wavy blonde hair"] → MUST stylize: [e.g., "flat-vector, simplified face, keep blonde hair, label: 'Nicole Forsgren'"]
|
||||
- **Character 2**: [Appearance, e.g., "Man, short dark hair, stubble"] → MUST stylize: [e.g., "flat-vector, simplified face, keep dark hair, label: 'Gergely Orosz'"]
|
||||
- **Placement**: [e.g., "Right third, side by side, facing left toward main visual"]
|
||||
- **Style**: Match rendering style, NOT photorealistic
|
||||
|
||||
## From Ref 2 ([filename]) — REQUIRED elements:
|
||||
[Same detailed breakdown]
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ usage: direct | style | palette
|
||||
|
||||
| Usage | When to Use |
|
||||
|-------|-------------|
|
||||
| `direct` | Reference matches desired output closely |
|
||||
| `style` | Extract visual style characteristics only |
|
||||
| `direct` | Model sees reference image directly; required if people must appear in output |
|
||||
| `style` | Extract visual style only (not for people who must appear) |
|
||||
| `palette` | Extract color scheme only |
|
||||
|
||||
## Verbal Extraction (No File)
|
||||
@@ -59,6 +59,19 @@ References are high-priority inputs. Extract **specific, concrete, reproducible*
|
||||
|
||||
**Output format**: List each element as bullet that can be copy-pasted into prompt as mandatory instruction.
|
||||
|
||||
### Character Analysis ⚠️ If Reference Contains People
|
||||
|
||||
Use `usage: direct` so model sees the reference image. Additionally describe per character: **appearance**, **pose**, **clothing** → with **transformation rules** (stylize to match rendering).
|
||||
|
||||
| Extract | Good | Bad |
|
||||
|---------|------|-----|
|
||||
| Appearance | "Woman: long wavy blonde hair, friendly smile" | "A woman" |
|
||||
| Pose | "Standing, facing camera, confident posture" | "Standing" |
|
||||
| Clothing | "Dark T-shirt, business casual" | "Formal" |
|
||||
| Transform | "Flat-vector cartoon, keep hair color & clothing" | "Make cartoon" |
|
||||
|
||||
Use `usage: direct`. Output each character as MUST/REQUIRED prompt instruction.
|
||||
|
||||
## Verification Output
|
||||
|
||||
**For saved files**:
|
||||
|
||||
@@ -139,6 +139,10 @@ ${BUN_X} {baseDir}/scripts/main.ts "Hello" --json
|
||||
|
||||
First run opens browser for Google auth. Cookies cached automatically.
|
||||
|
||||
When no explicit profile dir is set, cookie refresh may reuse an already-running local Chrome/Chromium debugging session tied to a standard user-data dir.
|
||||
Set `--profile-dir` or `GEMINI_WEB_CHROME_PROFILE_DIR` to force a dedicated profile and skip existing-session reuse.
|
||||
This is a best-effort CDP session reuse path, not the Chrome DevTools MCP prompt-based `--autoConnect` flow described in Chrome's official docs.
|
||||
|
||||
Supported browsers (auto-detected): Chrome, Chrome Canary/Beta, Chromium, Edge.
|
||||
|
||||
Force refresh: `--login` flag. Override browser: `GEMINI_WEB_CHROME_PATH` env var.
|
||||
|
||||
@@ -2,6 +2,7 @@ import process from 'node:process';
|
||||
|
||||
import {
|
||||
CdpConnection,
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
@@ -97,6 +98,87 @@ async function is_gemini_session_ready(cookies: CookieMap, verbose: boolean): Pr
|
||||
}
|
||||
}
|
||||
|
||||
async function fetch_cookies_from_existing_chrome(
|
||||
timeoutMs: number,
|
||||
verbose: boolean,
|
||||
): Promise<CookieMap | null> {
|
||||
const discovered = await discoverRunningChromeDebugPort();
|
||||
if (discovered === null) return null;
|
||||
|
||||
if (verbose) logger.info(`Found reusable Chrome debugging session on port ${discovered.port}. Connecting via WebSocket...`);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
let targetId: string | null = null;
|
||||
let createdTarget = false;
|
||||
try {
|
||||
const connectStart = Date.now();
|
||||
const connectTimeout = 30_000;
|
||||
let lastConnErr: unknown = null;
|
||||
while (Date.now() - connectStart < connectTimeout) {
|
||||
try {
|
||||
cdp = await CdpConnection.connect(discovered.wsUrl, 5_000);
|
||||
break;
|
||||
} catch (e) {
|
||||
lastConnErr = e;
|
||||
if (verbose) logger.debug(`WebSocket connect attempt failed: ${e instanceof Error ? e.message : String(e)}, retrying...`);
|
||||
await sleep(1000);
|
||||
}
|
||||
}
|
||||
if (!cdp) {
|
||||
if (verbose) logger.debug(`Could not connect to Chrome after ${connectTimeout / 1000}s: ${lastConnErr instanceof Error ? lastConnErr.message : String(lastConnErr)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing: false,
|
||||
url: GEMINI_APP_URL,
|
||||
matchTarget: (target) => target.type === 'page' && target.url.includes('gemini.google.com'),
|
||||
enableNetwork: true,
|
||||
activateTarget: false,
|
||||
});
|
||||
const { sessionId } = page;
|
||||
targetId = page.targetId;
|
||||
createdTarget = page.createdTarget;
|
||||
|
||||
if (verbose) logger.debug(createdTarget ? 'No Gemini tab found, creating new tab...' : 'Found existing Gemini tab, attaching...');
|
||||
|
||||
const start = Date.now();
|
||||
let last: CookieMap = {};
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const { cookies } = await cdp.send<{ cookies: Array<{ name: string; value: string }> }>(
|
||||
'Network.getCookies',
|
||||
{ urls: ['https://gemini.google.com/', 'https://accounts.google.com/', 'https://www.google.com/'] },
|
||||
{ sessionId, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const cookieMap: CookieMap = {};
|
||||
for (const cookie of cookies) {
|
||||
if (cookie?.name && typeof cookie.value === 'string') cookieMap[cookie.name] = cookie.value;
|
||||
}
|
||||
|
||||
last = cookieMap;
|
||||
if (await is_gemini_session_ready(cookieMap, verbose)) return cookieMap;
|
||||
|
||||
await sleep(1000);
|
||||
}
|
||||
|
||||
if (verbose) logger.debug(`Existing Chrome did not yield valid cookies. Last keys: ${Object.keys(last).join(', ')}`);
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (verbose) logger.debug(`Failed to connect to existing Chrome debugging session: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return null;
|
||||
} finally {
|
||||
if (cdp) {
|
||||
if (createdTarget && targetId) {
|
||||
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetch_google_cookies_via_cdp(
|
||||
profileDir: string,
|
||||
timeoutMs: number,
|
||||
@@ -178,6 +260,19 @@ export async function load_browser_cookies(domain_name: string = '', verbose: bo
|
||||
if (cached) return { chrome: cached };
|
||||
}
|
||||
|
||||
const hasExplicitProfile = !!(process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || process.env.BAOYU_CHROME_PROFILE_DIR?.trim());
|
||||
const existingCookies = hasExplicitProfile ? null : await fetch_cookies_from_existing_chrome(30_000, verbose);
|
||||
if (existingCookies) {
|
||||
const filtered: CookieMap = {};
|
||||
for (const [key, value] of Object.entries(existingCookies)) {
|
||||
if (typeof value === 'string' && value.length > 0) filtered[key] = value;
|
||||
}
|
||||
|
||||
await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp-existing');
|
||||
void domain_name;
|
||||
return { chrome: filtered };
|
||||
}
|
||||
|
||||
const profileDir = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim() || resolveGeminiWebChromeProfileDir();
|
||||
const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
|
||||
|
||||
|
||||
@@ -101,7 +101,12 @@ Options:
|
||||
-h, --help Show help
|
||||
|
||||
Env overrides:
|
||||
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH`);
|
||||
GEMINI_WEB_DATA_DIR, GEMINI_WEB_COOKIE_PATH, GEMINI_WEB_CHROME_PROFILE_DIR, GEMINI_WEB_CHROME_PATH
|
||||
|
||||
Notes:
|
||||
By default cookie refresh may reuse an already-running local Chrome/Chromium debugging session.
|
||||
Set --profile-dir or GEMINI_WEB_CHROME_PROFILE_DIR to force a dedicated profile and skip existing-session reuse.
|
||||
This reuse path is separate from Chrome DevTools MCP's prompt-based --autoConnect flow.`);
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
|
||||
+136
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
@@ -153,6 +189,106 @@ test("findExistingChromeDebugPort reads DevToolsActivePort and validates it agai
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
|
||||
+123
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { formatArticleMarkdown } from "./markdown.js";
|
||||
|
||||
test("formatArticleMarkdown renders MARKDOWN entities from atomic blocks", () => {
|
||||
const article = {
|
||||
title: "Atomic Markdown Example",
|
||||
content_state: {
|
||||
blocks: [
|
||||
{
|
||||
type: "unstyled",
|
||||
text: "Before the snippet.",
|
||||
entityRanges: [],
|
||||
},
|
||||
{
|
||||
type: "atomic",
|
||||
text: " ",
|
||||
entityRanges: [{ key: 0, offset: 0, length: 1 }],
|
||||
},
|
||||
{
|
||||
type: "unstyled",
|
||||
text: "After the snippet.",
|
||||
entityRanges: [],
|
||||
},
|
||||
],
|
||||
entityMap: {
|
||||
"0": {
|
||||
key: "5",
|
||||
value: {
|
||||
type: "MARKDOWN",
|
||||
mutability: "Mutable",
|
||||
data: {
|
||||
markdown: "```python\nprint('hello from x article')\n```\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { markdown } = formatArticleMarkdown(article);
|
||||
|
||||
assert.ok(markdown.includes("Before the snippet."));
|
||||
assert.ok(markdown.includes("```python\nprint('hello from x article')\n```"));
|
||||
assert.ok(markdown.includes("After the snippet."));
|
||||
assert.strictEqual(markdown, `# Atomic Markdown Example
|
||||
|
||||
Before the snippet.
|
||||
|
||||
\`\`\`python
|
||||
print('hello from x article')
|
||||
\`\`\`
|
||||
|
||||
After the snippet.`);
|
||||
});
|
||||
|
||||
test("formatArticleMarkdown renders article video media as poster plus video link", () => {
|
||||
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/123/img/poster.jpg";
|
||||
const videoUrl = "https://video.twimg.com/amplify_video/123/vid/avc1/720x720/demo.mp4?tag=21";
|
||||
const article = {
|
||||
title: "Video Example",
|
||||
content_state: {
|
||||
blocks: [
|
||||
{
|
||||
type: "unstyled",
|
||||
text: "Intro text.",
|
||||
entityRanges: [],
|
||||
},
|
||||
{
|
||||
type: "atomic",
|
||||
text: " ",
|
||||
entityRanges: [{ key: 0, offset: 0, length: 1 }],
|
||||
},
|
||||
],
|
||||
entityMap: {
|
||||
"0": {
|
||||
key: "0",
|
||||
value: {
|
||||
type: "MEDIA",
|
||||
mutability: "Immutable",
|
||||
data: {
|
||||
caption: "Demo reel",
|
||||
mediaItems: [{ mediaId: "vid-1" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
media_entities: [
|
||||
{
|
||||
media_id: "vid-1",
|
||||
media_info: {
|
||||
__typename: "ApiVideo",
|
||||
preview_image: {
|
||||
original_img_url: posterUrl,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
content_type: "video/mp4",
|
||||
bit_rate: 256000,
|
||||
url: videoUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { markdown } = formatArticleMarkdown(article);
|
||||
|
||||
assert.ok(markdown.includes("Intro text."));
|
||||
assert.ok(markdown.includes(``));
|
||||
assert.ok(markdown.includes(`[video](${videoUrl})`));
|
||||
assert.ok(!markdown.includes(``));
|
||||
assert.ok(!markdown.includes("## Media"));
|
||||
});
|
||||
|
||||
test("formatArticleMarkdown renders unused article videos in trailing media section", () => {
|
||||
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/456/img/poster.jpg";
|
||||
const videoUrl = "https://video.twimg.com/amplify_video/456/vid/avc1/1080x1080/demo.mp4?tag=21";
|
||||
const article = {
|
||||
title: "Trailing Media Example",
|
||||
plain_text: "Body text.",
|
||||
media_entities: [
|
||||
{
|
||||
media_id: "vid-2",
|
||||
media_info: {
|
||||
__typename: "ApiVideo",
|
||||
preview_image: {
|
||||
original_img_url: posterUrl,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
content_type: "video/mp4",
|
||||
bit_rate: 832000,
|
||||
url: videoUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { markdown, coverUrl } = formatArticleMarkdown(article);
|
||||
|
||||
assert.strictEqual(coverUrl, null);
|
||||
assert.ok(markdown.includes("## Media"));
|
||||
assert.ok(markdown.includes(``));
|
||||
assert.ok(markdown.includes(`[video](${videoUrl})`));
|
||||
});
|
||||
|
||||
test("formatArticleMarkdown keeps coverUrl as preview image for video cover media", () => {
|
||||
const posterUrl = "https://pbs.twimg.com/amplify_video_thumb/789/img/poster.jpg";
|
||||
const videoUrl = "https://video.twimg.com/amplify_video/789/vid/avc1/720x720/demo.mp4?tag=21";
|
||||
const article = {
|
||||
title: "Video Cover Example",
|
||||
plain_text: "Body text.",
|
||||
cover_media: {
|
||||
media_info: {
|
||||
__typename: "ApiVideo",
|
||||
preview_image: {
|
||||
original_img_url: posterUrl,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
content_type: "video/mp4",
|
||||
bit_rate: 1280000,
|
||||
url: videoUrl,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { coverUrl } = formatArticleMarkdown(article);
|
||||
|
||||
assert.strictEqual(coverUrl, posterUrl);
|
||||
});
|
||||
@@ -18,6 +18,17 @@ export type FormatArticleOptions = {
|
||||
referencedTweets?: Map<string, ReferencedTweetInfo>;
|
||||
};
|
||||
|
||||
type ResolvedMediaAsset =
|
||||
| {
|
||||
kind: "image";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
kind: "video";
|
||||
url: string;
|
||||
posterUrl?: string;
|
||||
};
|
||||
|
||||
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as ArticleEntity;
|
||||
@@ -109,58 +120,127 @@ function resolveEntityEntry(
|
||||
return entityMap[String(entityKey)];
|
||||
}
|
||||
|
||||
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
|
||||
function resolveVideoUrl(info?: ArticleMediaInfo): string | undefined {
|
||||
if (!info) return undefined;
|
||||
if (info.original_img_url) return info.original_img_url;
|
||||
if (info.preview_image?.original_img_url) return info.preview_image.original_img_url;
|
||||
const variants = info.variants ?? [];
|
||||
const mp4 = variants
|
||||
.filter((variant) => variant?.content_type?.includes("video"))
|
||||
.sort((a, b) => (b.bit_rate ?? 0) - (a.bit_rate ?? 0))[0];
|
||||
return mp4?.url ?? variants[0]?.url;
|
||||
return mp4?.url ?? variants.find((variant) => typeof variant?.url === "string")?.url;
|
||||
}
|
||||
|
||||
function buildMediaById(article: ArticleEntity): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
function resolveMediaAsset(info?: ArticleMediaInfo): ResolvedMediaAsset | undefined {
|
||||
if (!info) return undefined;
|
||||
|
||||
const posterUrl = info.preview_image?.original_img_url ?? info.original_img_url;
|
||||
const videoUrl = resolveVideoUrl(info);
|
||||
if (videoUrl) {
|
||||
return {
|
||||
kind: "video",
|
||||
url: videoUrl,
|
||||
posterUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const imageUrl = info.original_img_url ?? info.preview_image?.original_img_url;
|
||||
if (imageUrl) {
|
||||
return {
|
||||
kind: "image",
|
||||
url: imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveFallbackMediaAsset(rawUrl?: string): ResolvedMediaAsset | undefined {
|
||||
if (!rawUrl) return undefined;
|
||||
|
||||
if (/^https:\/\/video\.twimg\.com\//i.test(rawUrl) || /\.(mp4|m4v|mov|webm)(?:$|[?#])/i.test(rawUrl)) {
|
||||
return {
|
||||
kind: "video",
|
||||
url: rawUrl,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "image",
|
||||
url: rawUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCoverUrl(info?: ArticleMediaInfo): string | undefined {
|
||||
if (!info) return undefined;
|
||||
return info.original_img_url ?? info.preview_image?.original_img_url;
|
||||
}
|
||||
|
||||
function buildMediaIdentity(asset: ResolvedMediaAsset): string {
|
||||
return asset.kind === "video"
|
||||
? `video:${asset.url}:${asset.posterUrl ?? ""}`
|
||||
: `image:${asset.url}`;
|
||||
}
|
||||
|
||||
function renderMediaLines(
|
||||
asset: ResolvedMediaAsset,
|
||||
altText: string,
|
||||
usedUrls: Set<string>
|
||||
): string[] {
|
||||
if (asset.kind === "video") {
|
||||
const lines: string[] = [];
|
||||
if (asset.posterUrl && !usedUrls.has(asset.posterUrl)) {
|
||||
usedUrls.add(asset.posterUrl);
|
||||
lines.push(``);
|
||||
}
|
||||
if (!usedUrls.has(asset.url)) {
|
||||
usedUrls.add(asset.url);
|
||||
lines.push(`[video](${asset.url})`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (usedUrls.has(asset.url)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
usedUrls.add(asset.url);
|
||||
return [``];
|
||||
}
|
||||
|
||||
function buildMediaById(article: ArticleEntity): Map<string, ResolvedMediaAsset> {
|
||||
const map = new Map<string, ResolvedMediaAsset>();
|
||||
for (const entity of article.media_entities ?? []) {
|
||||
if (!entity?.media_id) continue;
|
||||
const url = resolveMediaUrl(entity.media_info);
|
||||
if (url) {
|
||||
map.set(entity.media_id, url);
|
||||
const asset = resolveMediaAsset(entity.media_info);
|
||||
if (asset) {
|
||||
map.set(entity.media_id, asset);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function collectMediaUrls(
|
||||
article: ArticleEntity,
|
||||
usedUrls: Set<string>,
|
||||
excludeUrl?: string
|
||||
): string[] {
|
||||
const urls: string[] = [];
|
||||
const addUrl = (url?: string) => {
|
||||
if (!url) return;
|
||||
if (excludeUrl && url === excludeUrl) {
|
||||
usedUrls.add(url);
|
||||
return;
|
||||
}
|
||||
if (usedUrls.has(url)) return;
|
||||
usedUrls.add(url);
|
||||
urls.push(url);
|
||||
function collectMediaAssets(article: ArticleEntity): ResolvedMediaAsset[] {
|
||||
const assets: ResolvedMediaAsset[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addAsset = (asset?: ResolvedMediaAsset) => {
|
||||
if (!asset) return;
|
||||
const identity = buildMediaIdentity(asset);
|
||||
if (seen.has(identity)) return;
|
||||
seen.add(identity);
|
||||
assets.push(asset);
|
||||
};
|
||||
|
||||
for (const entity of article.media_entities ?? []) {
|
||||
addUrl(resolveMediaUrl(entity?.media_info));
|
||||
addAsset(resolveMediaAsset(entity?.media_info));
|
||||
}
|
||||
|
||||
return urls;
|
||||
return assets;
|
||||
}
|
||||
|
||||
function resolveEntityMediaLines(
|
||||
entityKey: number | undefined,
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup,
|
||||
mediaById: Map<string, string>,
|
||||
mediaById: Map<string, ResolvedMediaAsset>,
|
||||
usedUrls: Set<string>
|
||||
): string[] {
|
||||
if (entityKey === undefined) return [];
|
||||
@@ -182,17 +262,16 @@ function resolveEntityMediaLines(
|
||||
: typeof item?.media_id === "string"
|
||||
? item.media_id
|
||||
: undefined;
|
||||
const url = mediaId ? mediaById.get(mediaId) : undefined;
|
||||
if (url && !usedUrls.has(url)) {
|
||||
usedUrls.add(url);
|
||||
lines.push(``);
|
||||
const asset = mediaId ? mediaById.get(mediaId) : undefined;
|
||||
if (asset) {
|
||||
lines.push(...renderMediaLines(asset, altText, usedUrls));
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackUrl = typeof value.data?.url === "string" ? value.data.url : undefined;
|
||||
if (fallbackUrl && !usedUrls.has(fallbackUrl)) {
|
||||
usedUrls.add(fallbackUrl);
|
||||
lines.push(``);
|
||||
const fallbackAsset = resolveFallbackMediaAsset(fallbackUrl);
|
||||
if (fallbackAsset) {
|
||||
lines.push(...renderMediaLines(fallbackAsset, altText, usedUrls));
|
||||
}
|
||||
|
||||
return lines;
|
||||
@@ -237,6 +316,22 @@ function resolveEntityTweetLines(
|
||||
return lines;
|
||||
}
|
||||
|
||||
function resolveEntityMarkdownLines(
|
||||
entityKey: number | undefined,
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup
|
||||
): string[] {
|
||||
if (entityKey === undefined) return [];
|
||||
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||
const value = entry?.value;
|
||||
if (!value || value.type !== "MARKDOWN") return [];
|
||||
|
||||
const markdown = typeof value.data?.markdown === "string" ? value.data.markdown : "";
|
||||
const normalized = markdown.replace(/\r\n/g, "\n").trimEnd();
|
||||
if (!normalized) return [];
|
||||
return normalized.split("\n");
|
||||
}
|
||||
|
||||
function buildMediaLinkMap(
|
||||
entityMap: ArticleContentState["entityMap"] | undefined
|
||||
): Map<number, string> {
|
||||
@@ -330,7 +425,7 @@ function renderContentBlocks(
|
||||
blocks: ArticleBlock[],
|
||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||
entityLookup: EntityLookup,
|
||||
mediaById: Map<string, string>,
|
||||
mediaById: Map<string, ResolvedMediaAsset>,
|
||||
usedUrls: Set<string>,
|
||||
mediaLinkMap: Map<number, string>,
|
||||
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||
@@ -397,6 +492,16 @@ function renderContentBlocks(
|
||||
return [...new Set(linkLines)];
|
||||
};
|
||||
|
||||
const collectMarkdownLines = (block: ArticleBlock): string[] => {
|
||||
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||
const markdownLines: string[] = [];
|
||||
for (const range of ranges) {
|
||||
if (typeof range?.key !== "number") continue;
|
||||
markdownLines.push(...resolveEntityMarkdownLines(range.key, entityMap, entityLookup));
|
||||
}
|
||||
return markdownLines;
|
||||
};
|
||||
|
||||
const pushTrailingMedia = (mediaLines: string[]) => {
|
||||
if (mediaLines.length > 0) {
|
||||
pushBlock(mediaLines, "media");
|
||||
@@ -441,6 +546,11 @@ function renderContentBlocks(
|
||||
pushBlock(tweetLines, "quote");
|
||||
}
|
||||
|
||||
const markdownLines = collectMarkdownLines(block);
|
||||
if (markdownLines.length > 0) {
|
||||
pushBlock(markdownLines, "text");
|
||||
}
|
||||
|
||||
const mediaLines = collectMediaLines(block);
|
||||
if (mediaLines.length > 0) {
|
||||
pushBlock(mediaLines, "media");
|
||||
@@ -571,7 +681,7 @@ export function formatArticleMarkdown(
|
||||
lines.push(`# ${title}`);
|
||||
}
|
||||
|
||||
const coverUrl = resolveMediaUrl(candidate.cover_media?.media_info) ?? null;
|
||||
const coverUrl = resolveCoverUrl(candidate.cover_media?.media_info) ?? null;
|
||||
if (coverUrl) {
|
||||
usedUrls.add(coverUrl);
|
||||
}
|
||||
@@ -602,12 +712,13 @@ export function formatArticleMarkdown(
|
||||
lines.push(candidate.preview_text.trim());
|
||||
}
|
||||
|
||||
const mediaUrls = collectMediaUrls(candidate, usedUrls, coverUrl ?? undefined);
|
||||
if (mediaUrls.length > 0) {
|
||||
const trailingMediaLines: string[] = [];
|
||||
for (const asset of collectMediaAssets(candidate)) {
|
||||
trailingMediaLines.push(...renderMediaLines(asset, "", usedUrls));
|
||||
}
|
||||
if (trailingMediaLines.length > 0) {
|
||||
lines.push("", "## Media", "");
|
||||
for (const url of mediaUrls) {
|
||||
lines.push(``);
|
||||
}
|
||||
lines.push(...trailingMediaLines);
|
||||
}
|
||||
|
||||
return { markdown: lines.join("\n").trimEnd(), coverUrl };
|
||||
|
||||
@@ -202,6 +202,13 @@ function toHighResUrl(rawUrl: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isPlausibleMediaUrl(rawUrl: string): boolean {
|
||||
const ext = resolveExtensionFromUrl(rawUrl);
|
||||
if (ext && (IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext))) return true;
|
||||
if (resolveKindFromHostname(rawUrl) !== undefined) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
||||
const candidates: MarkdownLinkCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -221,10 +228,12 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
|
||||
const label = match[1] ?? "";
|
||||
const rawUrl = match[3] ?? "";
|
||||
if (!rawUrl || seen.has(rawUrl)) continue;
|
||||
const isImage = label.startsWith("![");
|
||||
if (!isImage && !isPlausibleMediaUrl(rawUrl)) continue;
|
||||
seen.add(rawUrl);
|
||||
candidates.push({
|
||||
url: rawUrl,
|
||||
hint: label.startsWith("![") ? "image" : "unknown",
|
||||
hint: isImage ? "image" : "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export type ArticleEntityMapEntry = {
|
||||
mutability?: string;
|
||||
data?: {
|
||||
caption?: string;
|
||||
markdown?: string;
|
||||
mediaItems?: ArticleEntityMapMediaItem[];
|
||||
url?: string;
|
||||
tweetId?: string;
|
||||
|
||||
+136
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
@@ -153,6 +189,106 @@ test("findExistingChromeDebugPort reads DevToolsActivePort and validates it agai
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
|
||||
+123
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-format-markdown
|
||||
description: Formats plain text or markdown files with frontmatter, titles, summaries, headings, bold, lists, and code blocks. Use when user asks to "format markdown", "beautify article", "add formatting", or improve article layout. Outputs to {filename}-formatted.md.
|
||||
version: 1.56.1
|
||||
version: 1.57.0
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-format-markdown
|
||||
@@ -88,7 +88,7 @@ Read the user-specified file, then detect content type:
|
||||
| Has `**bold**`, `*italic*`, lists, code blocks, blockquotes | Markdown |
|
||||
| None of above | Plain text |
|
||||
|
||||
**If Markdown detected, ask user:**
|
||||
**If Markdown detected, use `AskUserQuestion` to ask:**
|
||||
|
||||
```
|
||||
Detected existing markdown formatting. What would you like to do?
|
||||
@@ -174,7 +174,8 @@ Check for YAML frontmatter (`---` block). Create if missing.
|
||||
|-------|------------|
|
||||
| `title` | See **Title Generation** below |
|
||||
| `slug` | Infer from file path or generate from title |
|
||||
| `summary` | See **Summary Generation** below |
|
||||
| `summary` | One-sentence concise summary (see **Summary Generation** below) |
|
||||
| `description` | Longer descriptive summary (see **Summary Generation** below) |
|
||||
| `coverImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path |
|
||||
|
||||
**Title Generation:**
|
||||
@@ -187,73 +188,52 @@ Whether or not a title already exists, always run the title optimization flow (u
|
||||
- Reader pain point or curiosity trigger
|
||||
- Most memorable metaphor or golden quote
|
||||
|
||||
**Generate 3-4 style-differentiated candidates:**
|
||||
**Generate titles** using formulas from `references/title-formulas.md`:
|
||||
|
||||
| Style | Characteristics | Example |
|
||||
|-------|----------------|---------|
|
||||
| Subversive | Deny common practice, create conflict | "All de-AI-flavor prompts are wrong" |
|
||||
| Solution | Give the answer directly, promise value | "One recipe to make AI write in your voice" |
|
||||
| Suspense | Reveal half, spark curiosity | "It took me six months to find how to remove AI flavor" |
|
||||
| Concrete number | Use numbers for credibility | "150 lines of docs taught AI my writing style" |
|
||||
1. Select the **2-3 best-matching hook formulas** based on the article's content, tone, and structure (see "When to pick each formula" in the reference)
|
||||
2. Generate **1-2 straightforward titles** (descriptive or declarative, no formula — clear and accurate)
|
||||
3. If the user specifies a direction (e.g., "make it suspenseful"), prioritize that direction
|
||||
4. Total: **4-5 candidates**
|
||||
|
||||
Present to user:
|
||||
Use `AskUserQuestion` to present candidates:
|
||||
|
||||
```
|
||||
Pick a title:
|
||||
|
||||
1. [Title A] — (recommended)
|
||||
2. [Title B] — [style note]
|
||||
3. [Title C] — [style note]
|
||||
1. [Hook title A] — (recommended) [formula name]
|
||||
2. [Hook title B] — [formula name]
|
||||
3. [Hook title C] — [formula name]
|
||||
4. [Straightforward title D] — straightforward
|
||||
5. [Straightforward title E] — straightforward
|
||||
|
||||
Enter number, or type a custom title:
|
||||
```
|
||||
|
||||
Put the strongest hook first and mark it (recommended).
|
||||
|
||||
**Title principles:**
|
||||
- **Hook in first 5 chars**: create information gap or cognitive conflict
|
||||
- **Specific > abstract**: "150 lines" beats "a document"
|
||||
- **Negation > affirmation**: "you're doing it wrong" beats "the right way"
|
||||
- **Conversational**: like chatting with a friend, not a paper title
|
||||
- **Max ~25 chars**: longer titles get truncated in feeds
|
||||
- **Accurate, not clickbait**: the article must deliver what the title promises
|
||||
|
||||
**Prohibited patterns:**
|
||||
- "浅谈 XX"、"关于 XX 的思考"、"XX 的探索与实践"
|
||||
- "震惊!"、"万字长文"、"建议收藏"
|
||||
- Pure questions without direction: "AI 写作的未来在哪里?"
|
||||
Put the strongest hook first and mark it (recommended). See `references/title-formulas.md` for title principles and prohibited patterns.
|
||||
|
||||
If first line is H1, extract to frontmatter and remove from body. If frontmatter already has `title`, include it as context but still generate fresh candidates.
|
||||
|
||||
**Summary Generation:**
|
||||
|
||||
Generate 3 candidate summaries with different angles. Present to user:
|
||||
Generate two versions directly (no user selection needed), both stored in frontmatter:
|
||||
|
||||
```
|
||||
Pick a summary:
|
||||
| Field | Length | Purpose |
|
||||
|-------|--------|---------|
|
||||
| `summary` | 1 sentence, ~50-80 chars | Concise hook — for feeds, social sharing, SEO meta |
|
||||
| `description` | 2-3 sentences, ~100-200 chars | Richer context — for article previews, newsletter blurbs |
|
||||
|
||||
1. [Summary A] — [focus note]
|
||||
2. [Summary B] — [focus note]
|
||||
3. [Summary C] — [focus note]
|
||||
|
||||
Enter number, or type a custom summary:
|
||||
```
|
||||
|
||||
**Summary principles:**
|
||||
- 80-150 characters, precise and information-rich
|
||||
**Principles:**
|
||||
- Convey **core value** to the reader, not just the topic
|
||||
- Vary angles: problem-driven, result-driven, insight-driven
|
||||
- **Hook the reader**: make them want to read the full article
|
||||
- Use concrete details (numbers, outcomes, specific methods) over vague descriptions
|
||||
- `summary` should be punchy and self-contained; `description` can expand with supporting details
|
||||
- If frontmatter already has `summary` or `description`, keep existing and only generate the missing one
|
||||
|
||||
**Prohibited patterns:**
|
||||
- "本文介绍了..."、"本文探讨了..."
|
||||
- "This article introduces...", "This article explores..."
|
||||
- Pure topic description without value proposition
|
||||
- Repeating the title in different words
|
||||
|
||||
If frontmatter already has `summary`, skip selection and use it.
|
||||
|
||||
**EXTEND.md skip behavior:** If `auto_select: true` is set in EXTEND.md, skip title and summary selection — generate the best candidate directly without asking. User can also set `auto_select_title: true` or `auto_select_summary: true` independently.
|
||||
**EXTEND.md skip behavior:** If `auto_select: true` or `auto_select_title: true` is set in EXTEND.md, skip title selection — generate the best candidate directly without asking.
|
||||
|
||||
Once title is in frontmatter, body should NOT have H1 (avoid duplication).
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Title Formulas Reference
|
||||
|
||||
8 hook formulas + straightforward style for balanced title generation.
|
||||
|
||||
## Hook Formulas
|
||||
|
||||
| # | Formula | Characteristics | Example |
|
||||
|---|---------|----------------|---------|
|
||||
| 1 | Subversive | Deny common belief, create cognitive conflict | "All de-AI-flavor prompts are wrong" |
|
||||
| 2 | Solution | Give the answer directly, promise concrete value | "One recipe to make AI write in your voice" |
|
||||
| 3 | Suspense | Reveal half, spark a curiosity gap | "It took me six months to find how to remove AI flavor" |
|
||||
| 4 | Concrete Number | Use specific numbers for credibility and impact | "150 lines of docs taught AI my writing style" |
|
||||
| 5 | Contrast | Small cause → big result, or expectation vs reality | "One doc replaced three months of AI tuning" |
|
||||
| 6 | Result First | Lead with a surprising outcome, hook reader to find out why | "After using this method, nobody could tell it was AI" |
|
||||
| 7 | Rhetorical Question | Ask a question that creates an unfinished feeling | "Why can people spot your AI writing at a glance?" |
|
||||
| 8 | Empathy | Touch pain points, trigger shared frustration or relief | "Three months fighting AI flavor — I finally broke free" |
|
||||
|
||||
### When to pick each formula
|
||||
|
||||
| Formula | Best for |
|
||||
|---------|----------|
|
||||
| Subversive | Articles that challenge mainstream advice or debunk myths |
|
||||
| Solution | How-to guides, tutorials, actionable advice pieces |
|
||||
| Suspense | Personal stories, case studies, journey narratives |
|
||||
| Concrete Number | Data-driven articles, benchmarks, step-by-step guides |
|
||||
| Contrast | Before/after stories, unexpected discoveries, comparisons |
|
||||
| Result First | Success stories, transformation pieces, "I tried X" articles |
|
||||
| Rhetorical Question | Problem-awareness pieces, diagnostic/explainer content |
|
||||
| Empathy | Struggle narratives, community pain points, relatable experiences |
|
||||
|
||||
## Straightforward Style
|
||||
|
||||
Not every title needs a hook. Straightforward titles work well as alternatives:
|
||||
|
||||
- **Descriptive**: clearly state the topic and scope
|
||||
- **Declarative**: state the main conclusion or thesis directly
|
||||
|
||||
These provide balance — readers who prefer clarity over curiosity will appreciate them.
|
||||
|
||||
## Title Principles
|
||||
|
||||
- **Hook in first 5 characters**: create information gap or cognitive conflict
|
||||
- **Specific > abstract**: "150 lines" beats "a document"
|
||||
- **Negation > affirmation**: "you're doing it wrong" beats "the right way"
|
||||
- **Conversational**: like chatting with a friend, not an academic paper
|
||||
- **Max ~30 characters**: longer titles get truncated in feeds
|
||||
- **Accurate, not clickbait**: the article must deliver what the title promises — titles can be bold but the content must back them up
|
||||
|
||||
## Prohibited Patterns
|
||||
|
||||
- Vague academic-style: "On XX", "Thoughts on XX", "Exploration and Practice of XX"
|
||||
- Pure shock bait: "Shocking!", "10,000-word essay", "Must bookmark"
|
||||
- Directionless questions: "Where is the future of AI writing?"
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: baoyu-image-gen
|
||||
description: AI image generation with OpenAI, Google, OpenRouter, DashScope, Jimeng, Seedream and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and batch generation from saved prompt files. Sequential by default; use batch parallel generation when the user already has multiple prompts or wants stable multi-image throughput. Use when user asks to generate, create, or draw images.
|
||||
version: 1.56.2
|
||||
version: 1.56.3
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
|
||||
@@ -74,7 +74,7 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
|
||||
# From prompt files
|
||||
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
# With reference images (Google, OpenAI, OpenRouter, or Replicate)
|
||||
# With reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||
|
||||
# With reference images (explicit provider/model)
|
||||
@@ -92,6 +92,12 @@ ${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider o
|
||||
# DashScope (阿里通义万象)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||
|
||||
# DashScope Qwen-Image 2.0 Pro (recommended for custom sizes and text rendering)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "为咖啡品牌设计一张 21:9 横幅海报,包含清晰中文标题" --image out.png --provider dashscope --model qwen-image-2.0-pro --size 2048x872
|
||||
|
||||
# DashScope legacy Qwen fixed-size model
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一张电影感海报" --image out.png --provider dashscope --model qwen-image-max --size 1664x928
|
||||
|
||||
# Replicate (google/nano-banana-pro)
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
@@ -142,12 +148,12 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `--batchfile <path>` | JSON batch file for multi-image generation |
|
||||
| `--jobs <count>` | Worker count for batch mode (default: auto, max from config, built-in default 10) |
|
||||
| `--provider google\|openai\|openrouter\|dashscope\|jimeng\|seedream\|replicate` | Force provider (default: auto-detect) |
|
||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`) |
|
||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`; OpenAI: `gpt-image-1.5`; OpenRouter: `google/gemini-3.1-flash-image-preview`; DashScope: `qwen-image-2.0-pro`) |
|
||||
| `--ar <ratio>` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size <WxH>` | Size (e.g., `1024x1024`) |
|
||||
| `--quality normal\|2k` | Quality preset (default: `2k`) |
|
||||
| `--imageSize 1K\|2K\|4K` | Image size for Google/OpenRouter (default: from quality) |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal models, and Replicate. Not supported by Jimeng or Seedream |
|
||||
| `--ref <files...>` | Reference images. Supported by Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal models, Replicate, and Seedream 5.0/4.5/4.0. Not supported by Jimeng, Seedream 3.0, or removed SeedEdit 3.0 |
|
||||
| `--n <count>` | Number of images |
|
||||
| `--json` | JSON output |
|
||||
|
||||
@@ -166,7 +172,7 @@ Paths in `promptFiles`, `image`, and `ref` are resolved relative to the batch fi
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model override |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model override (default: `google/gemini-3.1-flash-image-preview`) |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model override |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: z-image-turbo) |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model override (default: `qwen-image-2.0-pro`) |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model override (default: google/nano-banana-pro) |
|
||||
| `JIMENG_IMAGE_MODEL` | Jimeng model override (default: jimeng_t2i_v40) |
|
||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model override (default: doubao-seedream-5-0-260128) |
|
||||
@@ -201,6 +207,52 @@ Model priority (highest → lowest), applies to all providers:
|
||||
- Show: `Using [provider] / [model]`
|
||||
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
|
||||
|
||||
### DashScope Models
|
||||
|
||||
Use `--model qwen-image-2.0-pro` or set `default_model.dashscope` / `DASHSCOPE_IMAGE_MODEL` when the user wants official Qwen-Image behavior.
|
||||
|
||||
Official DashScope model families:
|
||||
|
||||
- `qwen-image-2.0-pro`, `qwen-image-2.0-pro-2026-03-03`, `qwen-image-2.0`, `qwen-image-2.0-2026-03-03`
|
||||
- Free-form `size` in `宽*高` format
|
||||
- Total pixels must stay between `512*512` and `2048*2048`
|
||||
- Default size is approximately `1024*1024`
|
||||
- Best choice for custom ratios such as `21:9` and text-heavy Chinese/English layouts
|
||||
- `qwen-image-max`, `qwen-image-max-2025-12-30`, `qwen-image-plus`, `qwen-image-plus-2026-01-09`, `qwen-image`
|
||||
- Fixed sizes only: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`
|
||||
- Default size is `1664*928`
|
||||
- `qwen-image` currently has the same capability as `qwen-image-plus`
|
||||
- Legacy DashScope models such as `z-image-turbo`, `z-image-ultra`, `wanx-v1`
|
||||
- Keep using them only when the user explicitly asks for legacy behavior or compatibility
|
||||
|
||||
When translating CLI args into DashScope behavior:
|
||||
|
||||
- `--size` wins over `--ar`
|
||||
- For `qwen-image-2.0*`, prefer explicit `--size`; otherwise infer from `--ar` and use the official recommended resolutions below
|
||||
- For `qwen-image-max/plus/image`, only use the five official fixed sizes; if the requested ratio is not covered, switch to `qwen-image-2.0-pro`
|
||||
- `--quality` is a baoyu-image-gen compatibility preset, not a native DashScope API field. Mapping `normal` / `2k` onto the `qwen-image-2.0*` table below is an implementation inference, not an official API guarantee
|
||||
|
||||
Recommended `qwen-image-2.0*` sizes for common aspect ratios:
|
||||
|
||||
| Ratio | `normal` | `2k` |
|
||||
|-------|----------|------|
|
||||
| `1:1` | `1024*1024` | `1536*1536` |
|
||||
| `2:3` | `768*1152` | `1024*1536` |
|
||||
| `3:2` | `1152*768` | `1536*1024` |
|
||||
| `3:4` | `960*1280` | `1080*1440` |
|
||||
| `4:3` | `1280*960` | `1440*1080` |
|
||||
| `9:16` | `720*1280` | `1080*1920` |
|
||||
| `16:9` | `1280*720` | `1920*1080` |
|
||||
| `21:9` | `1344*576` | `2048*872` |
|
||||
|
||||
DashScope official APIs also expose `negative_prompt`, `prompt_extend`, and `watermark`, but `baoyu-image-gen` does not expose them as dedicated CLI flags today.
|
||||
|
||||
Official references:
|
||||
|
||||
- [Qwen-Image API](https://help.aliyun.com/zh/model-studio/qwen-image-api)
|
||||
- [Text-to-image guide](https://help.aliyun.com/zh/model-studio/text-to-image)
|
||||
- [Qwen-Image Edit API](https://help.aliyun.com/zh/model-studio/qwen-image-edit-api)
|
||||
|
||||
### OpenRouter Models
|
||||
|
||||
Use full OpenRouter model IDs, e.g.:
|
||||
|
||||
@@ -50,7 +50,7 @@ options:
|
||||
- label: "OpenRouter"
|
||||
description: "Router for Gemini/FLUX/OpenAI-compatible image models"
|
||||
- label: "DashScope"
|
||||
description: "Alibaba Cloud - z-image-turbo, good for Chinese content"
|
||||
description: "Alibaba Cloud - Qwen-Image, strong Chinese/English text rendering"
|
||||
- label: "Replicate"
|
||||
description: "Community models - nano-banana-pro, flexible model selection"
|
||||
```
|
||||
@@ -186,12 +186,26 @@ options:
|
||||
header: "DashScope Model"
|
||||
question: "Choose a default DashScope image generation model?"
|
||||
options:
|
||||
- label: "z-image-turbo (Recommended)"
|
||||
description: "Fast generation, good quality"
|
||||
- label: "qwen-image-2.0-pro (Recommended)"
|
||||
description: "Best DashScope model for text rendering and custom sizes"
|
||||
- label: "qwen-image-2.0"
|
||||
description: "Faster 2.0 variant with flexible output size"
|
||||
- label: "qwen-image-max"
|
||||
description: "Legacy Qwen model with five fixed output sizes"
|
||||
- label: "qwen-image-plus"
|
||||
description: "Legacy Qwen model, same current capability as qwen-image"
|
||||
- label: "z-image-turbo"
|
||||
description: "Legacy DashScope model for compatibility"
|
||||
- label: "z-image-ultra"
|
||||
description: "Higher quality, slower generation"
|
||||
description: "Legacy DashScope model, higher quality but slower"
|
||||
```
|
||||
|
||||
Notes for DashScope setup:
|
||||
|
||||
- Prefer `qwen-image-2.0-pro` when the user needs custom `--size`, uncommon ratios like `21:9`, or strong Chinese/English text rendering.
|
||||
- `qwen-image-max` / `qwen-image-plus` / `qwen-image` only support five fixed sizes: `1664*928`, `1472*1104`, `1328*1328`, `1104*1472`, `928*1664`.
|
||||
- In `baoyu-image-gen`, `quality` is a compatibility preset. It is not a native DashScope parameter.
|
||||
|
||||
### Replicate Model Selection
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -23,7 +23,7 @@ default_model:
|
||||
google: null # e.g., "gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"
|
||||
openai: null # e.g., "gpt-image-1.5", "gpt-image-1"
|
||||
openrouter: null # e.g., "google/gemini-3.1-flash-image-preview"
|
||||
dashscope: null # e.g., "z-image-turbo"
|
||||
dashscope: null # e.g., "qwen-image-2.0-pro"
|
||||
replicate: null # e.g., "google/nano-banana-pro"
|
||||
|
||||
batch:
|
||||
@@ -88,7 +88,7 @@ default_model:
|
||||
google: "gemini-3-pro-image-preview"
|
||||
openai: "gpt-image-1.5"
|
||||
openrouter: "google/gemini-3.1-flash-image-preview"
|
||||
dashscope: "z-image-turbo"
|
||||
dashscope: "qwen-image-2.0-pro"
|
||||
replicate: "google/nano-banana-pro"
|
||||
batch:
|
||||
max_workers: 10
|
||||
|
||||
@@ -216,6 +216,39 @@ test("detectProvider selects an available ref-capable provider for reference-ima
|
||||
);
|
||||
});
|
||||
|
||||
test("detectProvider infers Seedream from model id and allows Seedream reference-image workflows", (t) => {
|
||||
useEnv(t, {
|
||||
GOOGLE_API_KEY: null,
|
||||
OPENAI_API_KEY: null,
|
||||
OPENROUTER_API_KEY: null,
|
||||
DASHSCOPE_API_KEY: null,
|
||||
REPLICATE_API_TOKEN: null,
|
||||
JIMENG_ACCESS_KEY_ID: null,
|
||||
JIMENG_SECRET_ACCESS_KEY: null,
|
||||
ARK_API_KEY: "ark-key",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
detectProvider(
|
||||
makeArgs({
|
||||
model: "doubao-seedream-4-5-251128",
|
||||
referenceImages: ["ref.png"],
|
||||
}),
|
||||
),
|
||||
"seedream",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
detectProvider(
|
||||
makeArgs({
|
||||
provider: "seedream",
|
||||
referenceImages: ["ref.png"],
|
||||
}),
|
||||
),
|
||||
"seedream",
|
||||
);
|
||||
});
|
||||
|
||||
test("batch worker and provider-rate-limit configuration prefer env over EXTEND config", (t) => {
|
||||
useEnv(t, {
|
||||
BAOYU_IMAGE_GEN_MAX_WORKERS: "12",
|
||||
@@ -294,6 +327,7 @@ test("loadBatchTasks and createTaskArgs resolve batch-relative paths", async (t)
|
||||
|
||||
test("path normalization, worker count, and retry classification follow expected rules", () => {
|
||||
assert.match(normalizeOutputImagePath("out/sample"), /out[\\/]+sample\.png$/);
|
||||
assert.match(normalizeOutputImagePath("out/sample", ".jpg"), /out[\\/]+sample\.jpg$/);
|
||||
assert.match(normalizeOutputImagePath("out/sample.webp"), /out[\\/]+sample\.webp$/);
|
||||
|
||||
assert.equal(getWorkerCount(8, null, 3), 3);
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
type ProviderModule = {
|
||||
getDefaultModel: () => string;
|
||||
generateImage: (prompt: string, model: string, args: CliArgs) => Promise<Uint8Array>;
|
||||
validateArgs?: (model: string, args: CliArgs) => void;
|
||||
getDefaultOutputExtension?: (model: string, args: CliArgs) => string;
|
||||
};
|
||||
|
||||
type PreparedTask = {
|
||||
@@ -78,7 +80,7 @@ Options:
|
||||
--size <WxH> Size (e.g., 1024x1024)
|
||||
--quality normal|2k Quality preset (default: 2k)
|
||||
--imageSize 1K|2K|4K Image size for Google/OpenRouter (default: from quality)
|
||||
--ref <files...> Reference images (Google multimodal, OpenAI GPT Image edits, OpenRouter multimodal, or Replicate)
|
||||
--ref <files...> Reference images (Google, OpenAI, OpenRouter, Replicate, or Seedream 4.0/4.5/5.0)
|
||||
--n <count> Number of images for the current task (default: 1)
|
||||
--json JSON output
|
||||
-h, --help Show help
|
||||
@@ -116,7 +118,7 @@ Environment variables:
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||
OPENROUTER_IMAGE_MODEL Default OpenRouter model (google/gemini-3.1-flash-image-preview)
|
||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (z-image-turbo)
|
||||
DASHSCOPE_IMAGE_MODEL Default DashScope model (qwen-image-2.0-pro)
|
||||
REPLICATE_IMAGE_MODEL Default Replicate model (google/nano-banana-pro)
|
||||
JIMENG_IMAGE_MODEL Default Jimeng model (jimeng_t2i_v40)
|
||||
SEEDREAM_IMAGE_MODEL Default Seedream model (doubao-seedream-5-0-260128)
|
||||
@@ -560,11 +562,17 @@ async function readPromptFromStdin(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeOutputImagePath(p: string): string {
|
||||
export function normalizeOutputImagePath(p: string, defaultExtension = ".png"): string {
|
||||
const full = path.resolve(p);
|
||||
const ext = path.extname(full);
|
||||
if (ext) return full;
|
||||
return `${full}.png`;
|
||||
return `${full}${defaultExtension}`;
|
||||
}
|
||||
|
||||
function inferProviderFromModel(model: string | null): Provider | null {
|
||||
if (!model) return null;
|
||||
if (model.includes("seedream") || model.includes("seededit")) return "seedream";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectProvider(args: CliArgs): Provider {
|
||||
@@ -574,10 +582,11 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
args.provider !== "google" &&
|
||||
args.provider !== "openai" &&
|
||||
args.provider !== "openrouter" &&
|
||||
args.provider !== "replicate"
|
||||
args.provider !== "replicate" &&
|
||||
args.provider !== "seedream"
|
||||
) {
|
||||
throw new Error(
|
||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider openrouter (OpenRouter multimodal), or --provider replicate."
|
||||
"Reference images require a ref-capable provider. Use --provider google (Gemini multimodal), --provider openai (GPT Image edits), --provider openrouter (OpenRouter multimodal), --provider replicate, or --provider seedream for supported Seedream models."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -590,14 +599,23 @@ export function detectProvider(args: CliArgs): Provider {
|
||||
const hasReplicate = !!process.env.REPLICATE_API_TOKEN;
|
||||
const hasJimeng = !!(process.env.JIMENG_ACCESS_KEY_ID && process.env.JIMENG_SECRET_ACCESS_KEY);
|
||||
const hasSeedream = !!process.env.ARK_API_KEY;
|
||||
const modelProvider = inferProviderFromModel(args.model);
|
||||
|
||||
if (modelProvider === "seedream") {
|
||||
if (!hasSeedream) {
|
||||
throw new Error("Model looks like a Volcengine ARK image model, but ARK_API_KEY is not set.");
|
||||
}
|
||||
return "seedream";
|
||||
}
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
if (hasGoogle) return "google";
|
||||
if (hasOpenai) return "openai";
|
||||
if (hasOpenrouter) return "openrouter";
|
||||
if (hasReplicate) return "replicate";
|
||||
if (hasSeedream) return "seedream";
|
||||
throw new Error(
|
||||
"Reference images require Google, OpenAI, OpenRouter, or Replicate. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, or REPLICATE_API_TOKEN, or remove --ref."
|
||||
"Reference images require Google, OpenAI, OpenRouter, Replicate, or supported Seedream models. Set GOOGLE_API_KEY/GEMINI_API_KEY, OPENAI_API_KEY, OPENROUTER_API_KEY, REPLICATE_API_TOKEN, or ARK_API_KEY, or remove --ref."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -701,6 +719,8 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
|
||||
const provider = detectProvider(args);
|
||||
const providerModule = await loadProviderModule(provider);
|
||||
const model = getModelForProvider(provider, args.model, extendConfig, providerModule);
|
||||
providerModule.validateArgs?.(model, args);
|
||||
const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, args) ?? ".png";
|
||||
|
||||
return {
|
||||
id: "single",
|
||||
@@ -708,7 +728,7 @@ async function prepareSingleTask(args: CliArgs, extendConfig: Partial<ExtendConf
|
||||
args,
|
||||
provider,
|
||||
model,
|
||||
outputPath: normalizeOutputImagePath(args.imagePath),
|
||||
outputPath: normalizeOutputImagePath(args.imagePath, defaultOutputExtension),
|
||||
providerModule,
|
||||
};
|
||||
}
|
||||
@@ -784,13 +804,15 @@ async function prepareBatchTasks(
|
||||
const provider = detectProvider(taskArgs);
|
||||
const providerModule = await loadProviderModule(provider);
|
||||
const model = getModelForProvider(provider, taskArgs.model, extendConfig, providerModule);
|
||||
providerModule.validateArgs?.(model, taskArgs);
|
||||
const defaultOutputExtension = providerModule.getDefaultOutputExtension?.(model, taskArgs) ?? ".png";
|
||||
prepared.push({
|
||||
id: task.id || `task-${String(i + 1).padStart(2, "0")}`,
|
||||
prompt,
|
||||
args: taskArgs,
|
||||
provider,
|
||||
model,
|
||||
outputPath: normalizeOutputImagePath(taskArgs.imagePath),
|
||||
outputPath: normalizeOutputImagePath(taskArgs.imagePath, defaultOutputExtension),
|
||||
providerModule,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,25 +1,147 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
getDefaultModel,
|
||||
getModelFamily,
|
||||
getQwen2SizeFromAspectRatio,
|
||||
getSizeFromAspectRatio,
|
||||
normalizeSize,
|
||||
parseAspectRatio,
|
||||
parseSize,
|
||||
resolveSizeForModel,
|
||||
} from "./dashscope.ts";
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("DashScope default model prefers env override and otherwise uses qwen-image-2.0-pro", (t) => {
|
||||
useEnv(t, { DASHSCOPE_IMAGE_MODEL: null });
|
||||
assert.equal(getDefaultModel(), "qwen-image-2.0-pro");
|
||||
|
||||
process.env.DASHSCOPE_IMAGE_MODEL = "qwen-image-max";
|
||||
assert.equal(getDefaultModel(), "qwen-image-max");
|
||||
});
|
||||
|
||||
test("DashScope aspect-ratio parsing accepts numeric ratios only", () => {
|
||||
assert.deepEqual(parseAspectRatio("3:2"), { width: 3, height: 2 });
|
||||
assert.equal(parseAspectRatio("square"), null);
|
||||
assert.equal(parseAspectRatio("-1:2"), null);
|
||||
});
|
||||
|
||||
test("DashScope size selection picks the closest supported size per quality preset", () => {
|
||||
test("DashScope model family routing distinguishes qwen-2.0, fixed-size qwen, and legacy models", () => {
|
||||
assert.equal(getModelFamily("qwen-image-2.0-pro"), "qwen2");
|
||||
assert.equal(getModelFamily("qwen-image"), "qwenFixed");
|
||||
assert.equal(getModelFamily("z-image-turbo"), "legacy");
|
||||
assert.equal(getModelFamily("wanx-v1"), "legacy");
|
||||
});
|
||||
|
||||
test("Legacy DashScope size selection keeps the previous quality-based heuristic", () => {
|
||||
assert.equal(getSizeFromAspectRatio(null, "normal"), "1024*1024");
|
||||
assert.equal(getSizeFromAspectRatio("16:9", "normal"), "1280*720");
|
||||
assert.equal(getSizeFromAspectRatio("16:9", "2k"), "2048*1152");
|
||||
assert.equal(getSizeFromAspectRatio("invalid", "2k"), "1536*1536");
|
||||
});
|
||||
|
||||
test("Qwen 2.0 recommended sizes follow the official common-ratio table", () => {
|
||||
assert.equal(getQwen2SizeFromAspectRatio(null, "normal"), "1024*1024");
|
||||
assert.equal(getQwen2SizeFromAspectRatio(null, "2k"), "1536*1536");
|
||||
assert.equal(getQwen2SizeFromAspectRatio("16:9", "normal"), "1280*720");
|
||||
assert.equal(getQwen2SizeFromAspectRatio("21:9", "2k"), "2048*872");
|
||||
});
|
||||
|
||||
test("Qwen 2.0 derives free-form sizes within pixel budget for uncommon ratios", () => {
|
||||
const size = getQwen2SizeFromAspectRatio("5:2", "normal");
|
||||
const parsed = parseSize(size);
|
||||
assert.ok(parsed);
|
||||
assert.ok(parsed.width * parsed.height >= 512 * 512);
|
||||
assert.ok(parsed.width * parsed.height <= 2048 * 2048);
|
||||
assert.ok(Math.abs(parsed.width / parsed.height - 2.5) < 0.08);
|
||||
});
|
||||
|
||||
test("resolveSizeForModel validates explicit qwen-image-2.0 sizes by total pixels", () => {
|
||||
assert.equal(
|
||||
resolveSizeForModel("qwen-image-2.0-pro", {
|
||||
size: "2048x872",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
"2048*872",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("qwen-image-2.0-pro", {
|
||||
size: "4096x4096",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
/total pixels between/,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSizeForModel enforces fixed sizes for qwen-image-max/plus/image", () => {
|
||||
assert.equal(
|
||||
resolveSizeForModel("qwen-image-max", {
|
||||
size: null,
|
||||
aspectRatio: "1:1",
|
||||
quality: "2k",
|
||||
}),
|
||||
"1328*1328",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveSizeForModel("qwen-image", {
|
||||
size: "1664x928",
|
||||
aspectRatio: "9:16",
|
||||
quality: "normal",
|
||||
}),
|
||||
"1664*928",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("qwen-image-max", {
|
||||
size: null,
|
||||
aspectRatio: "21:9",
|
||||
quality: "2k",
|
||||
}),
|
||||
/supports only fixed ratios/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSizeForModel("qwen-image-plus", {
|
||||
size: "1024x1024",
|
||||
aspectRatio: null,
|
||||
quality: "2k",
|
||||
}),
|
||||
/support only these sizes/,
|
||||
);
|
||||
});
|
||||
|
||||
test("DashScope size normalization converts WxH into provider format", () => {
|
||||
assert.equal(normalizeSize("1024x1024"), "1024*1024");
|
||||
assert.equal(normalizeSize("2048*1152"), "2048*1152");
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
import type { CliArgs } from "../types";
|
||||
import type { CliArgs, Quality } from "../types";
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.DASHSCOPE_IMAGE_MODEL || "z-image-turbo";
|
||||
}
|
||||
type DashScopeModelFamily = "qwen2" | "qwenFixed" | "legacy";
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.DASHSCOPE_API_KEY || null;
|
||||
}
|
||||
type DashScopeModelSpec = {
|
||||
family: DashScopeModelFamily;
|
||||
defaultSize: string;
|
||||
};
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
const DEFAULT_MODEL = "qwen-image-2.0-pro";
|
||||
const MIN_QWEN_2_TOTAL_PIXELS = 512 * 512;
|
||||
const MAX_QWEN_2_TOTAL_PIXELS = 2048 * 2048;
|
||||
const SIZE_STEP = 16;
|
||||
const QWEN_NEGATIVE_PROMPT =
|
||||
"低分辨率,低画质,肢体畸形,手指畸形,画面过饱和,蜡像感,人脸无细节,过度光滑,画面具有AI感,构图混乱,文字模糊,扭曲";
|
||||
|
||||
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const w = parseFloat(match[1]!);
|
||||
const h = parseFloat(match[2]!);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
return { width: w, height: h };
|
||||
}
|
||||
const QWEN_2_TARGET_PIXELS: Record<Quality, number> = {
|
||||
normal: 1024 * 1024,
|
||||
"2k": 1536 * 1536,
|
||||
};
|
||||
|
||||
const STANDARD_SIZES: [number, number][] = [
|
||||
const QWEN_2_RECOMMENDED: Record<string, Record<Quality, string>> = {
|
||||
"1:1": { normal: "1024*1024", "2k": "1536*1536" },
|
||||
"2:3": { normal: "768*1152", "2k": "1024*1536" },
|
||||
"3:2": { normal: "1152*768", "2k": "1536*1024" },
|
||||
"3:4": { normal: "960*1280", "2k": "1080*1440" },
|
||||
"4:3": { normal: "1280*960", "2k": "1440*1080" },
|
||||
"9:16": { normal: "720*1280", "2k": "1080*1920" },
|
||||
"16:9": { normal: "1280*720", "2k": "1920*1080" },
|
||||
"21:9": { normal: "1344*576", "2k": "2048*872" },
|
||||
};
|
||||
|
||||
const QWEN_FIXED_SIZES_BY_RATIO: Record<string, string> = {
|
||||
"16:9": "1664*928",
|
||||
"4:3": "1472*1104",
|
||||
"1:1": "1328*1328",
|
||||
"3:4": "1104*1472",
|
||||
"9:16": "928*1664",
|
||||
};
|
||||
|
||||
const QWEN_FIXED_SIZES = Object.values(QWEN_FIXED_SIZES_BY_RATIO);
|
||||
|
||||
const LEGACY_STANDARD_SIZES: [number, number][] = [
|
||||
[1024, 1024],
|
||||
[1280, 720],
|
||||
[720, 1280],
|
||||
@@ -34,7 +52,7 @@ const STANDARD_SIZES: [number, number][] = [
|
||||
[864, 1536],
|
||||
];
|
||||
|
||||
const STANDARD_SIZES_2K: [number, number][] = [
|
||||
const LEGACY_STANDARD_SIZES_2K: [number, number][] = [
|
||||
[1536, 1536],
|
||||
[2048, 1152],
|
||||
[1152, 2048],
|
||||
@@ -45,9 +63,167 @@ const STANDARD_SIZES_2K: [number, number][] = [
|
||||
[2048, 2048],
|
||||
];
|
||||
|
||||
const QWEN_2_SPEC: DashScopeModelSpec = {
|
||||
family: "qwen2",
|
||||
defaultSize: "1024*1024",
|
||||
};
|
||||
|
||||
const QWEN_FIXED_SPEC: DashScopeModelSpec = {
|
||||
family: "qwenFixed",
|
||||
defaultSize: QWEN_FIXED_SIZES_BY_RATIO["16:9"],
|
||||
};
|
||||
|
||||
const LEGACY_SPEC: DashScopeModelSpec = {
|
||||
family: "legacy",
|
||||
defaultSize: "1536*1536",
|
||||
};
|
||||
|
||||
const MODEL_SPEC_ALIASES: Record<string, DashScopeModelSpec> = {
|
||||
"qwen-image-2.0-pro": QWEN_2_SPEC,
|
||||
"qwen-image-2.0-pro-2026-03-03": QWEN_2_SPEC,
|
||||
"qwen-image-2.0": QWEN_2_SPEC,
|
||||
"qwen-image-2.0-2026-03-03": QWEN_2_SPEC,
|
||||
"qwen-image-max": QWEN_FIXED_SPEC,
|
||||
"qwen-image-max-2025-12-30": QWEN_FIXED_SPEC,
|
||||
"qwen-image-plus": QWEN_FIXED_SPEC,
|
||||
"qwen-image-plus-2026-01-09": QWEN_FIXED_SPEC,
|
||||
"qwen-image": QWEN_FIXED_SPEC,
|
||||
};
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.DASHSCOPE_IMAGE_MODEL || DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.DASHSCOPE_API_KEY || null;
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const base = process.env.DASHSCOPE_BASE_URL || "https://dashscope.aliyuncs.com";
|
||||
return base.replace(/\/+$/g, "");
|
||||
}
|
||||
|
||||
function getModelSpec(model: string): DashScopeModelSpec {
|
||||
return MODEL_SPEC_ALIASES[model.trim().toLowerCase()] || LEGACY_SPEC;
|
||||
}
|
||||
|
||||
export function getModelFamily(model: string): DashScopeModelFamily {
|
||||
return getModelSpec(model).family;
|
||||
}
|
||||
|
||||
function normalizeQuality(quality: CliArgs["quality"]): Quality {
|
||||
return quality === "normal" ? "normal" : "2k";
|
||||
}
|
||||
|
||||
export function parseAspectRatio(ar: string): { width: number; height: number } | null {
|
||||
const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return null;
|
||||
const w = parseFloat(match[1]!);
|
||||
const h = parseFloat(match[2]!);
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
return { width: w, height: h };
|
||||
}
|
||||
|
||||
export function normalizeSize(size: string): string {
|
||||
return size.replace("x", "*");
|
||||
}
|
||||
|
||||
export function parseSize(size: string): { width: number; height: number } | null {
|
||||
const match = normalizeSize(size).match(/^(\d+)\*(\d+)$/);
|
||||
if (!match) return null;
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function formatSize(width: number, height: number): string {
|
||||
return `${width}*${height}`;
|
||||
}
|
||||
|
||||
function getRatioValue(ar: string): number | null {
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return null;
|
||||
return parsed.width / parsed.height;
|
||||
}
|
||||
|
||||
function findKnownRatioKey(ar: string, candidates: string[], tolerance = 0.02): string | null {
|
||||
const targetRatio = getRatioValue(ar);
|
||||
if (targetRatio == null) return null;
|
||||
|
||||
let bestKey: string | null = null;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const candidateRatio = getRatioValue(candidate);
|
||||
if (candidateRatio == null) continue;
|
||||
const diff = Math.abs(candidateRatio - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
bestKey = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestDiff <= tolerance ? bestKey : null;
|
||||
}
|
||||
|
||||
function roundToStep(value: number): number {
|
||||
return Math.max(SIZE_STEP, Math.round(value / SIZE_STEP) * SIZE_STEP);
|
||||
}
|
||||
|
||||
function fitToPixelBudget(
|
||||
width: number,
|
||||
height: number,
|
||||
minPixels: number,
|
||||
maxPixels: number,
|
||||
): { width: number; height: number } {
|
||||
let nextWidth = width;
|
||||
let nextHeight = height;
|
||||
let pixels = nextWidth * nextHeight;
|
||||
|
||||
if (pixels > maxPixels) {
|
||||
const scale = Math.sqrt(maxPixels / pixels);
|
||||
nextWidth *= scale;
|
||||
nextHeight *= scale;
|
||||
} else if (pixels < minPixels) {
|
||||
const scale = Math.sqrt(minPixels / pixels);
|
||||
nextWidth *= scale;
|
||||
nextHeight *= scale;
|
||||
}
|
||||
|
||||
let roundedWidth = roundToStep(nextWidth);
|
||||
let roundedHeight = roundToStep(nextHeight);
|
||||
pixels = roundedWidth * roundedHeight;
|
||||
|
||||
while (pixels > maxPixels && (roundedWidth > SIZE_STEP || roundedHeight > SIZE_STEP)) {
|
||||
if (roundedWidth >= roundedHeight && roundedWidth > SIZE_STEP) {
|
||||
roundedWidth -= SIZE_STEP;
|
||||
} else if (roundedHeight > SIZE_STEP) {
|
||||
roundedHeight -= SIZE_STEP;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
pixels = roundedWidth * roundedHeight;
|
||||
}
|
||||
|
||||
while (pixels < minPixels) {
|
||||
if (roundedWidth <= roundedHeight) {
|
||||
roundedWidth += SIZE_STEP;
|
||||
} else {
|
||||
roundedHeight += SIZE_STEP;
|
||||
}
|
||||
pixels = roundedWidth * roundedHeight;
|
||||
}
|
||||
|
||||
return { width: roundedWidth, height: roundedHeight };
|
||||
}
|
||||
|
||||
export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const is2k = quality === "2k";
|
||||
const defaultSize = is2k ? "1536*1536" : "1024*1024";
|
||||
const normalizedQuality = normalizeQuality(quality);
|
||||
const sizes = normalizedQuality === "2k" ? LEGACY_STANDARD_SIZES_2K : LEGACY_STANDARD_SIZES;
|
||||
const defaultSize = normalizedQuality === "2k" ? "1536*1536" : "1024*1024";
|
||||
|
||||
if (!ar) return defaultSize;
|
||||
|
||||
@@ -55,86 +231,157 @@ export function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["qual
|
||||
if (!parsed) return defaultSize;
|
||||
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
const sizes = is2k ? STANDARD_SIZES_2K : STANDARD_SIZES;
|
||||
|
||||
let best = defaultSize;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [w, h] of sizes) {
|
||||
const diff = Math.abs(w / h - targetRatio);
|
||||
for (const [width, height] of sizes) {
|
||||
const diff = Math.abs(width / height - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
best = `${w}*${h}`;
|
||||
best = formatSize(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export function normalizeSize(size: string): string {
|
||||
return size.replace("x", "*");
|
||||
export function getQwen2SizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const normalizedQuality = normalizeQuality(quality);
|
||||
|
||||
if (!ar) {
|
||||
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
|
||||
}
|
||||
|
||||
const recommendedRatio = findKnownRatioKey(ar, Object.keys(QWEN_2_RECOMMENDED));
|
||||
if (recommendedRatio) {
|
||||
return QWEN_2_RECOMMENDED[recommendedRatio][normalizedQuality];
|
||||
}
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) {
|
||||
return QWEN_2_RECOMMENDED["1:1"][normalizedQuality];
|
||||
}
|
||||
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
const targetPixels = QWEN_2_TARGET_PIXELS[normalizedQuality];
|
||||
const rawWidth = Math.sqrt(targetPixels * targetRatio);
|
||||
const rawHeight = Math.sqrt(targetPixels / targetRatio);
|
||||
const fitted = fitToPixelBudget(
|
||||
rawWidth,
|
||||
rawHeight,
|
||||
MIN_QWEN_2_TOTAL_PIXELS,
|
||||
MAX_QWEN_2_TOTAL_PIXELS,
|
||||
);
|
||||
|
||||
return formatSize(fitted.width, fitted.height);
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
throw new Error(
|
||||
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
||||
function getQwenFixedSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
if (quality === "normal") {
|
||||
console.warn(
|
||||
"DashScope qwen-image-max/plus/image models use fixed output sizes; --quality normal does not change the generated resolution."
|
||||
);
|
||||
}
|
||||
|
||||
const size = args.size ? normalizeSize(args.size) : getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
|
||||
if (!ar) return QWEN_FIXED_SPEC.defaultSize;
|
||||
|
||||
const body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
prompt_extend: false,
|
||||
size,
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`Generating image with DashScope (${model})...`, { size });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`DashScope API error (${res.status}): ${err}`);
|
||||
const ratioKey = findKnownRatioKey(ar, Object.keys(QWEN_FIXED_SIZES_BY_RATIO));
|
||||
if (!ratioKey) {
|
||||
throw new Error(
|
||||
`DashScope model supports only fixed ratios ${Object.keys(QWEN_FIXED_SIZES_BY_RATIO).join(", ")}. ` +
|
||||
`For custom ratios like "${ar}", use --model qwen-image-2.0-pro.`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await res.json() as {
|
||||
output?: {
|
||||
result_image?: string;
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: Array<{ image?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
return QWEN_FIXED_SIZES_BY_RATIO[ratioKey]!;
|
||||
}
|
||||
|
||||
function validateSizeFormat(size: string): { width: number; height: number } {
|
||||
const parsed = parseSize(size);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid DashScope size "${size}". Expected <width>x<height> or <width>*<height>.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validateQwen2Size(size: string): string {
|
||||
const normalized = normalizeSize(size);
|
||||
const parsed = validateSizeFormat(normalized);
|
||||
const totalPixels = parsed.width * parsed.height;
|
||||
if (totalPixels < MIN_QWEN_2_TOTAL_PIXELS || totalPixels > MAX_QWEN_2_TOTAL_PIXELS) {
|
||||
throw new Error(
|
||||
`DashScope qwen-image-2.0* models require total pixels between ${MIN_QWEN_2_TOTAL_PIXELS} ` +
|
||||
`and ${MAX_QWEN_2_TOTAL_PIXELS}. Received ${normalized} (${totalPixels} pixels).`
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateQwenFixedSize(size: string): string {
|
||||
const normalized = normalizeSize(size);
|
||||
validateSizeFormat(normalized);
|
||||
if (!QWEN_FIXED_SIZES.includes(normalized)) {
|
||||
throw new Error(
|
||||
`DashScope qwen-image-max/plus/image models support only these sizes: ${QWEN_FIXED_SIZES.join(", ")}. ` +
|
||||
`Received ${normalized}.`
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveSizeForModel(
|
||||
model: string,
|
||||
args: Pick<CliArgs, "size" | "aspectRatio" | "quality">,
|
||||
): string {
|
||||
const spec = getModelSpec(model);
|
||||
|
||||
if (args.size) {
|
||||
if (spec.family === "qwen2") return validateQwen2Size(args.size);
|
||||
if (spec.family === "qwenFixed") return validateQwenFixedSize(args.size);
|
||||
validateSizeFormat(args.size);
|
||||
return normalizeSize(args.size);
|
||||
}
|
||||
|
||||
if (spec.family === "qwen2") {
|
||||
return getQwen2SizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
}
|
||||
|
||||
if (spec.family === "qwenFixed") {
|
||||
return getQwenFixedSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
}
|
||||
|
||||
return getSizeFromAspectRatio(args.aspectRatio, args.quality);
|
||||
}
|
||||
|
||||
function buildParameters(
|
||||
family: DashScopeModelFamily,
|
||||
size: string,
|
||||
): Record<string, unknown> {
|
||||
const parameters: Record<string, unknown> = {
|
||||
prompt_extend: false,
|
||||
size,
|
||||
};
|
||||
|
||||
if (family === "qwen2" || family === "qwenFixed") {
|
||||
parameters.watermark = false;
|
||||
parameters.negative_prompt = QWEN_NEGATIVE_PROMPT;
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
type DashScopeResponse = {
|
||||
output?: {
|
||||
result_image?: string;
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: Array<{ image?: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
async function extractImageFromResponse(result: DashScopeResponse): Promise<Uint8Array> {
|
||||
let imageData: string | null = null;
|
||||
|
||||
if (result.output?.result_image) {
|
||||
@@ -163,3 +410,54 @@ export async function generateImage(
|
||||
|
||||
return Uint8Array.from(Buffer.from(imageData, "base64"));
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) throw new Error("DASHSCOPE_API_KEY is required");
|
||||
|
||||
if (args.referenceImages.length > 0) {
|
||||
throw new Error(
|
||||
"Reference images are not supported with DashScope provider in baoyu-image-gen. Use --provider google with a Gemini multimodal model."
|
||||
);
|
||||
}
|
||||
|
||||
const spec = getModelSpec(model);
|
||||
const size = resolveSizeForModel(model, args);
|
||||
const url = `${getBaseUrl()}/api/v1/services/aigc/multimodal-generation/generation`;
|
||||
|
||||
const body = {
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: buildParameters(spec.family, size),
|
||||
};
|
||||
|
||||
console.log(`Generating image with DashScope (${model})...`, { family: spec.family, size });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`DashScope API error (${res.status}): ${err}`);
|
||||
}
|
||||
|
||||
const result = await res.json() as DashScopeResponse;
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import type { CliArgs } from "../types.ts";
|
||||
import {
|
||||
buildImageInput,
|
||||
buildRequestBody,
|
||||
generateImage,
|
||||
getDefaultOutputExtension,
|
||||
resolveSeedreamSize,
|
||||
validateArgs,
|
||||
} from "./seedream.ts";
|
||||
|
||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
return {
|
||||
prompt: null,
|
||||
promptFiles: [],
|
||||
imagePath: null,
|
||||
provider: null,
|
||||
model: null,
|
||||
aspectRatio: null,
|
||||
size: null,
|
||||
quality: null,
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function useEnv(
|
||||
t: TestContext,
|
||||
values: Record<string, string | null>,
|
||||
): void {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
t.after(() => {
|
||||
for (const [key, value] of previous.entries()) {
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function makeTempPng(t: TestContext, name: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "seedream-test-"));
|
||||
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||
|
||||
const filePath = path.join(dir, name);
|
||||
const png1x1 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7m0AAAAASUVORK5CYII=";
|
||||
await fs.writeFile(filePath, Buffer.from(png1x1, "base64"));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
test("Seedream request body and default extensions follow official model capabilities", () => {
|
||||
const five = buildRequestBody(
|
||||
"A robot illustrator",
|
||||
"doubao-seedream-5-0-260128",
|
||||
makeArgs(),
|
||||
);
|
||||
assert.equal(five.size, "2K");
|
||||
assert.equal(five.response_format, "url");
|
||||
assert.equal(five.output_format, "png");
|
||||
assert.equal(getDefaultOutputExtension("doubao-seedream-5-0-260128"), ".png");
|
||||
|
||||
const fourFive = buildRequestBody(
|
||||
"A robot illustrator",
|
||||
"doubao-seedream-4-5-251128",
|
||||
makeArgs(),
|
||||
);
|
||||
assert.equal(fourFive.size, "2K");
|
||||
assert.equal(fourFive.response_format, "url");
|
||||
assert.ok(!("output_format" in fourFive));
|
||||
assert.equal(getDefaultOutputExtension("doubao-seedream-4-5-251128"), ".jpg");
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
buildRequestBody(
|
||||
"Change the bubbles into hearts",
|
||||
"doubao-seededit-3-0-i2i-250628",
|
||||
makeArgs({ referenceImages: ["ref.png"] }),
|
||||
"data:image/png;base64,AAAA",
|
||||
),
|
||||
/no longer supported/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Seedream size selection validates model-specific presets", () => {
|
||||
assert.equal(
|
||||
resolveSeedreamSize("doubao-seedream-4-0-250828", makeArgs({ quality: "normal" })),
|
||||
"1K",
|
||||
);
|
||||
assert.equal(
|
||||
resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ quality: "2k" })),
|
||||
"2048x2048",
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSeedreamSize("doubao-seedream-5-0-260128", makeArgs({ size: "4K" })),
|
||||
/only supports 2K, 3K/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSeedreamSize("doubao-seedream-3-0-t2i-250415", makeArgs({ imageSize: "2K" })),
|
||||
/only supports explicit WxH sizes/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveSeedreamSize("doubao-seededit-3-0-i2i-250628", makeArgs({ size: "1024x1024" })),
|
||||
/no longer supported/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Seedream reference-image support is model-specific", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
validateArgs(
|
||||
"doubao-seedream-5-0-260128",
|
||||
makeArgs({ referenceImages: ["a.png", "b.png"] }),
|
||||
),
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"doubao-seedream-3-0-t2i-250415",
|
||||
makeArgs({ referenceImages: ["a.png"] }),
|
||||
),
|
||||
/does not support reference images/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"doubao-seededit-3-0-i2i-250628",
|
||||
makeArgs(),
|
||||
),
|
||||
/no longer supported/,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateArgs(
|
||||
"ep-20260315171508-t8br2",
|
||||
makeArgs({ referenceImages: ["a.png"] }),
|
||||
),
|
||||
/require a known model ID/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Seedream image input encodes local references as data URLs", async (t) => {
|
||||
const refOne = await makeTempPng(t, "one.png");
|
||||
const refTwo = await makeTempPng(t, "two.png");
|
||||
|
||||
const single = await buildImageInput("doubao-seedream-4-5-251128", [refOne]);
|
||||
assert.match(String(single), /^data:image\/png;base64,/);
|
||||
|
||||
const multiple = await buildImageInput("doubao-seedream-5-0-260128", [refOne, refTwo]);
|
||||
assert.ok(Array.isArray(multiple));
|
||||
assert.equal(multiple.length, 2);
|
||||
});
|
||||
|
||||
test("Seedream generateImage posts the documented response_format and downloads the returned URL", async (t) => {
|
||||
useEnv(t, { ARK_API_KEY: "test-key", SEEDREAM_BASE_URL: null });
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
t.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const calls: Array<{
|
||||
input: string;
|
||||
init?: RequestInit;
|
||||
}> = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
calls.push({
|
||||
input: String(input),
|
||||
init,
|
||||
});
|
||||
|
||||
if (calls.length === 1) {
|
||||
return Response.json({
|
||||
model: "doubao-seedream-4-5-251128",
|
||||
created: 1740000000,
|
||||
data: [
|
||||
{
|
||||
url: "https://example.com/generated-image",
|
||||
size: "2048x2048",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
generated_images: 1,
|
||||
output_tokens: 1,
|
||||
total_tokens: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(Uint8Array.from([7, 8, 9]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/jpeg" },
|
||||
});
|
||||
};
|
||||
|
||||
const image = await generateImage(
|
||||
"A robot illustrator",
|
||||
"doubao-seedream-4-5-251128",
|
||||
makeArgs(),
|
||||
);
|
||||
|
||||
assert.deepEqual([...image], [7, 8, 9]);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(
|
||||
calls[0]?.input,
|
||||
"https://ark.cn-beijing.volces.com/api/v3/images/generations",
|
||||
);
|
||||
|
||||
const requestBody = JSON.parse(String(calls[0]?.init?.body)) as Record<string, unknown>;
|
||||
assert.equal(requestBody.model, "doubao-seedream-4-5-251128");
|
||||
assert.equal(requestBody.size, "2K");
|
||||
assert.equal(requestBody.response_format, "url");
|
||||
assert.ok(!("output_format" in requestBody));
|
||||
assert.equal(calls[1]?.input, "https://example.com/generated-image");
|
||||
});
|
||||
@@ -1,5 +1,50 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
export type SeedreamModelFamily =
|
||||
| "seedream5"
|
||||
| "seedream45"
|
||||
| "seedream40"
|
||||
| "seedream30"
|
||||
| "unknown";
|
||||
|
||||
type SeedreamRequestImage = string | string[];
|
||||
|
||||
type SeedreamRequestBody = {
|
||||
model: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
response_format: "url";
|
||||
watermark: boolean;
|
||||
image?: SeedreamRequestImage;
|
||||
output_format?: "png";
|
||||
};
|
||||
|
||||
type SeedreamImageResponse = {
|
||||
model?: string;
|
||||
created?: number;
|
||||
data?: Array<{
|
||||
url?: string;
|
||||
b64_json?: string;
|
||||
size?: string;
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
}>;
|
||||
usage?: {
|
||||
generated_images: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function getDefaultModel(): string {
|
||||
return process.env.SEEDREAM_IMAGE_MODEL || "doubao-seedream-5-0-260128";
|
||||
}
|
||||
@@ -12,46 +57,252 @@ function getBaseUrl(): string {
|
||||
return process.env.SEEDREAM_BASE_URL || "https://ark.cn-beijing.volces.com/api/v3";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert aspect ratio to Seedream size format
|
||||
* Seedream API accepts: "2k" (default), "3k", or WIDTHxHEIGHT format
|
||||
* Note: API uses lowercase "2k"/"3k", not "2K"/"3K"
|
||||
*/
|
||||
function getSeedreamSize(ar: string | null, quality: CliArgs["quality"], imageSize?: string | null): string {
|
||||
// If explicit size is provided
|
||||
if (imageSize) {
|
||||
const upper = imageSize.toUpperCase();
|
||||
if (upper === "2K" || upper === "3K") {
|
||||
return upper.toLowerCase(); // API expects "2k" or "3k"
|
||||
}
|
||||
// For widthxheight format, pass through as-is
|
||||
if (imageSize.includes("x")) {
|
||||
return imageSize;
|
||||
}
|
||||
function parsePixelSize(value: string): { width: number; height: number } | null {
|
||||
const match = value.trim().match(/^(\d+)\s*[xX]\s*(\d+)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const width = parseInt(match[1]!, 10);
|
||||
const height = parseInt(match[2]!, 10);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Default to 2k (smallest option supported by API)
|
||||
return "2k";
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
type SeedreamImageResponse = {
|
||||
model: string;
|
||||
created: number;
|
||||
data: Array<{
|
||||
url: string;
|
||||
size: string;
|
||||
}>;
|
||||
usage: {
|
||||
generated_images: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
function normalizePixelSize(value: string): string | null {
|
||||
const parsed = parsePixelSize(value);
|
||||
if (!parsed) return null;
|
||||
return `${parsed.width}x${parsed.height}`;
|
||||
}
|
||||
|
||||
function normalizeSizePreset(value: string): string | null {
|
||||
const upper = value.trim().toUpperCase();
|
||||
if (upper === "ADAPTIVE") return "adaptive";
|
||||
if (upper === "1K" || upper === "2K" || upper === "3K" || upper === "4K") return upper;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSizeValue(value: string): string | null {
|
||||
return normalizeSizePreset(value) ?? normalizePixelSize(value);
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
if (ext === ".bmp") return "image/bmp";
|
||||
if (ext === ".tiff" || ext === ".tif") return "image/tiff";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
async function readImageAsDataUrl(filePath: string): Promise<string> {
|
||||
const bytes = await readFile(filePath);
|
||||
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
export function getModelFamily(model: string): SeedreamModelFamily {
|
||||
const normalized = model.trim();
|
||||
if (/^doubao-seedream-5-0(?:-lite)?-\d+$/.test(normalized)) return "seedream5";
|
||||
if (/^doubao-seedream-4-5-\d+$/.test(normalized)) return "seedream45";
|
||||
if (/^doubao-seedream-4-0-\d+$/.test(normalized)) return "seedream40";
|
||||
if (/^doubao-seedream-3-0-t2i-\d+$/.test(normalized)) return "seedream30";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function isRemovedSeededitModel(model: string): boolean {
|
||||
return /^doubao-seededit-3-0-i2i-\d+$/.test(model.trim());
|
||||
}
|
||||
|
||||
function assertSupportedModel(model: string): void {
|
||||
if (isRemovedSeededitModel(model)) {
|
||||
throw new Error(
|
||||
`${model} is no longer supported. SeedEdit 3.0 support has been removed from this tool; use Seedream 5.0/4.5/4.0/3.0 instead.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsReferenceImages(model: string): boolean {
|
||||
const family = getModelFamily(model);
|
||||
return family === "seedream5" || family === "seedream45" || family === "seedream40";
|
||||
}
|
||||
|
||||
function supportsOutputFormat(model: string): boolean {
|
||||
return getModelFamily(model) === "seedream5";
|
||||
}
|
||||
|
||||
export function getDefaultOutputExtension(model: string): ".png" | ".jpg" {
|
||||
assertSupportedModel(model);
|
||||
return supportsOutputFormat(model) ? ".png" : ".jpg";
|
||||
}
|
||||
|
||||
export function getDefaultSeedreamSize(model: string, args: CliArgs): string {
|
||||
assertSupportedModel(model);
|
||||
const family = getModelFamily(model);
|
||||
|
||||
if (family === "seedream5") return "2K";
|
||||
if (family === "seedream45") return "2K";
|
||||
if (family === "seedream40") return args.quality === "normal" ? "1K" : "2K";
|
||||
if (family === "seedream30") return args.quality === "2k" ? "2048x2048" : "1024x1024";
|
||||
return "2K";
|
||||
}
|
||||
|
||||
export function resolveSeedreamSize(model: string, args: CliArgs): string {
|
||||
assertSupportedModel(model);
|
||||
const family = getModelFamily(model);
|
||||
const requested = args.size || args.imageSize || null;
|
||||
const normalized = requested ? normalizeSizeValue(requested) : null;
|
||||
|
||||
if (!normalized) {
|
||||
return getDefaultSeedreamSize(model, args);
|
||||
}
|
||||
|
||||
if (family === "seedream30") {
|
||||
const pixelSize = normalizePixelSize(normalized);
|
||||
if (!pixelSize) {
|
||||
throw new Error("Seedream 3.0 only supports explicit WxH sizes such as 1024x1024.");
|
||||
}
|
||||
return pixelSize;
|
||||
}
|
||||
|
||||
if (family === "seedream5") {
|
||||
if (normalized === "4K" || normalized === "1K" || normalized === "adaptive") {
|
||||
throw new Error("Seedream 5.0 only supports 2K, 3K, or explicit WxH sizes.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (family === "seedream45") {
|
||||
if (normalized === "1K" || normalized === "3K" || normalized === "adaptive") {
|
||||
throw new Error("Seedream 4.5 only supports 2K, 4K, or explicit WxH sizes.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (family === "seedream40") {
|
||||
if (normalized === "3K" || normalized === "adaptive") {
|
||||
throw new Error("Seedream 4.0 only supports 1K, 2K, 4K, or explicit WxH sizes.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (normalized === "adaptive") {
|
||||
throw new Error("Adaptive size is not supported by Seedream image generation.");
|
||||
}
|
||||
|
||||
if (normalized === "1K" || normalized === "3K" || normalized === "4K") {
|
||||
throw new Error(
|
||||
"Unknown Seedream model ID. Use a documented model ID or pass an explicit WxH size instead of preset imageSize."
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function validateArgs(model: string, args: CliArgs): void {
|
||||
assertSupportedModel(model);
|
||||
const family = getModelFamily(model);
|
||||
const refCount = args.referenceImages.length;
|
||||
|
||||
if (refCount === 0) {
|
||||
resolveSeedreamSize(model, args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (family === "unknown") {
|
||||
throw new Error(
|
||||
"Reference images with Seedream require a known model ID. Use Seedream 5.0/4.5/4.0 model IDs instead of an endpoint ID."
|
||||
);
|
||||
}
|
||||
|
||||
if (!supportsReferenceImages(model)) {
|
||||
throw new Error(`${model} does not support reference images.`);
|
||||
}
|
||||
|
||||
if ((family === "seedream5" || family === "seedream45" || family === "seedream40") && refCount > 14) {
|
||||
throw new Error(`${model} supports at most 14 reference images.`);
|
||||
}
|
||||
|
||||
resolveSeedreamSize(model, args);
|
||||
}
|
||||
|
||||
export async function buildImageInput(
|
||||
model: string,
|
||||
referenceImages: string[],
|
||||
): Promise<SeedreamRequestImage | undefined> {
|
||||
if (referenceImages.length === 0) return undefined;
|
||||
assertSupportedModel(model);
|
||||
|
||||
const encoded = await Promise.all(referenceImages.map((refPath) => readImageAsDataUrl(refPath)));
|
||||
|
||||
return encoded.length === 1 ? encoded[0]! : encoded;
|
||||
}
|
||||
|
||||
export function buildRequestBody(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs,
|
||||
imageInput?: SeedreamRequestImage,
|
||||
): SeedreamRequestBody {
|
||||
validateArgs(model, args);
|
||||
|
||||
const requestBody: SeedreamRequestBody = {
|
||||
model,
|
||||
prompt,
|
||||
size: resolveSeedreamSize(model, args),
|
||||
response_format: "url",
|
||||
watermark: false,
|
||||
};
|
||||
};
|
||||
|
||||
if (imageInput) {
|
||||
requestBody.image = imageInput;
|
||||
}
|
||||
|
||||
if (supportsOutputFormat(model)) {
|
||||
requestBody.output_format = "png";
|
||||
}
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
async function downloadImage(url: string): Promise<Uint8Array> {
|
||||
const imgResponse = await fetch(url);
|
||||
if (!imgResponse.ok) {
|
||||
throw new Error(`Failed to download image from ${url}`);
|
||||
}
|
||||
|
||||
const buffer = await imgResponse.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
export async function extractImageFromResponse(result: SeedreamImageResponse): Promise<Uint8Array> {
|
||||
const first = result.data?.find((item) => item.url || item.b64_json || item.error);
|
||||
|
||||
if (!first) {
|
||||
throw new Error("No image data in Seedream response");
|
||||
}
|
||||
|
||||
if (first.error) {
|
||||
throw new Error(first.error.message || "Seedream returned an image generation error");
|
||||
}
|
||||
|
||||
if (first.b64_json) {
|
||||
return Uint8Array.from(Buffer.from(first.b64_json, "base64"));
|
||||
}
|
||||
|
||||
if (first.url) {
|
||||
console.error(`Downloading image from ${first.url}...`);
|
||||
return downloadImage(first.url);
|
||||
}
|
||||
|
||||
throw new Error("No image URL or base64 data in Seedream response");
|
||||
}
|
||||
|
||||
export async function generateImage(
|
||||
prompt: string,
|
||||
model: string,
|
||||
args: CliArgs
|
||||
args: CliArgs,
|
||||
): Promise<Uint8Array> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
@@ -61,20 +312,13 @@ export async function generateImage(
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = getBaseUrl();
|
||||
const size = getSeedreamSize(args.aspectRatio, args.quality, args.imageSize);
|
||||
validateArgs(model, args);
|
||||
const imageInput = await buildImageInput(model, args.referenceImages);
|
||||
const requestBody = buildRequestBody(prompt, model, args, imageInput);
|
||||
|
||||
console.error(`Calling Seedream API (${model}) with size: ${size}`);
|
||||
console.error(`Calling Seedream API (${model}) with size: ${requestBody.size}`);
|
||||
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt,
|
||||
size,
|
||||
output_format: "png",
|
||||
watermark: false,
|
||||
};
|
||||
|
||||
const response = await fetch(`${baseUrl}/images/generations`, {
|
||||
const response = await fetch(`${getBaseUrl()}/images/generations`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -89,23 +333,9 @@ export async function generateImage(
|
||||
}
|
||||
|
||||
const result = (await response.json()) as SeedreamImageResponse;
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
throw new Error("No image data in Seedream response");
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || "Seedream API returned an error");
|
||||
}
|
||||
|
||||
const imageUrl = result.data[0].url;
|
||||
if (!imageUrl) {
|
||||
throw new Error("No image URL in Seedream response");
|
||||
}
|
||||
|
||||
// Download image from URL
|
||||
console.error(`Downloading image from ${imageUrl}...`);
|
||||
const imgResponse = await fetch(imageUrl);
|
||||
if (!imgResponse.ok) {
|
||||
throw new Error(`Failed to download image from ${imageUrl}`);
|
||||
}
|
||||
|
||||
const buffer = await imgResponse.arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
return extractImageFromResponse(result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||
|
||||
const render = (md: string) => {
|
||||
const r = initRenderer();
|
||||
return renderMarkdown(md, r).html;
|
||||
};
|
||||
|
||||
test("bold with inline code (no underscore)", () => {
|
||||
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code (contains underscore)", () => {
|
||||
const html = render("**变成 `input_ids`。**");
|
||||
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code", () => {
|
||||
const html = render("*查看 `hidden_states`*");
|
||||
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||
});
|
||||
|
||||
test("plain inline code (regression)", () => {
|
||||
const html = render("`lm_head`");
|
||||
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||
});
|
||||
|
||||
test("bold without code (regression)", () => {
|
||||
const html = render("**纯粗体文本**");
|
||||
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||
assert.doesNotMatch(html, /<code/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing backticks", () => {
|
||||
const html = render("**``a`b``**");
|
||||
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only spaces", () => {
|
||||
const oneSpace = render("**`` ``**");
|
||||
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||
|
||||
const twoSpaces = render("**`` ``**");
|
||||
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||
});
|
||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
function wrapInlineCode(value: string): string {
|
||||
const runs = value.match(/`+/g);
|
||||
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||
const padding = /^ *$/.test(value) ? "" : " ";
|
||||
return `${fence}${padding}${value}${padding}${fence}`;
|
||||
}
|
||||
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
||||
const tree = processor.parse(markdown);
|
||||
const extractText = (node: any): string => {
|
||||
if (node.type === "text") return node.value;
|
||||
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||
if (node.children) return node.children.map(extractText).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
+136
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
@@ -153,6 +189,106 @@ test("findExistingChromeDebugPort reads DevToolsActivePort and validates it agai
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
|
||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||
|
||||
const render = (md: string) => {
|
||||
const r = initRenderer();
|
||||
return renderMarkdown(md, r).html;
|
||||
};
|
||||
|
||||
test("bold with inline code (no underscore)", () => {
|
||||
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code (contains underscore)", () => {
|
||||
const html = render("**变成 `input_ids`。**");
|
||||
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code", () => {
|
||||
const html = render("*查看 `hidden_states`*");
|
||||
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||
});
|
||||
|
||||
test("plain inline code (regression)", () => {
|
||||
const html = render("`lm_head`");
|
||||
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||
});
|
||||
|
||||
test("bold without code (regression)", () => {
|
||||
const html = render("**纯粗体文本**");
|
||||
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||
assert.doesNotMatch(html, /<code/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing backticks", () => {
|
||||
const html = render("**``a`b``**");
|
||||
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only spaces", () => {
|
||||
const oneSpace = render("**`` ``**");
|
||||
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||
|
||||
const twoSpaces = render("**`` ``**");
|
||||
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||
});
|
||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
function wrapInlineCode(value: string): string {
|
||||
const runs = value.match(/`+/g);
|
||||
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||
const padding = /^ *$/.test(value) ? "" : " ";
|
||||
return `${fence}${padding}${value}${padding}${fence}`;
|
||||
}
|
||||
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
||||
const tree = processor.parse(markdown);
|
||||
const extractText = (node: any): string => {
|
||||
if (node.type === "text") return node.value;
|
||||
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||
if (node.children) return node.children.map(extractText).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -51,32 +51,42 @@ async function waitForElement(session: ChromeSession, selector: string, timeoutM
|
||||
return false;
|
||||
}
|
||||
|
||||
async function clickMenuByText(session: ChromeSession, text: string): Promise<void> {
|
||||
async function clickMenuByText(session: ChromeSession, text: string, maxRetries = 5): Promise<void> {
|
||||
console.log(`[wechat] Clicking "${text}" menu...`);
|
||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||
for (const item of items) {
|
||||
const title = item.querySelector('.new-creation__menu-title');
|
||||
if (title && title.textContent?.trim() === '${text}') {
|
||||
item.scrollIntoView({ block: 'center' });
|
||||
const rect = item.getBoundingClientRect();
|
||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const items = document.querySelectorAll('.new-creation__menu .new-creation__menu-item');
|
||||
for (const item of items) {
|
||||
const title = item.querySelector('.new-creation__menu-title');
|
||||
if (title && title.textContent?.trim() === '${text}') {
|
||||
item.scrollIntoView({ block: 'center' });
|
||||
const rect = item.getBoundingClientRect();
|
||||
return JSON.stringify({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'null';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
return 'null';
|
||||
})()
|
||||
`,
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
|
||||
if (posResult.result.value === 'null') throw new Error(`Menu "${text}" not found`);
|
||||
const pos = JSON.parse(posResult.result.value);
|
||||
if (posResult.result.value !== 'null') {
|
||||
const pos = JSON.parse(posResult.result.value);
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
await sleep(100);
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
return;
|
||||
}
|
||||
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
await sleep(100);
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
if (attempt < maxRetries) {
|
||||
const delay = Math.min(1000 * attempt, 3000);
|
||||
console.log(`[wechat] Menu "${text}" not found, retrying in ${delay}ms (${attempt}/${maxRetries})...`);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
throw new Error(`Menu "${text}" not found after ${maxRetries} attempts`);
|
||||
}
|
||||
|
||||
async function copyImageToClipboard(imagePath: string): Promise<void> {
|
||||
@@ -495,10 +505,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
if (!loggedIn) throw new Error('Login timeout');
|
||||
}
|
||||
console.log('[wechat] Logged in.');
|
||||
await sleep(2000);
|
||||
await sleep(5000);
|
||||
|
||||
// Wait for menu to be ready
|
||||
const menuReady = await waitForElement(session, '.new-creation__menu', 20_000);
|
||||
const menuReady = await waitForElement(session, '.new-creation__menu', 40_000);
|
||||
if (!menuReady) throw new Error('Home page menu did not load');
|
||||
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
@@ -517,16 +527,21 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
await cdp.send('DOM.enable', {}, { sessionId });
|
||||
|
||||
await sleep(3000);
|
||||
// Wait for editor elements to fully load
|
||||
console.log('[wechat] Waiting for editor to load...');
|
||||
const editorLoaded = await waitForElement(session, '#title', 30_000);
|
||||
if (!editorLoaded) throw new Error('Editor did not load (#title not found)');
|
||||
await waitForElement(session, '.ProseMirror', 15_000);
|
||||
await sleep(2000);
|
||||
|
||||
if (effectiveTitle) {
|
||||
console.log('[wechat] Filling title...');
|
||||
await evaluate(session, `document.querySelector('#title').value = ${JSON.stringify(effectiveTitle)}; document.querySelector('#title').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
await evaluate(session, `(function() { const el = document.querySelector('#title'); el.focus(); el.value = ${JSON.stringify(effectiveTitle)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
}
|
||||
|
||||
if (effectiveAuthor) {
|
||||
console.log('[wechat] Filling author...');
|
||||
await evaluate(session, `document.querySelector('#author').value = ${JSON.stringify(effectiveAuthor)}; document.querySelector('#author').dispatchEvent(new Event('input', { bubbles: true }));`);
|
||||
await evaluate(session, `(function() { const el = document.querySelector('#author'); el.focus(); el.value = ${JSON.stringify(effectiveAuthor)}; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); })()`);
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
|
||||
+136
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
@@ -153,6 +189,106 @@ test("findExistingChromeDebugPort reads DevToolsActivePort and validates it agai
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
|
||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { initRenderer, renderMarkdown } from "./renderer.ts";
|
||||
|
||||
const render = (md: string) => {
|
||||
const r = initRenderer();
|
||||
return renderMarkdown(md, r).html;
|
||||
};
|
||||
|
||||
test("bold with inline code (no underscore)", () => {
|
||||
const html = render("**算出 `logits`,算出 `loss`。**");
|
||||
assert.match(html, /<code[^>]*>logits<\/code>/);
|
||||
assert.match(html, /<code[^>]*>loss<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code (contains underscore)", () => {
|
||||
const html = render("**变成 `input_ids`。**");
|
||||
assert.match(html, /<code[^>]*>input_ids<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code", () => {
|
||||
const html = render("*查看 `hidden_states`*");
|
||||
assert.match(html, /<code[^>]*>hidden_states<\/code>/);
|
||||
});
|
||||
|
||||
test("plain inline code (regression)", () => {
|
||||
const html = render("`lm_head`");
|
||||
assert.match(html, /<code[^>]*>lm_head<\/code>/);
|
||||
});
|
||||
|
||||
test("bold without code (regression)", () => {
|
||||
const html = render("**纯粗体文本**");
|
||||
assert.match(html, /<strong[^>]*>纯粗体文本<\/strong>/);
|
||||
assert.doesNotMatch(html, /<code/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing backticks", () => {
|
||||
const html = render("**``a`b``**");
|
||||
assert.match(html, /<code[^>]*>a`b<\/code>/);
|
||||
});
|
||||
|
||||
test("emphasis with inline code containing backticks", () => {
|
||||
const html = render("*``a`b``*");
|
||||
assert.match(html, /<em[^>]*><code[^>]*>a`b<\/code><\/em>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing consecutive backticks", () => {
|
||||
const html = render("**```a``b```**");
|
||||
assert.match(html, /<code[^>]*>a``b<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only backticks", () => {
|
||||
const html = render("**```` `` ````**");
|
||||
assert.match(html, /<code[^>]*>``<\/code>/);
|
||||
});
|
||||
|
||||
test("bold with inline code containing only spaces", () => {
|
||||
const oneSpace = render("**`` ``**");
|
||||
assert.match(oneSpace, /<code[^>]*> <\/code>/);
|
||||
|
||||
const twoSpaces = render("**`` ``**");
|
||||
assert.match(twoSpaces, /<code[^>]*> <\/code>/);
|
||||
});
|
||||
@@ -109,6 +109,13 @@ function parseFrontMatterAndContent(markdownText: string): ParseResult {
|
||||
}
|
||||
}
|
||||
|
||||
function wrapInlineCode(value: string): string {
|
||||
const runs = value.match(/`+/g);
|
||||
const fence = "`".repeat(Math.max(...(runs?.map((run) => run.length) ?? [0])) + 1);
|
||||
const padding = /^ *$/.test(value) ? "" : " ";
|
||||
return `${fence}${padding}${value}${padding}${fence}`;
|
||||
}
|
||||
|
||||
export function initRenderer(opts: IOpts = {}): RendererAPI {
|
||||
const footnotes: [number, string, string][] = [];
|
||||
let footnoteIndex = 0;
|
||||
@@ -369,6 +376,7 @@ function preprocessCjkEmphasis(markdown: string): string {
|
||||
const tree = processor.parse(markdown);
|
||||
const extractText = (node: any): string => {
|
||||
if (node.type === "text") return node.value;
|
||||
if (node.type === "inlineCode") return wrapInlineCode(node.value);
|
||||
if (node.children) return node.children.map(extractText).join("");
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
@@ -153,6 +189,106 @@ test("findExistingChromeDebugPort reads DevToolsActivePort and validates it agai
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
|
||||
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
+136
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
@@ -7,9 +8,11 @@ import process from "node:process";
|
||||
import test, { type TestContext } from "node:test";
|
||||
|
||||
import {
|
||||
discoverRunningChromeDebugPort,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
waitForChromeDebugPort,
|
||||
} from "./index.ts";
|
||||
@@ -74,6 +77,39 @@ async function closeServer(server: http.Server): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function shellPathForPlatform(): string | null {
|
||||
if (process.platform === "win32") return null;
|
||||
return "/bin/bash";
|
||||
}
|
||||
|
||||
async function startFakeChromiumProcess(port: number): Promise<ChildProcess | null> {
|
||||
const shell = shellPathForPlatform();
|
||||
if (!shell) return null;
|
||||
|
||||
const child = spawn(
|
||||
shell,
|
||||
[
|
||||
"-lc",
|
||||
`exec -a chromium-mock ${JSON.stringify(process.execPath)} -e 'setInterval(() => {}, 1000)' -- --remote-debugging-port=${port}`,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
return child;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) return;
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise((resolve) => child.once("exit", resolve));
|
||||
}
|
||||
|
||||
test("getFreePort honors a fixed environment override and otherwise allocates a TCP port", async (t) => {
|
||||
useEnv(t, { TEST_FIXED_PORT: "45678" });
|
||||
assert.equal(await getFreePort("TEST_FIXED_PORT"), 45678);
|
||||
@@ -153,6 +189,106 @@ test("findExistingChromeDebugPort reads DevToolsActivePort and validates it agai
|
||||
assert.equal(found, port);
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort reads DevToolsActivePort from the provided user-data dir", async (t) => {
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
await fs.writeFile(path.join(root, "DevToolsActivePort"), `${port}\n/devtools/browser/demo\n`);
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(found, {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}/devtools/browser/demo`,
|
||||
});
|
||||
});
|
||||
|
||||
test("discoverRunningChromeDebugPort ignores unrelated debugging processes", async (t) => {
|
||||
if (process.platform === "win32") {
|
||||
t.skip("Process discovery fallback is not used on Windows.");
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await makeTempDir("baoyu-cdp-user-data-");
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startDebugServer(port);
|
||||
t.after(() => closeServer(server));
|
||||
|
||||
const fakeChromium = await startFakeChromiumProcess(port);
|
||||
t.after(async () => { await stopProcess(fakeChromium); });
|
||||
|
||||
const found = await discoverRunningChromeDebugPort({
|
||||
userDataDirs: [root],
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
assert.equal(found, null);
|
||||
});
|
||||
|
||||
test("openPageSession reports whether it created a new target", async () => {
|
||||
const calls: string[] = [];
|
||||
const cdpExisting = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
calls.push(method);
|
||||
if (method === "Target.getTargets") {
|
||||
return {
|
||||
targetInfos: [{ targetId: "existing-target", type: "page", url: "https://gemini.google.com/app" }],
|
||||
} as T;
|
||||
}
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-existing" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const existing = await openPageSession({
|
||||
cdp: cdpExisting as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(existing, {
|
||||
sessionId: "session-existing",
|
||||
targetId: "existing-target",
|
||||
createdTarget: false,
|
||||
});
|
||||
assert.deepEqual(calls, ["Target.getTargets", "Target.attachToTarget"]);
|
||||
|
||||
const createCalls: string[] = [];
|
||||
const cdpCreated = {
|
||||
send: async <T>(method: string): Promise<T> => {
|
||||
createCalls.push(method);
|
||||
if (method === "Target.getTargets") return { targetInfos: [] } as T;
|
||||
if (method === "Target.createTarget") return { targetId: "created-target" } as T;
|
||||
if (method === "Target.attachToTarget") return { sessionId: "session-created" } as T;
|
||||
throw new Error(`Unexpected method: ${method}`);
|
||||
},
|
||||
};
|
||||
|
||||
const created = await openPageSession({
|
||||
cdp: cdpCreated as never,
|
||||
reusing: false,
|
||||
url: "https://gemini.google.com/app",
|
||||
matchTarget: (target) => target.url.includes("gemini.google.com"),
|
||||
activateTarget: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(created, {
|
||||
sessionId: "session-created",
|
||||
targetId: "created-target",
|
||||
createdTarget: true,
|
||||
});
|
||||
assert.deepEqual(createCalls, ["Target.getTargets", "Target.createTarget", "Target.attachToTarget"]);
|
||||
});
|
||||
|
||||
test("waitForChromeDebugPort retries until the debug endpoint becomes available", async (t) => {
|
||||
const port = await getFreePort();
|
||||
|
||||
|
||||
+123
-8
@@ -43,6 +43,19 @@ type FindExistingChromeDebugPortOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
|
||||
|
||||
export type DiscoveredChrome = {
|
||||
port: number;
|
||||
wsUrl: string;
|
||||
};
|
||||
|
||||
type DiscoverRunningChromeOptions = {
|
||||
channels?: ChromeChannel[];
|
||||
userDataDirs?: string[];
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
@@ -73,6 +86,7 @@ type OpenPageSessionOptions = {
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
createdTarget: boolean;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
@@ -173,16 +187,32 @@ async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolea
|
||||
}
|
||||
}
|
||||
|
||||
function isPortListening(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
|
||||
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once("error", () => { clearTimeout(timer); resolve(false); });
|
||||
socket.connect(port, "127.0.0.1");
|
||||
});
|
||||
}
|
||||
|
||||
function parseDevToolsActivePort(filePath: string): { port: number; wsPath: string } | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(lines[0]?.trim() ?? "", 10);
|
||||
const wsPath = lines[1]?.trim();
|
||||
if (port > 0 && wsPath) return { port, wsPath };
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
const parsed = parseDevToolsActivePort(path.join(options.profileDir, "DevToolsActivePort"));
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
if (parsed && parsed.port > 0 && await isDebugPortReady(parsed.port, timeoutMs)) return parsed.port;
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
@@ -204,6 +234,88 @@ export async function findExistingChromeDebugPort(options: FindExistingChromeDeb
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultChromeUserDataDirs(channels: ChromeChannel[] = ["stable"]): string[] {
|
||||
const home = os.homedir();
|
||||
const dirs: string[] = [];
|
||||
|
||||
const channelDirs: Record<string, { darwin: string; linux: string; win32: string }> = {
|
||||
stable: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome"),
|
||||
linux: path.join(home, ".config", "google-chrome"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome", "User Data"),
|
||||
},
|
||||
beta: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Beta"),
|
||||
linux: path.join(home, ".config", "google-chrome-beta"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Beta", "User Data"),
|
||||
},
|
||||
canary: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Canary"),
|
||||
linux: path.join(home, ".config", "google-chrome-canary"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome SxS", "User Data"),
|
||||
},
|
||||
dev: {
|
||||
darwin: path.join(home, "Library", "Application Support", "Google", "Chrome Dev"),
|
||||
linux: path.join(home, ".config", "google-chrome-dev"),
|
||||
win32: path.join(process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "Google", "Chrome Dev", "User Data"),
|
||||
},
|
||||
};
|
||||
|
||||
const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "win32" : "linux";
|
||||
|
||||
for (const ch of channels) {
|
||||
const entry = channelDirs[ch];
|
||||
if (entry) dirs.push(entry[platform]);
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Best-effort reuse of an already-running local CDP session discovered from
|
||||
// known Chrome user-data dirs. This is distinct from Chrome DevTools MCP's
|
||||
// prompt-based --autoConnect flow.
|
||||
export async function discoverRunningChromeDebugPort(options: DiscoverRunningChromeOptions = {}): Promise<DiscoveredChrome | null> {
|
||||
const channels = options.channels ?? ["stable", "beta", "canary", "dev"];
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
|
||||
const userDataDirs = (options.userDataDirs ?? getDefaultChromeUserDataDirs(channels))
|
||||
.map((dir) => path.resolve(dir));
|
||||
for (const dir of userDataDirs) {
|
||||
const parsed = parseDevToolsActivePort(path.join(dir, "DevToolsActivePort"));
|
||||
if (!parsed) continue;
|
||||
if (await isPortListening(parsed.port, timeoutMs)) {
|
||||
return { port: parsed.port, wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.wsPath}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) =>
|
||||
line.includes("--remote-debugging-port=") &&
|
||||
userDataDirs.some((dir) => line.includes(dir))
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`, { timeoutMs });
|
||||
if (version.webSocketDebuggerUrl) return { port, wsUrl: version.webSocketDebuggerUrl };
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
@@ -376,10 +488,12 @@ export function killChrome(chrome: ChildProcess): void {
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
let createdTarget = false;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
@@ -388,6 +502,7 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
createdTarget = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,5 +519,5 @@ export async function openPageSession(options: OpenPageSessionOptions): Promise<
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
return { sessionId, targetId, createdTarget };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user