mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-31 22:29:48 +08:00
refactor: unify skill cdp and release artifacts
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-danger-gemini-web-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
}
|
||||
}
|
||||
+92
-196
@@ -1,183 +1,76 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import process from 'node:process';
|
||||
|
||||
import {
|
||||
CdpConnection,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
killChrome,
|
||||
launchChrome as launchChromeBase,
|
||||
openPageSession,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
type PlatformCandidates,
|
||||
} from 'baoyu-chrome-cdp';
|
||||
|
||||
import { Endpoint, Headers } from '../constants.js';
|
||||
import { logger } from './logger.js';
|
||||
import { cookie_header, fetch_with_timeout, sleep } from './http.js';
|
||||
import { cookie_header, fetch_with_timeout } from './http.js';
|
||||
import { read_cookie_file, type CookieMap, write_cookie_file } from './cookie-file.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
||||
const GEMINI_APP_URL = 'https://gemini.google.com/app';
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
||||
>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (msg.id) {
|
||||
const p = this.pending.get(msg.id);
|
||||
if (p) {
|
||||
this.pending.delete(msg.id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
if (msg.error?.message) p.reject(new Error(msg.error.message));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, p] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
p.reject(new Error('CDP connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error('CDP connection failed.'));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, opts?: CdpSendOptions): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const msg: Record<string, unknown> = { id, method };
|
||||
if (params) msg.params = params;
|
||||
if (opts?.sessionId) msg.sessionId = opts.sessionId;
|
||||
|
||||
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
||||
const out = await new Promise<unknown>((resolve, reject) => {
|
||||
const t =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer: t });
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
});
|
||||
return out as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
const CHROME_CANDIDATES_FULL: PlatformCandidates = {
|
||||
darwin: [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
],
|
||||
win32: [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
],
|
||||
default: [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
],
|
||||
};
|
||||
|
||||
async function get_free_port(): Promise<number> {
|
||||
const fixed = parseInt(process.env.GEMINI_WEB_DEBUG_PORT || '', 10);
|
||||
if (fixed > 0) return fixed;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
srv.on('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
srv.close(() => reject(new Error('Unable to allocate a free TCP port.')));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
return await getFreePort('GEMINI_WEB_DEBUG_PORT');
|
||||
}
|
||||
|
||||
function find_chrome_executable(): string | null {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
candidates.push(
|
||||
'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
return findChromeExecutableBase({
|
||||
candidates: CHROME_CANDIDATES_FULL,
|
||||
envNames: ['GEMINI_WEB_CHROME_PATH'],
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
async function wait_for_chrome_debug_port(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch_with_timeout(`http://127.0.0.1:${port}/json/version`, { timeout_ms: 5_000 });
|
||||
if (!res.ok) throw new Error(`status=${res.status}`);
|
||||
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
||||
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error('Chrome debug port not ready');
|
||||
async function find_existing_chrome_debug_port(profileDir: string): Promise<number | null> {
|
||||
return await findExistingChromeDebugPort({ profileDir });
|
||||
}
|
||||
|
||||
async function launch_chrome(profileDir: string, port: number): Promise<ChildProcess> {
|
||||
const chrome = find_chrome_executable();
|
||||
if (!chrome) throw new Error('Chrome executable not found.');
|
||||
async function launch_chrome(profileDir: string, port: number) {
|
||||
const chromePath = find_chrome_executable();
|
||||
if (!chromePath) throw new Error('Chrome executable not found.');
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-popup-blocking',
|
||||
'https://gemini.google.com/app',
|
||||
];
|
||||
|
||||
return spawn(chrome, args, { stdio: 'ignore' });
|
||||
return await launchChromeBase({
|
||||
chromePath,
|
||||
profileDir,
|
||||
port,
|
||||
url: GEMINI_APP_URL,
|
||||
extraArgs: ['--disable-popup-blocking'],
|
||||
});
|
||||
}
|
||||
|
||||
async function is_gemini_session_ready(cookies: CookieMap, verbose: boolean): Promise<boolean> {
|
||||
@@ -209,27 +102,33 @@ async function fetch_google_cookies_via_cdp(
|
||||
timeoutMs: number,
|
||||
verbose: boolean,
|
||||
): Promise<CookieMap> {
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await get_free_port();
|
||||
const chrome = await launch_chrome(profileDir, port);
|
||||
const existingPort = await find_existing_chrome_debug_port(profileDir);
|
||||
const reusing = existingPort !== null;
|
||||
const port = existingPort ?? await get_free_port();
|
||||
const chrome = reusing ? null : await launch_chrome(profileDir, port);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
let targetId: string | null = null;
|
||||
try {
|
||||
const wsUrl = await wait_for_chrome_debug_port(port, 30_000);
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 15_000);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', {
|
||||
url: 'https://gemini.google.com/app',
|
||||
newWindow: true,
|
||||
});
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });
|
||||
await cdp.send('Network.enable', {}, { sessionId });
|
||||
|
||||
if (verbose) {
|
||||
logger.info('Chrome opened. If needed, complete Google login in the window. Waiting for a valid Gemini session...');
|
||||
logger.info(reusing
|
||||
? `Reusing existing Chrome on port ${port}. Waiting for a valid Gemini session...`
|
||||
: 'Chrome opened. If needed, complete Google login in the window. Waiting for a valid Gemini session...');
|
||||
}
|
||||
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing,
|
||||
url: GEMINI_APP_URL,
|
||||
matchTarget: (target) => target.type === 'page' && target.url.includes('gemini.google.com'),
|
||||
enableNetwork: true,
|
||||
});
|
||||
const { sessionId } = page;
|
||||
targetId = page.targetId;
|
||||
|
||||
const start = Date.now();
|
||||
let last: CookieMap = {};
|
||||
|
||||
@@ -240,14 +139,14 @@ async function fetch_google_cookies_via_cdp(
|
||||
{ sessionId, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const m: CookieMap = {};
|
||||
for (const c of cookies) {
|
||||
if (c?.name && typeof c.value === 'string') m[c.name] = c.value;
|
||||
const cookieMap: CookieMap = {};
|
||||
for (const cookie of cookies) {
|
||||
if (cookie?.name && typeof cookie.value === 'string') cookieMap[cookie.name] = cookie.value;
|
||||
}
|
||||
|
||||
last = m;
|
||||
if (await is_gemini_session_ready(m, verbose)) {
|
||||
return m;
|
||||
last = cookieMap;
|
||||
if (await is_gemini_session_ready(cookieMap, verbose)) {
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
await sleep(1000);
|
||||
@@ -256,22 +155,19 @@ async function fetch_google_cookies_via_cdp(
|
||||
throw new Error(`Timed out waiting for a valid Gemini session. Last keys: ${Object.keys(last).join(', ')}`);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try {
|
||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
if (reusing && targetId) {
|
||||
try {
|
||||
await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
try {
|
||||
chrome.kill('SIGTERM');
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill('SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
if (chrome) killChrome(chrome);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +182,8 @@ export async function load_browser_cookies(domain_name: string = '', verbose: bo
|
||||
const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
|
||||
|
||||
const filtered: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(cookies)) {
|
||||
if (typeof v === 'string' && v.length > 0) filtered[k] = v;
|
||||
for (const [key, value] of Object.entries(cookies)) {
|
||||
if (typeof value === 'string' && value.length > 0) filtered[key] = value;
|
||||
}
|
||||
|
||||
await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp');
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "baoyu-danger-gemini-web-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-danger-x-to-markdown-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import {
|
||||
CdpConnection,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
killChrome,
|
||||
launchChrome as launchChromeBase,
|
||||
openPageSession,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
type PlatformCandidates,
|
||||
} from "baoyu-chrome-cdp";
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
import { read_cookie_file, write_cookie_file } from "./cookie-file.js";
|
||||
@@ -9,201 +18,47 @@ import { resolveXToMarkdownCookiePath } from "./paths.js";
|
||||
import { X_COOKIE_NAMES, X_REQUIRED_COOKIES, X_LOGIN_URL, X_USER_DATA_DIR } from "./constants.js";
|
||||
import type { CookieLike } from "./types.js";
|
||||
|
||||
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit & { timeoutMs?: number } = {}
|
||||
): Promise<Response> {
|
||||
const { timeoutMs, ...rest } = init;
|
||||
if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest);
|
||||
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...rest, signal: ctl.signal });
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
||||
>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const data =
|
||||
typeof event.data === "string"
|
||||
? event.data
|
||||
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (msg.id) {
|
||||
const p = this.pending.get(msg.id);
|
||||
if (p) {
|
||||
this.pending.delete(msg.id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
if (msg.error?.message) p.reject(new Error(msg.error.message));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
this.ws.addEventListener("close", () => {
|
||||
for (const [id, p] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
p.reject(new Error("CDP connection closed."));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error("CDP connection failed."));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
async send<T = unknown>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
opts?: CdpSendOptions
|
||||
): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const msg: Record<string, unknown> = { id, method };
|
||||
if (params) msg.params = params;
|
||||
if (opts?.sessionId) msg.sessionId = opts.sessionId;
|
||||
|
||||
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
||||
const out = await new Promise<unknown>((resolve, reject) => {
|
||||
const t =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer: t });
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
});
|
||||
return out as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const fixed = parseInt(process.env.X_DEBUG_PORT || "", 10);
|
||||
if (fixed > 0) return fixed;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
srv.on("error", reject);
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
srv.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
const CHROME_CANDIDATES_FULL: PlatformCandidates = {
|
||||
darwin: [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
],
|
||||
win32: [
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
],
|
||||
default: [
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/microsoft-edge",
|
||||
],
|
||||
};
|
||||
|
||||
function findChromeExecutable(): string | null {
|
||||
const override = process.env.X_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
candidates.push(
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
||||
);
|
||||
break;
|
||||
case "win32":
|
||||
candidates.push(
|
||||
"C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe",
|
||||
"C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe",
|
||||
"C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe",
|
||||
"C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe"
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/microsoft-edge"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
return findChromeExecutableBase({
|
||||
candidates: CHROME_CANDIDATES_FULL,
|
||||
envNames: ["X_CHROME_PATH"],
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 });
|
||||
if (!res.ok) throw new Error(`status=${res.status}`);
|
||||
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
||||
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error("Chrome debug port not ready");
|
||||
}
|
||||
|
||||
async function launchChrome(profileDir: string, port: number): Promise<ChildProcess> {
|
||||
const chrome = findChromeExecutable();
|
||||
if (!chrome) throw new Error("Chrome executable not found.");
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-popup-blocking",
|
||||
X_LOGIN_URL,
|
||||
];
|
||||
|
||||
return spawn(chrome, args, { stdio: "ignore" });
|
||||
async function launchChrome(profileDir: string, port: number) {
|
||||
const chromePath = findChromeExecutable();
|
||||
if (!chromePath) throw new Error("Chrome executable not found.");
|
||||
return await launchChromeBase({
|
||||
chromePath,
|
||||
profileDir,
|
||||
port,
|
||||
url: X_LOGIN_URL,
|
||||
extraArgs: ["--disable-popup-blocking"],
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchXCookiesViaCdp(
|
||||
@@ -212,25 +67,33 @@ async function fetchXCookiesViaCdp(
|
||||
verbose: boolean,
|
||||
log?: (message: string) => void
|
||||
): Promise<Record<string, string>> {
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
const chrome = await launchChrome(profileDir, port);
|
||||
const existingPort = await findExistingChromeDebugPort({ profileDir });
|
||||
const reusing = existingPort !== null;
|
||||
const port = existingPort ?? await getFreePort("X_DEBUG_PORT");
|
||||
const chrome = reusing ? null : await launchChrome(profileDir, port);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
let targetId: string | null = null;
|
||||
try {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 15_000);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>("Target.createTarget", {
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing,
|
||||
url: X_LOGIN_URL,
|
||||
newWindow: true,
|
||||
matchTarget: (target) => target.type === "page" && (
|
||||
target.url.includes("x.com") || target.url.includes("twitter.com")
|
||||
),
|
||||
enableNetwork: true,
|
||||
});
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true });
|
||||
await cdp.send("Network.enable", {}, { sessionId });
|
||||
const { sessionId } = page;
|
||||
targetId = page.targetId;
|
||||
|
||||
if (verbose) {
|
||||
log?.("[x-cookies] Chrome opened. If needed, complete X login in the window. Waiting for cookies...");
|
||||
log?.(reusing
|
||||
? `[x-cookies] Reusing existing Chrome on port ${port}. Waiting for cookies...`
|
||||
: "[x-cookies] Chrome opened. If needed, complete X login in the window. Waiting for cookies...");
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
@@ -255,22 +118,19 @@ async function fetchXCookiesViaCdp(
|
||||
throw new Error(`Timed out waiting for X cookies. Last keys: ${Object.keys(last).join(", ")}`);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try {
|
||||
await cdp.send("Browser.close", {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
if (reusing && targetId) {
|
||||
try {
|
||||
await cdp.send("Target.closeTarget", { targetId }, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
await cdp.send("Browser.close", {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
try {
|
||||
chrome.kill("SIGTERM");
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill("SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
if (chrome) killChrome(chrome);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "baoyu-danger-x-to-markdown-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-post-to-wechat-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
}
|
||||
}
|
||||
@@ -1,172 +1,83 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execSync, type ChildProcess } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
import {
|
||||
CdpConnection,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort as findExistingChromeDebugPortBase,
|
||||
getFreePort as getFreePortBase,
|
||||
launchChrome as launchChromeBase,
|
||||
resolveSharedChromeProfileDir,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
type PlatformCandidates,
|
||||
} from 'baoyu-chrome-cdp';
|
||||
|
||||
export { CdpConnection, sleep, waitForChromeDebugPort };
|
||||
|
||||
const CHROME_CANDIDATES_FULL: PlatformCandidates = {
|
||||
darwin: [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
],
|
||||
win32: [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
],
|
||||
default: [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
],
|
||||
};
|
||||
|
||||
let wslHome: string | null | undefined;
|
||||
function getWslWindowsHome(): string | null {
|
||||
if (wslHome !== undefined) return wslHome;
|
||||
if (!process.env.WSL_DISTRO_NAME) {
|
||||
wslHome = null;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5_000,
|
||||
}).trim().replace(/\r/g, '');
|
||||
wslHome = execSync(`wslpath -u "${raw}"`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5_000,
|
||||
}).trim() || null;
|
||||
} catch {
|
||||
wslHome = null;
|
||||
}
|
||||
return wslHome;
|
||||
}
|
||||
|
||||
export async function getFreePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
return await getFreePortBase('WECHAT_BROWSER_DEBUG_PORT');
|
||||
}
|
||||
|
||||
export function findChromeExecutable(chromePathOverride?: string): string | undefined {
|
||||
if (chromePathOverride?.trim()) return chromePathOverride.trim();
|
||||
return findChromeExecutableBase({
|
||||
candidates: CHROME_CANDIDATES_FULL,
|
||||
envNames: ['WECHAT_BROWSER_CHROME_PATH'],
|
||||
});
|
||||
}
|
||||
|
||||
export function findChromeExecutable(): string | undefined {
|
||||
const override = process.env.WECHAT_BROWSER_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
candidates.push(
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium');
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDefaultProfileDir(): string {
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||
}
|
||||
|
||||
export class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
|
||||
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
|
||||
|
||||
if (msg.method) {
|
||||
const handlers = this.eventHandlers.get(msg.method);
|
||||
if (handlers) handlers.forEach((h) => h(msg.params));
|
||||
}
|
||||
|
||||
if (msg.id) {
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (pending) {
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error('CDP connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
|
||||
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
|
||||
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: unknown) => void): void {
|
||||
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
|
||||
this.eventHandlers.get(method)!.add(handler);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? 15_000;
|
||||
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
return resolveSharedChromeProfileDir({
|
||||
envNames: ['BAOYU_CHROME_PROFILE_DIR', 'WECHAT_BROWSER_PROFILE_DIR'],
|
||||
wslWindowsHome: getWslWindowsHome(),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ChromeSession {
|
||||
@@ -177,56 +88,38 @@ export interface ChromeSession {
|
||||
|
||||
export async function tryConnectExisting(port: number): Promise<CdpConnection | null> {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) {
|
||||
const cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, 5_000);
|
||||
return cdp;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
const wsUrl = await waitForChromeDebugPort(port, 5_000, { includeLastError: true });
|
||||
return await CdpConnection.connect(wsUrl, 5_000);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(): Promise<number | null> {
|
||||
if (process.platform !== 'darwin' && process.platform !== 'linux') return null;
|
||||
try {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const cmd = process.platform === 'darwin'
|
||||
? `lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -i 'google\\|chrome' | awk '{print $9}' | sed 's/.*://'`
|
||||
: `ss -tlnp 2>/dev/null | grep -i chrome | awk '{print $4}' | sed 's/.*://'`;
|
||||
const output = execSync(cmd, { encoding: 'utf-8', timeout: 5_000 }).trim();
|
||||
if (!output) return null;
|
||||
const ports = output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p) && p > 0);
|
||||
for (const port of ports) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) return port;
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
export async function findExistingChromeDebugPort(profileDir = getDefaultProfileDir()): Promise<number | null> {
|
||||
return await findExistingChromeDebugPortBase({ profileDir });
|
||||
}
|
||||
|
||||
export async function launchChrome(url: string, profileDir?: string): Promise<{ cdp: CdpConnection; chrome: ReturnType<typeof spawn> }> {
|
||||
const chromePath = findChromeExecutable();
|
||||
export async function launchChrome(
|
||||
url: string,
|
||||
profileDir?: string,
|
||||
chromePathOverride?: string,
|
||||
): Promise<{ cdp: CdpConnection; chrome: ChildProcess }> {
|
||||
const chromePath = findChromeExecutable(chromePathOverride);
|
||||
if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
const profile = profileDir ?? getDefaultProfileDir();
|
||||
await mkdir(profile, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
console.log(`[cdp] Launching Chrome (profile: ${profile})`);
|
||||
|
||||
const chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profile}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
const chrome = await launchChromeBase({
|
||||
chromePath,
|
||||
profileDir: profile,
|
||||
port,
|
||||
url,
|
||||
], { stdio: 'ignore' });
|
||||
extraArgs: ['--disable-blink-features=AutomationControlled', '--start-maximized'],
|
||||
});
|
||||
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
const cdp = await CdpConnection.connect(wsUrl, 30_000);
|
||||
|
||||
return { cdp, chrome };
|
||||
@@ -234,11 +127,14 @@ export async function launchChrome(url: string, profileDir?: string): Promise<{
|
||||
|
||||
export async function getPageSession(cdp: CdpConnection, urlPattern: string): Promise<ChromeSession> {
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes(urlPattern));
|
||||
const pageTarget = targets.targetInfos.find((target) => target.type === 'page' && target.url.includes(urlPattern));
|
||||
|
||||
if (!pageTarget) throw new Error(`Page not found: ${urlPattern}`);
|
||||
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', {
|
||||
targetId: pageTarget.targetId,
|
||||
flatten: true,
|
||||
});
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
@@ -247,11 +143,20 @@ export async function getPageSession(cdp: CdpConnection, urlPattern: string): Pr
|
||||
return { cdp, sessionId, targetId: pageTarget.targetId };
|
||||
}
|
||||
|
||||
export async function waitForNewTab(cdp: CdpConnection, initialIds: Set<string>, urlPattern: string, timeoutMs = 30_000): Promise<string> {
|
||||
export async function waitForNewTab(
|
||||
cdp: CdpConnection,
|
||||
initialIds: Set<string>,
|
||||
urlPattern: string,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
const newTab = targets.targetInfos.find(t => t.type === 'page' && !initialIds.has(t.targetId) && t.url.includes(urlPattern));
|
||||
const newTab = targets.targetInfos.find((target) => (
|
||||
target.type === 'page' &&
|
||||
!initialIds.has(target.targetId) &&
|
||||
target.url.includes(urlPattern)
|
||||
));
|
||||
if (newTab) return newTab.targetId;
|
||||
await sleep(500);
|
||||
}
|
||||
@@ -259,7 +164,7 @@ export async function waitForNewTab(cdp: CdpConnection, initialIds: Set<string>,
|
||||
}
|
||||
|
||||
export async function clickElement(session: ChromeSession, selector: string): Promise<void> {
|
||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
const position = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
expression: `
|
||||
(function() {
|
||||
const el = document.querySelector('${selector}');
|
||||
@@ -272,23 +177,46 @@ export async function clickElement(session: ChromeSession, selector: string): Pr
|
||||
returnByValue: true,
|
||||
}, { sessionId: session.sessionId });
|
||||
|
||||
if (posResult.result.value === 'null') throw new Error(`Element not found: ${selector}`);
|
||||
const pos = JSON.parse(posResult.result.value);
|
||||
if (position.result.value === 'null') throw new Error(`Element not found: ${selector}`);
|
||||
const pos = JSON.parse(position.result.value);
|
||||
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
await session.cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mousePressed',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId: session.sessionId });
|
||||
await sleep(50);
|
||||
await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId });
|
||||
await session.cdp.send('Input.dispatchMouseEvent', {
|
||||
type: 'mouseReleased',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
button: 'left',
|
||||
clickCount: 1,
|
||||
}, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
export async function typeText(session: ChromeSession, text: string): Promise<void> {
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].length > 0) {
|
||||
await session.cdp.send('Input.insertText', { text: lines[i] }, { sessionId: session.sessionId });
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (line.length > 0) {
|
||||
await session.cdp.send('Input.insertText', { text: line }, { sessionId: session.sessionId });
|
||||
}
|
||||
if (i < lines.length - 1) {
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId });
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId });
|
||||
if (index < lines.length - 1) {
|
||||
await session.cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId: session.sessionId });
|
||||
await session.cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
windowsVirtualKeyCode: 13,
|
||||
}, { sessionId: session.sessionId });
|
||||
}
|
||||
await sleep(30);
|
||||
}
|
||||
@@ -296,8 +224,20 @@ export async function typeText(session: ChromeSession, text: string): Promise<vo
|
||||
|
||||
export async function pasteFromClipboard(session: ChromeSession): Promise<void> {
|
||||
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
|
||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
|
||||
await session.cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyDown',
|
||||
key: 'v',
|
||||
code: 'KeyV',
|
||||
modifiers,
|
||||
windowsVirtualKeyCode: 86,
|
||||
}, { sessionId: session.sessionId });
|
||||
await session.cdp.send('Input.dispatchKeyEvent', {
|
||||
type: 'keyUp',
|
||||
key: 'v',
|
||||
code: 'KeyV',
|
||||
modifiers,
|
||||
windowsVirtualKeyCode: 86,
|
||||
}, { sessionId: session.sessionId });
|
||||
}
|
||||
|
||||
export async function evaluate<T = unknown>(session: ChromeSession, expression: string): Promise<T> {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "baoyu-post-to-wechat-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { execSync, spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, readdir } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import {
|
||||
CdpConnection,
|
||||
findChromeExecutable,
|
||||
getDefaultProfileDir,
|
||||
launchChrome,
|
||||
sleep,
|
||||
} from './cdp.ts';
|
||||
|
||||
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||
|
||||
interface MarkdownMeta {
|
||||
@@ -104,195 +109,6 @@ async function loadImagesFromDir(dir: string): Promise<string[]> {
|
||||
return images;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const fixed = parseInt(process.env.WECHAT_BROWSER_DEBUG_PORT || '', 10);
|
||||
if (fixed > 0) return fixed;
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function findChromeExecutable(): string | undefined {
|
||||
const override = process.env.WECHAT_BROWSER_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
candidates.push(
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let _wslHome: string | null | undefined;
|
||||
function getWslWindowsHome(): string | null {
|
||||
if (_wslHome !== undefined) return _wslHome;
|
||||
if (!process.env.WSL_DISTRO_NAME) { _wslHome = null; return null; }
|
||||
try {
|
||||
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf-8', timeout: 5000 }).trim().replace(/\r/g, '');
|
||||
_wslHome = execSync(`wslpath -u "${raw}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
|
||||
} catch { _wslHome = null; }
|
||||
return _wslHome;
|
||||
}
|
||||
|
||||
function getDefaultProfileDir(): string {
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.WECHAT_BROWSER_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, '.local', 'share', 'baoyu-skills', 'chrome-profile');
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||
}
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
|
||||
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
|
||||
|
||||
if (msg.method) {
|
||||
const handlers = this.eventHandlers.get(msg.method);
|
||||
if (handlers) handlers.forEach((h) => h(msg.params));
|
||||
}
|
||||
|
||||
if (msg.id) {
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (pending) {
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error('CDP connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
|
||||
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
|
||||
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: unknown) => void): void {
|
||||
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
|
||||
this.eventHandlers.get(method)!.add(handler);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? 15_000;
|
||||
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
interface WeChatBrowserOptions {
|
||||
title?: string;
|
||||
content?: string;
|
||||
@@ -348,29 +164,18 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise<void>
|
||||
if (!fs.existsSync(img)) throw new Error(`Image not found: ${img}`);
|
||||
}
|
||||
|
||||
const chromePath = options.chromePath ?? findChromeExecutable();
|
||||
const chromePath = findChromeExecutable(options.chromePath);
|
||||
if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
console.log(`[wechat-browser] Launching Chrome (profile: ${profileDir})`);
|
||||
|
||||
const chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
WECHAT_URL,
|
||||
], { stdio: 'ignore' });
|
||||
const launched = await launchChrome(WECHAT_URL, profileDir, chromePath);
|
||||
const chrome = launched.chrome;
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
|
||||
try {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
||||
cdp = await CdpConnection.connect(wsUrl, 30_000);
|
||||
cdp = launched.cdp;
|
||||
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('mp.weixin.qq.com'));
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-post-to-weibo-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"front-matter": ["front-matter@4.0.2", "", { "dependencies": { "js-yaml": "^3.13.1" } }, "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg=="],
|
||||
|
||||
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
|
||||
|
||||
"js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "baoyu-post-to-weibo-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
CdpConnection,
|
||||
copyHtmlToClipboard,
|
||||
@@ -11,7 +9,7 @@ import {
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getDefaultProfileDir,
|
||||
getFreePort,
|
||||
launchChrome,
|
||||
pasteFromClipboard,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
@@ -74,36 +72,17 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
// Try reusing an existing Chrome instance with the same profile
|
||||
const existingPort = findExistingChromeDebugPort(profileDir);
|
||||
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||
let port: number;
|
||||
let launched = false;
|
||||
|
||||
if (existingPort) {
|
||||
console.log(`[weibo-article] Found existing Chrome on port ${existingPort}, reusing...`);
|
||||
port = existingPort;
|
||||
} else {
|
||||
const chromePath = options.chromePath ?? findChromeExecutable();
|
||||
const chromePath = findChromeExecutable(options.chromePath);
|
||||
if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
port = await getFreePort();
|
||||
console.log(`[weibo-article] Launching Chrome...`);
|
||||
const chromeArgs = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
WEIBO_ARTICLE_URL,
|
||||
];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, '');
|
||||
spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' });
|
||||
} else {
|
||||
spawn(chromePath, chromeArgs, { stdio: 'ignore' });
|
||||
}
|
||||
launched = true;
|
||||
port = await launchChrome(WEIBO_ARTICLE_URL, profileDir, chromePath);
|
||||
}
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -8,8 +7,8 @@ import {
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getDefaultProfileDir,
|
||||
getFreePort,
|
||||
killChromeByProfile,
|
||||
launchChrome as launchWeiboChrome,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
} from './weibo-utils.js';
|
||||
@@ -37,32 +36,11 @@ export async function postToWeibo(options: WeiboPostOptions): Promise<void> {
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const chromePath = options.chromePath ?? findChromeExecutable();
|
||||
const chromePath = findChromeExecutable(options.chromePath);
|
||||
if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
const launchChrome = async (): Promise<number> => {
|
||||
const port = await getFreePort();
|
||||
console.log(`[weibo-post] Launching Chrome (profile: ${profileDir})`);
|
||||
const chromeArgs = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--start-maximized',
|
||||
WEIBO_HOME_URL,
|
||||
];
|
||||
if (process.platform === 'darwin') {
|
||||
const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, '');
|
||||
spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' });
|
||||
} else {
|
||||
spawn(chromePath, chromeArgs, { stdio: 'ignore' });
|
||||
}
|
||||
return port;
|
||||
};
|
||||
|
||||
let port: number;
|
||||
const existingPort = findExistingChromeDebugPort(profileDir);
|
||||
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||
|
||||
if (existingPort) {
|
||||
console.log(`[weibo-post] Found existing Chrome on port ${existingPort}, checking health...`);
|
||||
@@ -77,10 +55,10 @@ export async function postToWeibo(options: WeiboPostOptions): Promise<void> {
|
||||
console.log('[weibo-post] Existing Chrome unresponsive, restarting...');
|
||||
killChromeByProfile(profileDir);
|
||||
await sleep(2000);
|
||||
port = await launchChrome();
|
||||
port = await launchWeiboChrome(WEIBO_HOME_URL, profileDir, chromePath);
|
||||
}
|
||||
} else {
|
||||
port = await launchChrome();
|
||||
port = await launchWeiboChrome(WEIBO_HOME_URL, profileDir, chromePath);
|
||||
}
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const CHROME_CANDIDATES = {
|
||||
import {
|
||||
CdpConnection,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort as findExistingChromeDebugPortBase,
|
||||
getFreePort as getFreePortBase,
|
||||
launchChrome as launchChromeBase,
|
||||
resolveSharedChromeProfileDir,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
type PlatformCandidates,
|
||||
} from 'baoyu-chrome-cdp';
|
||||
|
||||
export { CdpConnection, sleep, waitForChromeDebugPort };
|
||||
|
||||
export const CHROME_CANDIDATES: PlatformCandidates = {
|
||||
darwin: [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
@@ -23,183 +34,81 @@ export const CHROME_CANDIDATES = {
|
||||
],
|
||||
};
|
||||
|
||||
export function findChromeExecutable(): string | undefined {
|
||||
const override = process.env.WEIBO_BROWSER_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates = process.platform === 'darwin'
|
||||
? CHROME_CANDIDATES.darwin
|
||||
: process.platform === 'win32'
|
||||
? CHROME_CANDIDATES.win32
|
||||
: CHROME_CANDIDATES.default;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
let wslHome: string | null | undefined;
|
||||
function getWslWindowsHome(): string | null {
|
||||
if (wslHome !== undefined) return wslHome;
|
||||
if (!process.env.WSL_DISTRO_NAME) {
|
||||
wslHome = null;
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
try {
|
||||
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5_000,
|
||||
}).trim().replace(/\r/g, '');
|
||||
wslHome = execSync(`wslpath -u "${raw}"`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5_000,
|
||||
}).trim() || null;
|
||||
} catch {
|
||||
wslHome = null;
|
||||
}
|
||||
return wslHome;
|
||||
}
|
||||
|
||||
export function findExistingChromeDebugPort(profileDir: string): number | null {
|
||||
try {
|
||||
const result = spawnSync('ps', ['aux'], { encoding: 'utf-8', timeout: 5000 });
|
||||
if (result.status !== 0 || !result.stdout) return null;
|
||||
const lines = result.stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line.includes('--remote-debugging-port=') || !line.includes(profileDir)) continue;
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
if (portMatch) return Number(portMatch[1]);
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
export function findChromeExecutable(chromePathOverride?: string): string | undefined {
|
||||
if (chromePathOverride?.trim()) return chromePathOverride.trim();
|
||||
return findChromeExecutableBase({
|
||||
candidates: CHROME_CANDIDATES,
|
||||
envNames: ['WEIBO_BROWSER_CHROME_PATH'],
|
||||
});
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(profileDir: string): Promise<number | null> {
|
||||
return await findExistingChromeDebugPortBase({ profileDir });
|
||||
}
|
||||
|
||||
export function killChromeByProfile(profileDir: string): void {
|
||||
try {
|
||||
const result = spawnSync('ps', ['aux'], { encoding: 'utf-8', timeout: 5000 });
|
||||
const result = spawnSync('ps', ['aux'], { encoding: 'utf-8', timeout: 5_000 });
|
||||
if (result.status !== 0 || !result.stdout) return;
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
if (!line.includes(profileDir) || !line.includes('--remote-debugging-port=')) continue;
|
||||
const pidMatch = line.trim().split(/\s+/)[1];
|
||||
if (pidMatch) {
|
||||
try { process.kill(Number(pidMatch), 'SIGTERM'); } catch {}
|
||||
const pid = line.trim().split(/\s+/)[1];
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(Number(pid), 'SIGTERM');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function getDefaultProfileDir(): string {
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.WEIBO_BROWSER_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function getFreePort(): Promise<number> {
|
||||
const fixed = parseInt(process.env.WEIBO_BROWSER_DEBUG_PORT || '', 10);
|
||||
if (fixed > 0) return fixed;
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
return resolveSharedChromeProfileDir({
|
||||
envNames: ['BAOYU_CHROME_PROFILE_DIR', 'WEIBO_BROWSER_PROFILE_DIR'],
|
||||
wslWindowsHome: getWslWindowsHome(),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
||||
return (await res.json()) as T;
|
||||
export async function getFreePort(): Promise<number> {
|
||||
return await getFreePortBase('WEIBO_BROWSER_DEBUG_PORT');
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
export async function launchChrome(url: string, profileDir: string, chromePathOverride?: string): Promise<number> {
|
||||
const chromePath = findChromeExecutable(chromePathOverride);
|
||||
if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
export class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private defaultTimeoutMs: number;
|
||||
|
||||
private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) {
|
||||
this.ws = ws;
|
||||
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? 15_000;
|
||||
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
|
||||
|
||||
if (msg.id) {
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (pending) {
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error('CDP connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number, options?: { defaultTimeoutMs?: number }): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
|
||||
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
|
||||
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
|
||||
});
|
||||
return new CdpConnection(ws, options);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = timeoutMs > 0
|
||||
? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
const port = await getFreePort();
|
||||
console.log(`[weibo-cdp] Launching Chrome (profile: ${profileDir})`);
|
||||
await launchChromeBase({
|
||||
chromePath,
|
||||
profileDir,
|
||||
port,
|
||||
url,
|
||||
extraArgs: ['--disable-blink-features=AutomationControlled', '--start-maximized'],
|
||||
});
|
||||
return port;
|
||||
}
|
||||
|
||||
export function getScriptDir(): string {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "baoyu-post-to-x-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
@@ -27,6 +28,8 @@
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { parseMarkdown } from './md-to-html.js';
|
||||
import {
|
||||
@@ -11,9 +8,10 @@ import {
|
||||
CdpConnection,
|
||||
copyHtmlToClipboard,
|
||||
copyImageToClipboard,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getDefaultProfileDir,
|
||||
getFreePort,
|
||||
launchChrome,
|
||||
openPageSession,
|
||||
pasteFromClipboard,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
@@ -61,25 +59,6 @@ interface ArticleOptions {
|
||||
chromePath?: string;
|
||||
}
|
||||
|
||||
async function findExistingDebugPort(profileDir: string): Promise<number | null> {
|
||||
const portFile = path.join(profileDir, 'DevToolsActivePort');
|
||||
if (!fs.existsSync(portFile)) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, 'utf-8').trim();
|
||||
if (!content) return null;
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number(portLine);
|
||||
if (!Number.isFinite(port) || port <= 0) return null;
|
||||
|
||||
// Verify the port is actually active.
|
||||
await waitForChromeDebugPort(port, 1500, { includeLastError: true });
|
||||
return port;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;
|
||||
|
||||
@@ -98,31 +77,17 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
await writeFile(htmlPath, parsed.html, 'utf-8');
|
||||
console.log(`[x-article] HTML saved to: ${htmlPath}`);
|
||||
|
||||
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_BASIC);
|
||||
if (!chromePath) throw new Error('Chrome not found');
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
const existingPort = await findExistingDebugPort(profileDir);
|
||||
const port = existingPort ?? await getFreePort();
|
||||
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||
const reusing = existingPort !== null;
|
||||
let port = existingPort ?? 0;
|
||||
|
||||
if (existingPort) {
|
||||
if (reusing) {
|
||||
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
|
||||
} else {
|
||||
console.log(`[x-article] Launching Chrome...`);
|
||||
const chromeArgs = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--start-maximized',
|
||||
X_ARTICLES_URL,
|
||||
];
|
||||
if (process.platform === 'darwin') {
|
||||
const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, '');
|
||||
spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' });
|
||||
} else {
|
||||
spawn(chromePath, chromeArgs, { stdio: 'ignore' });
|
||||
}
|
||||
const launched = await launchChrome(X_ARTICLES_URL, profileDir, CHROME_CANDIDATES_BASIC, options.chromePath);
|
||||
port = launched.port;
|
||||
}
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
@@ -131,20 +96,16 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 60_000 });
|
||||
|
||||
// Get page target
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.startsWith(X_ARTICLES_URL));
|
||||
|
||||
if (!pageTarget) {
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL });
|
||||
pageTarget = { targetId, url: X_ARTICLES_URL, type: 'page' };
|
||||
}
|
||||
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
await cdp.send('DOM.enable', {}, { sessionId });
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing,
|
||||
url: X_ARTICLES_URL,
|
||||
matchTarget: (target) => target.type === 'page' && target.url.startsWith(X_ARTICLES_URL),
|
||||
enablePage: true,
|
||||
enableRuntime: true,
|
||||
enableDom: true,
|
||||
});
|
||||
const { sessionId } = page;
|
||||
|
||||
console.log('[x-article] Waiting for articles page...');
|
||||
await sleep(1000);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import process from 'node:process';
|
||||
@@ -6,9 +5,10 @@ import {
|
||||
CHROME_CANDIDATES_FULL,
|
||||
CdpConnection,
|
||||
copyImageToClipboard,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getDefaultProfileDir,
|
||||
getFreePort,
|
||||
launchChrome,
|
||||
openPageSession,
|
||||
pasteFromClipboard,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
@@ -28,22 +28,20 @@ interface XBrowserOptions {
|
||||
export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||
const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
||||
|
||||
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
|
||||
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
|
||||
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||
const reusing = existingPort !== null;
|
||||
let port = existingPort ?? 0;
|
||||
let chrome: Awaited<ReturnType<typeof launchChrome>>['chrome'] | null = null;
|
||||
if (!reusing) {
|
||||
const launched = await launchChrome(X_COMPOSE_URL, profileDir, CHROME_CANDIDATES_FULL, options.chromePath);
|
||||
port = launched.port;
|
||||
chrome = launched.chrome;
|
||||
}
|
||||
|
||||
const chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--start-maximized',
|
||||
X_COMPOSE_URL,
|
||||
], { stdio: 'ignore' });
|
||||
if (reusing) console.log(`[x-browser] Reusing existing Chrome on port ${port}`);
|
||||
else console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
|
||||
@@ -51,18 +49,15 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });
|
||||
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
||||
|
||||
if (!pageTarget) {
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL });
|
||||
pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' };
|
||||
}
|
||||
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing,
|
||||
url: X_COMPOSE_URL,
|
||||
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
|
||||
enablePage: true,
|
||||
enableRuntime: true,
|
||||
});
|
||||
const { sessionId } = page;
|
||||
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
|
||||
|
||||
console.log('[x-browser] Waiting for X editor...');
|
||||
@@ -192,7 +187,9 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||
if (cdp) {
|
||||
cdp.close();
|
||||
}
|
||||
chrome.unref();
|
||||
if (chrome) {
|
||||
chrome.unref();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import process from 'node:process';
|
||||
import {
|
||||
CHROME_CANDIDATES_FULL,
|
||||
CdpConnection,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getDefaultProfileDir,
|
||||
getFreePort,
|
||||
killChrome,
|
||||
launchChrome,
|
||||
openPageSession,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
} from './x-utils.js';
|
||||
@@ -31,42 +32,39 @@ interface QuoteOptions {
|
||||
export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||
const { tweetUrl, comment, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
||||
|
||||
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
|
||||
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
|
||||
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||
const reusing = existingPort !== null;
|
||||
let port = existingPort ?? 0;
|
||||
console.log(`[x-quote] Opening tweet: ${tweetUrl}`);
|
||||
let chrome: Awaited<ReturnType<typeof launchChrome>>['chrome'] | null = null;
|
||||
if (!reusing) {
|
||||
const launched = await launchChrome(tweetUrl, profileDir, CHROME_CANDIDATES_FULL, options.chromePath);
|
||||
port = launched.port;
|
||||
chrome = launched.chrome;
|
||||
}
|
||||
|
||||
const chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--start-maximized',
|
||||
tweetUrl,
|
||||
], { stdio: 'ignore' });
|
||||
if (reusing) console.log(`[x-quote] Reusing existing Chrome on port ${port}`);
|
||||
else console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
let targetId: string | null = null;
|
||||
|
||||
try {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });
|
||||
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
||||
|
||||
if (!pageTarget) {
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: tweetUrl });
|
||||
pageTarget = { targetId, url: tweetUrl, type: 'page' };
|
||||
}
|
||||
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing,
|
||||
url: tweetUrl,
|
||||
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
|
||||
enablePage: true,
|
||||
enableRuntime: true,
|
||||
});
|
||||
const { sessionId } = page;
|
||||
targetId = page.targetId;
|
||||
|
||||
console.log('[x-quote] Waiting for tweet to load...');
|
||||
await sleep(3000);
|
||||
@@ -175,14 +173,14 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||
}
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
|
||||
if (reusing && targetId) {
|
||||
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
|
||||
} else if (!reusing) {
|
||||
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
|
||||
}, 2_000).unref?.();
|
||||
try { chrome.kill('SIGTERM'); } catch {}
|
||||
if (chrome) killChrome(chrome);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export type PlatformCandidates = {
|
||||
darwin?: string[];
|
||||
win32?: string[];
|
||||
default: string[];
|
||||
};
|
||||
import {
|
||||
CdpConnection,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort as findExistingChromeDebugPortBase,
|
||||
getFreePort as getFreePortBase,
|
||||
killChrome,
|
||||
launchChrome as launchChromeBase,
|
||||
openPageSession,
|
||||
resolveSharedChromeProfileDir,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
type PlatformCandidates,
|
||||
} from 'baoyu-chrome-cdp';
|
||||
|
||||
export { CdpConnection, killChrome, openPageSession, sleep, waitForChromeDebugPort };
|
||||
export type { PlatformCandidates } from 'baoyu-chrome-cdp';
|
||||
|
||||
export const CHROME_CANDIDATES_BASIC: PlatformCandidates = {
|
||||
darwin: [
|
||||
@@ -53,20 +61,11 @@ export const CHROME_CANDIDATES_FULL: PlatformCandidates = {
|
||||
],
|
||||
};
|
||||
|
||||
function getCandidatesForPlatform(candidates: PlatformCandidates): string[] {
|
||||
if (process.platform === 'darwin' && candidates.darwin?.length) return candidates.darwin;
|
||||
if (process.platform === 'win32' && candidates.win32?.length) return candidates.win32;
|
||||
return candidates.default;
|
||||
}
|
||||
|
||||
export function findChromeExecutable(candidates: PlatformCandidates): string | undefined {
|
||||
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
for (const candidate of getCandidatesForPlatform(candidates)) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
return findChromeExecutableBase({
|
||||
candidates,
|
||||
envNames: ['X_BROWSER_CHROME_PATH'],
|
||||
});
|
||||
}
|
||||
|
||||
let _wslHome: string | null | undefined;
|
||||
@@ -81,158 +80,39 @@ function getWslWindowsHome(): string | null {
|
||||
}
|
||||
|
||||
export function getDefaultProfileDir(): string {
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.X_BROWSER_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, '.local', 'share', 'baoyu-skills', 'chrome-profile');
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function getFreePort(): Promise<number> {
|
||||
const fixed = parseInt(process.env.X_BROWSER_DEBUG_PORT || '', 10);
|
||||
if (fixed > 0) return fixed;
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
return resolveSharedChromeProfileDir({
|
||||
envNames: ['BAOYU_CHROME_PROFILE_DIR', 'X_BROWSER_PROFILE_DIR'],
|
||||
wslWindowsHome: getWslWindowsHome(),
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
||||
return (await res.json()) as T;
|
||||
export async function getFreePort(): Promise<number> {
|
||||
return await getFreePortBase('X_BROWSER_DEBUG_PORT');
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
options?: { includeLastError?: boolean },
|
||||
): Promise<string> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
if (options?.includeLastError && lastError) {
|
||||
throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
||||
}
|
||||
throw new Error('Chrome debug port not ready');
|
||||
export async function findExistingChromeDebugPort(profileDir: string): Promise<number | null> {
|
||||
return await findExistingChromeDebugPortBase({ profileDir });
|
||||
}
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
export async function launchChrome(
|
||||
url: string,
|
||||
profileDir: string,
|
||||
candidates: PlatformCandidates,
|
||||
chromePathOverride?: string,
|
||||
): Promise<{ chrome: Awaited<ReturnType<typeof launchChromeBase>>; port: number }> {
|
||||
const chromePath = chromePathOverride?.trim() || findChromeExecutable(candidates);
|
||||
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
export class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||
private defaultTimeoutMs: number;
|
||||
const port = await getFreePort();
|
||||
const chrome = await launchChromeBase({
|
||||
chromePath,
|
||||
profileDir,
|
||||
port,
|
||||
url,
|
||||
extraArgs: ['--start-maximized'],
|
||||
});
|
||||
|
||||
private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) {
|
||||
this.ws = ws;
|
||||
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? 15_000;
|
||||
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
|
||||
|
||||
if (msg.method) {
|
||||
const handlers = this.eventHandlers.get(msg.method);
|
||||
if (handlers) handlers.forEach((h) => h(msg.params));
|
||||
}
|
||||
|
||||
if (msg.id) {
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (pending) {
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error('CDP connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number, options?: { defaultTimeoutMs?: number }): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
|
||||
ws.addEventListener('open', () => { clearTimeout(timer); resolve(); });
|
||||
ws.addEventListener('error', () => { clearTimeout(timer); reject(new Error('CDP connection failed.')); });
|
||||
});
|
||||
return new CdpConnection(ws, options);
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: unknown) => void): void {
|
||||
if (!this.eventHandlers.has(method)) this.eventHandlers.set(method, new Set());
|
||||
this.eventHandlers.get(method)!.add(handler);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: { sessionId?: string; timeoutMs?: number }): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = timeoutMs > 0
|
||||
? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
return { chrome, port };
|
||||
}
|
||||
|
||||
export function getScriptDir(): string {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
@@ -6,9 +5,11 @@ import process from 'node:process';
|
||||
import {
|
||||
CHROME_CANDIDATES_FULL,
|
||||
CdpConnection,
|
||||
findChromeExecutable,
|
||||
findExistingChromeDebugPort,
|
||||
getDefaultProfileDir,
|
||||
getFreePort,
|
||||
killChrome,
|
||||
launchChrome,
|
||||
openPageSession,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
} from './x-utils.js';
|
||||
@@ -27,9 +28,6 @@ interface XVideoOptions {
|
||||
export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||
const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
||||
|
||||
const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL);
|
||||
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
|
||||
|
||||
if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`);
|
||||
|
||||
const absVideoPath = path.resolve(videoPath);
|
||||
@@ -37,37 +35,37 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
console.log(`[x-video] Launching Chrome (profile: ${profileDir})`);
|
||||
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||
const reusing = existingPort !== null;
|
||||
let port = existingPort ?? 0;
|
||||
let chrome: Awaited<ReturnType<typeof launchChrome>>['chrome'] | null = null;
|
||||
if (!reusing) {
|
||||
const launched = await launchChrome(X_COMPOSE_URL, profileDir, CHROME_CANDIDATES_FULL, options.chromePath);
|
||||
port = launched.port;
|
||||
chrome = launched.chrome;
|
||||
}
|
||||
|
||||
const chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--start-maximized',
|
||||
X_COMPOSE_URL,
|
||||
], { stdio: 'ignore' });
|
||||
if (reusing) console.log(`[x-video] Reusing existing Chrome on port ${port}`);
|
||||
else console.log(`[x-video] Launching Chrome (profile: ${profileDir})`);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
let targetId: string | null = null;
|
||||
|
||||
try {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });
|
||||
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
||||
|
||||
if (!pageTarget) {
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL });
|
||||
pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' };
|
||||
}
|
||||
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
|
||||
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
||||
await cdp.send('DOM.enable', {}, { sessionId });
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing,
|
||||
url: X_COMPOSE_URL,
|
||||
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
|
||||
enablePage: true,
|
||||
enableRuntime: true,
|
||||
enableDom: true,
|
||||
});
|
||||
const { sessionId } = page;
|
||||
targetId = page.targetId;
|
||||
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
|
||||
|
||||
console.log('[x-video] Waiting for X editor...');
|
||||
@@ -182,15 +180,12 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||
}
|
||||
} finally {
|
||||
if (cdp) {
|
||||
if (reusing && submit && targetId) {
|
||||
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
// Don't kill Chrome in preview mode, let user review
|
||||
if (submit) {
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
|
||||
}, 2_000).unref?.();
|
||||
try { chrome.kill('SIGTERM'); } catch {}
|
||||
}
|
||||
if (chrome && submit) killChrome(chrome);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"name": "baoyu-url-to-markdown-scripts",
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"defuddle": "^0.10.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"linkedom": "^0.18.12",
|
||||
@@ -36,6 +37,8 @@
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
@@ -1,245 +1,84 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import {
|
||||
CdpConnection,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
killChrome,
|
||||
launchChrome as launchChromeBase,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
type PlatformCandidates,
|
||||
} from 'baoyu-chrome-cdp';
|
||||
|
||||
import { resolveUrlToMarkdownChromeProfileDir } from "./paths.js";
|
||||
import { CDP_CONNECT_TIMEOUT_MS, NETWORK_IDLE_TIMEOUT_MS } from "./constants.js";
|
||||
import { resolveUrlToMarkdownChromeProfileDir } from './paths.js';
|
||||
import { NETWORK_IDLE_TIMEOUT_MS } from './constants.js';
|
||||
|
||||
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
||||
const CHROME_CANDIDATES_FULL: PlatformCandidates = {
|
||||
darwin: [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
],
|
||||
win32: [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
],
|
||||
default: [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
],
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
export { CdpConnection, getFreePort, killChrome, sleep, waitForChromeDebugPort };
|
||||
|
||||
async function fetchWithTimeout(url: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<Response> {
|
||||
const { timeoutMs, ...rest } = init;
|
||||
if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest);
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...rest, signal: ctl.signal });
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }>();
|
||||
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } };
|
||||
if (msg.id) {
|
||||
const p = this.pending.get(msg.id);
|
||||
if (p) {
|
||||
this.pending.delete(msg.id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
if (msg.error?.message) p.reject(new Error(msg.error.message));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
} else if (msg.method) {
|
||||
const handlers = this.eventHandlers.get(msg.method);
|
||||
if (handlers) {
|
||||
for (const h of handlers) h(msg.params);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
this.ws.addEventListener("close", () => {
|
||||
for (const [id, p] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
p.reject(new Error("CDP connection closed."));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||
ws.addEventListener("open", () => { clearTimeout(t); resolve(); });
|
||||
ws.addEventListener("error", () => { clearTimeout(t); reject(new Error("CDP connection failed.")); });
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
on(event: string, handler: (params: unknown) => void): void {
|
||||
let handlers = this.eventHandlers.get(event);
|
||||
if (!handlers) {
|
||||
handlers = new Set();
|
||||
this.eventHandlers.set(event, handlers);
|
||||
}
|
||||
handlers.add(handler);
|
||||
}
|
||||
|
||||
off(event: string, handler: (params: unknown) => void): void {
|
||||
this.eventHandlers.get(event)?.delete(handler);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, opts?: CdpSendOptions): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const msg: Record<string, unknown> = { id, method };
|
||||
if (params) msg.params = params;
|
||||
if (opts?.sessionId) msg.sessionId = opts.sessionId;
|
||||
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
||||
const out = await new Promise<unknown>((resolve, reject) => {
|
||||
const t = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null;
|
||||
this.pending.set(id, { resolve, reject, timer: t });
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
});
|
||||
return out as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
srv.on("error", reject);
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
srv.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
export async function findExistingChromePort(): Promise<number | null> {
|
||||
return await findExistingChromeDebugPort({
|
||||
profileDir: resolveUrlToMarkdownChromeProfileDir(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function findExistingChromePort(): Promise<number | null> {
|
||||
const profileDir = resolveUrlToMarkdownChromeProfileDir();
|
||||
|
||||
const activePortPath = path.join(profileDir, "DevToolsActivePort");
|
||||
try {
|
||||
const content = await readFile(activePortPath, "utf-8");
|
||||
const port = parseInt(content.split("\n")[0].trim(), 10);
|
||||
if (port && !isNaN(port)) {
|
||||
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 3_000 });
|
||||
if (res.ok) return port;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
const ps = execSync("ps aux", { encoding: "utf-8", timeout: 5_000 });
|
||||
const escapedDir = profileDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const lines = ps.split("\n").filter(l => l.includes(profileDir) && l.includes("--remote-debugging-port="));
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
if (portMatch) {
|
||||
const port = parseInt(portMatch[1], 10);
|
||||
if (port && !isNaN(port)) {
|
||||
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 3_000 });
|
||||
if (res.ok) return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findChromeExecutable(): string | null {
|
||||
const override = process.env.URL_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
candidates.push(
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
||||
);
|
||||
break;
|
||||
case "win32":
|
||||
candidates.push(
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/microsoft-edge"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
return findChromeExecutableBase({
|
||||
candidates: CHROME_CANDIDATES_FULL,
|
||||
envNames: ['URL_CHROME_PATH'],
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 });
|
||||
if (!res.ok) throw new Error(`status=${res.status}`);
|
||||
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
||||
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error("Chrome debug port not ready");
|
||||
export async function launchChrome(url: string, port: number, headless = false) {
|
||||
const chromePath = findChromeExecutable();
|
||||
if (!chromePath) throw new Error('Chrome executable not found. Install Chrome or set URL_CHROME_PATH env.');
|
||||
|
||||
return await launchChromeBase({
|
||||
chromePath,
|
||||
profileDir: resolveUrlToMarkdownChromeProfileDir(),
|
||||
port,
|
||||
url,
|
||||
headless,
|
||||
extraArgs: ['--disable-popup-blocking'],
|
||||
});
|
||||
}
|
||||
|
||||
export async function launchChrome(url: string, port: number, headless: boolean = false): Promise<ChildProcess> {
|
||||
const chrome = findChromeExecutable();
|
||||
if (!chrome) throw new Error("Chrome executable not found. Install Chrome or set URL_CHROME_PATH env.");
|
||||
const profileDir = resolveUrlToMarkdownChromeProfileDir();
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-popup-blocking",
|
||||
];
|
||||
if (headless) args.push("--headless=new");
|
||||
args.push(url);
|
||||
|
||||
return spawn(chrome, args, { stdio: "ignore" });
|
||||
}
|
||||
|
||||
export async function waitForNetworkIdle(cdp: CdpConnection, sessionId: string, timeoutMs: number = NETWORK_IDLE_TIMEOUT_MS): Promise<void> {
|
||||
export async function waitForNetworkIdle(
|
||||
cdp: CdpConnection,
|
||||
sessionId: string,
|
||||
timeoutMs: number = NETWORK_IDLE_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pending = 0;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
cdp.off("Network.requestWillBeSent", onRequest);
|
||||
cdp.off("Network.loadingFinished", onFinish);
|
||||
cdp.off("Network.loadingFailed", onFinish);
|
||||
cdp.off('Network.requestWillBeSent', onRequest);
|
||||
cdp.off('Network.loadingFinished', onFinish);
|
||||
cdp.off('Network.loadingFailed', onFinish);
|
||||
};
|
||||
const done = () => { cleanup(); resolve(); };
|
||||
const resetTimer = () => {
|
||||
@@ -248,79 +87,93 @@ export async function waitForNetworkIdle(cdp: CdpConnection, sessionId: string,
|
||||
};
|
||||
const onRequest = () => { pending++; resetTimer(); };
|
||||
const onFinish = () => { pending = Math.max(0, pending - 1); if (pending <= 2) resetTimer(); };
|
||||
cdp.on("Network.requestWillBeSent", onRequest);
|
||||
cdp.on("Network.loadingFinished", onFinish);
|
||||
cdp.on("Network.loadingFailed", onFinish);
|
||||
cdp.on('Network.requestWillBeSent', onRequest);
|
||||
cdp.on('Network.loadingFinished', onFinish);
|
||||
cdp.on('Network.loadingFailed', onFinish);
|
||||
resetTimer();
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForPageLoad(cdp: CdpConnection, sessionId: string, timeoutMs: number = 30_000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
export async function waitForPageLoad(
|
||||
cdp: CdpConnection,
|
||||
sessionId: string,
|
||||
timeoutMs: number = 30_000,
|
||||
): Promise<void> {
|
||||
void sessionId;
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
cdp.off("Page.loadEventFired", handler);
|
||||
cdp.off('Page.loadEventFired', handler);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
const handler = () => {
|
||||
clearTimeout(timer);
|
||||
cdp.off("Page.loadEventFired", handler);
|
||||
cdp.off('Page.loadEventFired', handler);
|
||||
resolve();
|
||||
};
|
||||
cdp.on("Page.loadEventFired", handler);
|
||||
cdp.on('Page.loadEventFired', handler);
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTargetAndAttach(cdp: CdpConnection, url: string): Promise<{ targetId: string; sessionId: string }> {
|
||||
const { targetId } = await cdp.send<{ targetId: string }>("Target.createTarget", { url });
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true });
|
||||
await cdp.send("Network.enable", {}, { sessionId });
|
||||
await cdp.send("Page.enable", {}, { sessionId });
|
||||
export async function createTargetAndAttach(
|
||||
cdp: CdpConnection,
|
||||
url: string,
|
||||
): Promise<{ targetId: string; sessionId: string }> {
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url });
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });
|
||||
await cdp.send('Network.enable', {}, { sessionId });
|
||||
await cdp.send('Page.enable', {}, { sessionId });
|
||||
return { targetId, sessionId };
|
||||
}
|
||||
|
||||
export async function navigateAndWait(cdp: CdpConnection, sessionId: string, url: string, timeoutMs: number): Promise<void> {
|
||||
export async function navigateAndWait(
|
||||
cdp: CdpConnection,
|
||||
sessionId: string,
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("Page load timeout")), timeoutMs);
|
||||
const timer = setTimeout(() => reject(new Error('Page load timeout')), timeoutMs);
|
||||
const handler = (params: unknown) => {
|
||||
const p = params as { name?: string };
|
||||
if (p.name === "load" || p.name === "DOMContentLoaded") {
|
||||
const event = params as { name?: string };
|
||||
if (event.name === 'load' || event.name === 'DOMContentLoaded') {
|
||||
clearTimeout(timer);
|
||||
cdp.off("Page.lifecycleEvent", handler);
|
||||
cdp.off('Page.lifecycleEvent', handler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
cdp.on("Page.lifecycleEvent", handler);
|
||||
cdp.on('Page.lifecycleEvent', handler);
|
||||
});
|
||||
await cdp.send("Page.navigate", { url }, { sessionId });
|
||||
await cdp.send('Page.navigate', { url }, { sessionId });
|
||||
await loadPromise;
|
||||
}
|
||||
|
||||
export async function evaluateScript<T>(cdp: CdpConnection, sessionId: string, expression: string, timeoutMs: number = 30_000): Promise<T> {
|
||||
const result = await cdp.send<{ result: { value?: T; type?: string; description?: string } }>(
|
||||
"Runtime.evaluate",
|
||||
export async function evaluateScript<T>(
|
||||
cdp: CdpConnection,
|
||||
sessionId: string,
|
||||
expression: string,
|
||||
timeoutMs: number = 30_000,
|
||||
): Promise<T> {
|
||||
const result = await cdp.send<{ result: { value?: T } }>(
|
||||
'Runtime.evaluate',
|
||||
{ expression, returnByValue: true, awaitPromise: true },
|
||||
{ sessionId, timeoutMs }
|
||||
{ sessionId, timeoutMs },
|
||||
);
|
||||
return result.result.value as T;
|
||||
}
|
||||
|
||||
export async function autoScroll(cdp: CdpConnection, sessionId: string, steps: number = 8, waitMs: number = 600): Promise<void> {
|
||||
let lastHeight = await evaluateScript<number>(cdp, sessionId, "document.body.scrollHeight");
|
||||
export async function autoScroll(
|
||||
cdp: CdpConnection,
|
||||
sessionId: string,
|
||||
steps: number = 8,
|
||||
waitMs: number = 600,
|
||||
): Promise<void> {
|
||||
let lastHeight = await evaluateScript<number>(cdp, sessionId, 'document.body.scrollHeight');
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await evaluateScript<void>(cdp, sessionId, "window.scrollTo(0, document.body.scrollHeight)");
|
||||
await evaluateScript<void>(cdp, sessionId, 'window.scrollTo(0, document.body.scrollHeight)');
|
||||
await sleep(waitMs);
|
||||
const newHeight = await evaluateScript<number>(cdp, sessionId, "document.body.scrollHeight");
|
||||
const newHeight = await evaluateScript<number>(cdp, sessionId, 'document.body.scrollHeight');
|
||||
if (newHeight === lastHeight) break;
|
||||
lastHeight = newHeight;
|
||||
}
|
||||
await evaluateScript<void>(cdp, sessionId, "window.scrollTo(0, 0)");
|
||||
}
|
||||
|
||||
export function killChrome(chrome: ChildProcess): void {
|
||||
try { chrome.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try { chrome.kill("SIGKILL"); } catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
await evaluateScript<void>(cdp, sessionId, 'window.scrollTo(0, 0)');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"defuddle": "^0.10.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"linkedom": "^0.18.12",
|
||||
|
||||
Reference in New Issue
Block a user