Compare commits

...

13 Commits

Author SHA1 Message Date
Jim Liu 宝玉 4be6f3682a chore: sync vendored baoyu-chrome-cdp across all skills 2026-03-16 12:54:36 -05:00
Jim Liu 宝玉 b89ef02221 chore(baoyu-post-to-wechat): sync vendored baoyu-chrome-cdp 2026-03-16 12:53:51 -05:00
Jim Liu 宝玉 55356c8820 chore: release v1.69.0 2026-03-16 12:53:25 -05:00
Jim Liu 宝玉 f23d4eebc3 docs(baoyu-cover-image): clarify people reference image workflow and interactive confirmation 2026-03-16 12:52:16 -05:00
Jim Liu 宝玉 3b795b6ef3 Merge pull request #92 from bviews/main
feat(baoyu-chrome-cdp): support Chrome 146 auto-connect
2026-03-16 12:40:22 -05:00
Jim Liu 宝玉 c62d9d5a1b Merge pull request #94 from cfh-7598/fix/wechat-article-reliability
fix(baoyu-post-to-wechat): improve browser publishing reliability
2026-03-16 12:32:49 -05:00
cfh 84c56b0da3 fix(baoyu-post-to-wechat): improve browser publishing reliability
- Add retry logic (5 attempts with progressive backoff) to clickMenuByText
  to handle slow-loading home page menus
- Increase post-login wait from 2s to 5s and menu timeout from 20s to 40s
- Replace fixed 3s sleep after editor tab opens with waitForElement polling
  for #title (30s) and .ProseMirror (15s) to reliably wait for full load
- Improve title/author filling with focus() and change event dispatch
  for more reliable value setting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:43:39 +08:00
Alec Jiang 462d080a0e fix(gemini-web): also respect BAOYU_CHROME_PROFILE_DIR in auto-discovery skip
Both GEMINI_WEB_CHROME_PROFILE_DIR and BAOYU_CHROME_PROFILE_DIR are
valid profile overrides (see resolveGeminiWebChromeProfileDir). Skip
existing Chrome auto-discovery when either is set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:02:58 +08:00
Alec Jiang bd01c370c9 fix(gemini-web): respect explicit profile config over auto-discovery
Skip existing Chrome auto-discovery when GEMINI_WEB_CHROME_PROFILE_DIR
is explicitly set, to avoid binding to the wrong browser profile/account.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:39:53 +08:00
Alec Jiang b7bcc8c094 fix(baoyu-chrome-cdp): keep HTTP validation in findExistingChromeDebugPort
findExistingChromeDebugPort callers (fetch_google_cookies_via_cdp) depend
on /json/version being available. TCP-only check could misclassify
non-CDP listeners as reusable, causing waitForChromeDebugPort to timeout
instead of falling back to launching a new Chrome. Restore isDebugPortReady
(HTTP) as the validator for this function; TCP-only check remains in
discoverRunningChromeDebugPort for Chrome 146 approval mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:11:37 +08:00
Alec Jiang d9f9da639d fix(baoyu-chrome-cdp): support Chrome 146 native remote debugging (approval mode)
Chrome 146+ blocks all HTTP endpoints (/json/version) in approval mode.
Read DevToolsActivePort ws path directly and use TCP port check instead
of HTTP discovery. Add WebSocket connect retry loop for approval dialog.
Unify findExistingChromeDebugPort to use the same mechanism.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 18:00:58 +08:00
bviews 126b3040e6 fix(gemini-web): reuse openPageSession and fix orphaned tab leak
- Replace manual CDP target orchestration with openPageSession, keeping
  behavior consistent with fetch_google_cookies_via_cdp
- Move created-tab cleanup into finally block so tabs are always closed
  even when Target.attachToTarget or Network.enable throws

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:44:53 +08:00
bviews 682888cc95 feat(baoyu-chrome-cdp): support connecting to existing Chrome session
Add ability to discover and connect to an already-running Chrome browser
(v144+) for cookie extraction, avoiding the need to launch a new window
and re-login. Uses Chrome's DevToolsActivePort from default user data
directories and process scanning as discovery mechanisms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:51:57 +08:00
14 changed files with 959 additions and 80 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.68.0"
"version": "1.69.0"
},
"plugins": [
{
+16
View File
@@ -2,6 +2,22 @@
English | [中文](./CHANGELOG.zh.md)
## 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
+16
View File
@@ -2,6 +2,22 @@
[English](./CHANGELOG.md) | 中文
## 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
### 新功能
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.68.0**.
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.69.0**.
## Architecture
+110 -7
View File
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,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 +232,81 @@ 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;
}
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 = getDefaultChromeUserDataDirs(channels);
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=") && /chrome|chromium/i.test(line));
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,
+13 -2
View File
@@ -159,13 +159,24 @@ if (Test-Path "$HOME/.baoyu-skills/baoyu-cover-image/EXTEND.md") { "user" }
2. **Save source content** (if pasted, save to `source.md`)
3. **Analyze content**: topic, tone, keywords, visual metaphors
4. **Deep analyze references** ⚠️: Extract specific, concrete elements (see reference-images.md)
- If references contain **people** → set `usage: direct` so model sees reference image, describe character features for stylized preservation (see reference-images.md § Character Analysis)
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 |
|-----------|---------|-------------|
@@ -2,6 +2,7 @@ import process from 'node:process';
import {
CdpConnection,
discoverRunningChromeDebugPort,
findChromeExecutable as findChromeExecutableBase,
findExistingChromeDebugPort,
getFreePort,
@@ -97,6 +98,92 @@ 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 existing Chrome on port ${discovered.port}. Connecting via WebSocket...`);
let cdp: CdpConnection | null = null;
let createdTab = false;
let targetId: string | null = null;
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 targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
const hasGeminiTab = targets.targetInfos.some(
(t) => t.type === 'page' && t.url.includes('gemini.google.com'),
);
createdTab = !hasGeminiTab;
if (verbose) logger.debug(hasGeminiTab ? 'Found existing Gemini tab, attaching...' : 'No Gemini tab found, creating new tab...');
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;
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: ${e instanceof Error ? e.message : String(e)}`);
return null;
} finally {
if (cdp) {
if (createdTab && 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 +265,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);
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,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 +232,81 @@ 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;
}
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 = getDefaultChromeUserDataDirs(channels);
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=") && /chrome|chromium/i.test(line));
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,
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,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 +232,81 @@ 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;
}
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 = getDefaultChromeUserDataDirs(channels);
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=") && /chrome|chromium/i.test(line));
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,
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,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 +232,81 @@ 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;
}
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 = getDefaultChromeUserDataDirs(channels);
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=") && /chrome|chromium/i.test(line));
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,
@@ -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);
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,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 +232,81 @@ 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;
}
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 = getDefaultChromeUserDataDirs(channels);
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=") && /chrome|chromium/i.test(line));
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,
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,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 +232,81 @@ 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;
}
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 = getDefaultChromeUserDataDirs(channels);
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=") && /chrome|chromium/i.test(line));
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,
@@ -43,6 +43,18 @@ type FindExistingChromeDebugPortOptions = {
timeoutMs?: number;
};
export type ChromeChannel = "stable" | "beta" | "canary" | "dev";
export type DiscoveredChrome = {
port: number;
wsUrl: string;
};
type DiscoverRunningChromeOptions = {
channels?: ChromeChannel[];
timeoutMs?: number;
};
type LaunchChromeOptions = {
chromePath: string;
profileDir: string;
@@ -173,16 +185,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 +232,81 @@ 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;
}
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 = getDefaultChromeUserDataDirs(channels);
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=") && /chrome|chromium/i.test(line));
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,