mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-29 05:09:48 +08:00
refactor: unify skill cdp and release artifacts
This commit is contained in:
@@ -35,6 +35,7 @@ Just run `/release-skills` - auto-detects your project configuration.
|
|||||||
### Step 1: Detect Project Configuration
|
### Step 1: Detect Project Configuration
|
||||||
|
|
||||||
1. Check for `.releaserc.yml` (optional config override)
|
1. Check for `.releaserc.yml` (optional config override)
|
||||||
|
- If present, inspect whether it defines release hooks
|
||||||
2. Auto-detect version file by scanning (priority order):
|
2. Auto-detect version file by scanning (priority order):
|
||||||
- `package.json` (Node.js)
|
- `package.json` (Node.js)
|
||||||
- `pyproject.toml` (Python)
|
- `pyproject.toml` (Python)
|
||||||
@@ -48,6 +49,34 @@ Just run `/release-skills` - auto-detects your project configuration.
|
|||||||
4. Identify language of each changelog by filename suffix
|
4. Identify language of each changelog by filename suffix
|
||||||
5. Display detected configuration
|
5. Display detected configuration
|
||||||
|
|
||||||
|
**Project Hook Contract**:
|
||||||
|
|
||||||
|
If `.releaserc.yml` defines `release.hooks`, keep the release workflow generic and delegate project-specific packaging/publishing to those hooks.
|
||||||
|
|
||||||
|
Supported hooks:
|
||||||
|
|
||||||
|
| Hook | Purpose | Expected Responsibility |
|
||||||
|
|------|---------|-------------------------|
|
||||||
|
| `prepare_artifact` | Build a releasable artifact for one target | Vendor local deps, rewrite package metadata, stage files |
|
||||||
|
| `publish_artifact` | Publish one prepared artifact | Upload artifact, attach version/changelog/tags |
|
||||||
|
|
||||||
|
Supported placeholders:
|
||||||
|
|
||||||
|
| Placeholder | Meaning |
|
||||||
|
|-------------|---------|
|
||||||
|
| `{project_root}` | Absolute path to repository root |
|
||||||
|
| `{target}` | Absolute path to the module/skill being released |
|
||||||
|
| `{artifact_dir}` | Absolute path to a temporary artifact directory for this target |
|
||||||
|
| `{version}` | Version selected by the release workflow |
|
||||||
|
| `{dry_run}` | `true` or `false` |
|
||||||
|
| `{release_notes_file}` | Absolute path to a UTF-8 file containing release notes/changelog text |
|
||||||
|
|
||||||
|
Execution rules:
|
||||||
|
- Keep the skill generic: do not hardcode registry/package-manager/project layout details into this SKILL.
|
||||||
|
- If `prepare_artifact` exists, run it once per target before publish-related checks that need the final artifact.
|
||||||
|
- Write release notes to a temp file and pass that file path to `publish_artifact`; do not inline multiline changelog text into shell commands.
|
||||||
|
- If hooks are absent, fall back to the default project-agnostic release workflow.
|
||||||
|
|
||||||
**Language Detection Rules**:
|
**Language Detection Rules**:
|
||||||
|
|
||||||
Changelog files follow the pattern `CHANGELOG_{LANG}.md` or `CHANGELOG.{lang}.md`, where `{lang}` / `{LANG}` is a language or region code.
|
Changelog files follow the pattern `CHANGELOG_{LANG}.md` or `CHANGELOG.{lang}.md`, where `{lang}` / `{LANG}` is a language or region code.
|
||||||
|
|||||||
@@ -164,3 +164,4 @@ posts/
|
|||||||
# ClawHub local state (current and legacy directory names from the official CLI)
|
# ClawHub local state (current and legacy directory names from the official CLI)
|
||||||
.clawhub/
|
.clawhub/
|
||||||
.clawdhub/
|
.clawdhub/
|
||||||
|
.worktrees/
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
release:
|
||||||
|
target_globs:
|
||||||
|
- skills/*
|
||||||
|
artifact_root: .release-artifacts
|
||||||
|
hooks:
|
||||||
|
prepare_artifact: bun scripts/prepare-skill-artifact.mjs --skill-dir "{target}" --out-dir "{artifact_dir}"
|
||||||
|
publish_artifact: node scripts/publish-skill-artifact.mjs --skill-dir "{target}" --artifact-dir "{artifact_dir}" --version "{version}" --changelog-file "{release_notes_file}" --dry-run "{dry_run}"
|
||||||
@@ -38,6 +38,8 @@ skills/
|
|||||||
|
|
||||||
Top-level `scripts/` directory contains repository maintenance utilities:
|
Top-level `scripts/` directory contains repository maintenance utilities:
|
||||||
- `scripts/sync-clawhub.sh` - Publish skills to ClawHub/OpenClaw registry
|
- `scripts/sync-clawhub.sh` - Publish skills to ClawHub/OpenClaw registry
|
||||||
|
- `scripts/prepare-skill-artifact.mjs` - Build one releasable skill artifact with vendored local packages
|
||||||
|
- `scripts/publish-skill-artifact.mjs` - Publish one prepared skill artifact
|
||||||
- `scripts/sync-md-to-wechat.sh` - Sync markdown content to WeChat
|
- `scripts/sync-md-to-wechat.sh` - Sync markdown content to WeChat
|
||||||
|
|
||||||
**Plugin Categories**:
|
**Plugin Categories**:
|
||||||
@@ -213,6 +215,8 @@ bash scripts/sync-clawhub.sh <skill> # sync one skill
|
|||||||
|
|
||||||
Requires `clawhub` CLI or `npx` (auto-downloads via npx if not installed).
|
Requires `clawhub` CLI or `npx` (auto-downloads via npx if not installed).
|
||||||
|
|
||||||
|
Release-time artifact preparation is configured via `.releaserc.yml`. Keep registry/project-specific packaging in hook scripts instead of hardcoding it into generic release instructions.
|
||||||
|
|
||||||
## Skill Loading Rules
|
## Skill Loading Rules
|
||||||
|
|
||||||
**IMPORTANT**: When working in this project, follow these rules:
|
**IMPORTANT**: When working in this project, follow these rules:
|
||||||
@@ -231,6 +235,8 @@ Requires `clawhub` CLI or `npx` (auto-downloads via npx if not installed).
|
|||||||
|
|
||||||
**IMPORTANT**: When user requests release/发布/push, ALWAYS use `/release-skills` workflow.
|
**IMPORTANT**: When user requests release/发布/push, ALWAYS use `/release-skills` workflow.
|
||||||
|
|
||||||
|
If `.releaserc.yml` defines `release.hooks.prepare_artifact` / `publish_artifact`, use those hooks to build and publish the final skill artifact.
|
||||||
|
|
||||||
**Never skip**:
|
**Never skip**:
|
||||||
1. `CHANGELOG.md` + `CHANGELOG.zh.md` - Both must be updated
|
1. `CHANGELOG.md` + `CHANGELOG.zh.md` - Both must be updated
|
||||||
2. `marketplace.json` version bump
|
2. `marketplace.json` version bump
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "baoyu-chrome-cdp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { spawn, spawnSync, type ChildProcess } 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";
|
||||||
|
|
||||||
|
export type PlatformCandidates = {
|
||||||
|
darwin?: string[];
|
||||||
|
win32?: string[];
|
||||||
|
default: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CdpSendOptions = {
|
||||||
|
sessionId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchJsonOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindChromeExecutableOptions = {
|
||||||
|
candidates: PlatformCandidates;
|
||||||
|
envNames?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolveSharedChromeProfileDirOptions = {
|
||||||
|
envNames?: string[];
|
||||||
|
appDataDirName?: string;
|
||||||
|
profileDirName?: string;
|
||||||
|
wslWindowsHome?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindExistingChromeDebugPortOptions = {
|
||||||
|
profileDir: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LaunchChromeOptions = {
|
||||||
|
chromePath: string;
|
||||||
|
profileDir: string;
|
||||||
|
port: number;
|
||||||
|
url?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
extraArgs?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChromeTargetInfo = {
|
||||||
|
targetId: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenPageSessionOptions = {
|
||||||
|
cdp: CdpConnection;
|
||||||
|
reusing: boolean;
|
||||||
|
url: string;
|
||||||
|
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||||
|
enablePage?: boolean;
|
||||||
|
enableRuntime?: boolean;
|
||||||
|
enableDom?: boolean;
|
||||||
|
enableNetwork?: boolean;
|
||||||
|
activateTarget?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageSession = {
|
||||||
|
sessionId: string;
|
||||||
|
targetId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||||
|
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||||
|
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||||
|
|
||||||
|
return await 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override && fs.existsSync(override)) return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = process.platform === "darwin"
|
||||||
|
? options.candidates.darwin ?? options.candidates.default
|
||||||
|
: process.platform === "win32"
|
||||||
|
? options.candidates.win32 ?? options.candidates.default
|
||||||
|
: options.candidates.default;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||||
|
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||||
|
|
||||||
|
if (options.wslWindowsHome) {
|
||||||
|
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.platform === "darwin"
|
||||||
|
? path.join(os.homedir(), "Library", "Application Support")
|
||||||
|
: process.platform === "win32"
|
||||||
|
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||||
|
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||||
|
return path.join(base, appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||||
|
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs }
|
||||||
|
);
|
||||||
|
return !!version.webSocketDebuggerUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(portFile, "utf-8");
|
||||||
|
const [portLine] = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status !== 0 || !result.stdout) return null;
|
||||||
|
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`,
|
||||||
|
{ timeoutMs: 5_000 }
|
||||||
|
);
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||||
|
this.ws = ws;
|
||||||
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||||
|
|
||||||
|
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((handler) => handler(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?.defaultTimeoutMs ?? 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(method: string, handler: (params: unknown) => void): void {
|
||||||
|
this.eventHandlers.get(method)?.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||||
|
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
`--remote-debugging-port=${options.port}`,
|
||||||
|
`--user-data-dir=${options.profileDir}`,
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
...(options.extraArgs ?? []),
|
||||||
|
];
|
||||||
|
if (options.headless) args.push("--headless=new");
|
||||||
|
if (options.url) args.push(options.url);
|
||||||
|
|
||||||
|
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killChrome(chrome: ChildProcess): void {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGTERM");
|
||||||
|
} catch {}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chrome.killed) {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGKILL");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, 2_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
|
let targetId: string;
|
||||||
|
|
||||||
|
if (options.reusing) {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
} else {
|
||||||
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
|
if (existing) {
|
||||||
|
targetId = existing.targetId;
|
||||||
|
} else {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||||
|
"Target.attachToTarget",
|
||||||
|
{ targetId, flatten: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.activateTarget ?? true) {
|
||||||
|
await options.cdp.send("Target.activateTarget", { targetId });
|
||||||
|
}
|
||||||
|
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||||
|
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||||
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
|
return { sessionId, targetId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const TEXT_EXTENSIONS = new Set([
|
||||||
|
"md",
|
||||||
|
"mdx",
|
||||||
|
"txt",
|
||||||
|
"json",
|
||||||
|
"json5",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"js",
|
||||||
|
"cjs",
|
||||||
|
"mjs",
|
||||||
|
"ts",
|
||||||
|
"tsx",
|
||||||
|
"jsx",
|
||||||
|
"py",
|
||||||
|
"sh",
|
||||||
|
"rb",
|
||||||
|
"go",
|
||||||
|
"rs",
|
||||||
|
"swift",
|
||||||
|
"kt",
|
||||||
|
"java",
|
||||||
|
"cs",
|
||||||
|
"cpp",
|
||||||
|
"c",
|
||||||
|
"h",
|
||||||
|
"hpp",
|
||||||
|
"sql",
|
||||||
|
"csv",
|
||||||
|
"ini",
|
||||||
|
"cfg",
|
||||||
|
"env",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"css",
|
||||||
|
"scss",
|
||||||
|
"sass",
|
||||||
|
"svg",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PACKAGE_DEPENDENCY_SECTIONS = [
|
||||||
|
"dependencies",
|
||||||
|
"optionalDependencies",
|
||||||
|
"peerDependencies",
|
||||||
|
"devDependencies",
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function listTextFiles(root) {
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
async function walk(folder) {
|
||||||
|
const entries = await fs.readdir(folder, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith(".")) continue;
|
||||||
|
if (entry.name === "node_modules") continue;
|
||||||
|
if (entry.name === ".clawhub" || entry.name === ".clawdhub") continue;
|
||||||
|
|
||||||
|
const fullPath = path.join(folder, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await walk(fullPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue;
|
||||||
|
|
||||||
|
const relPath = path.relative(root, fullPath).split(path.sep).join("/");
|
||||||
|
const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
if (!TEXT_EXTENSIONS.has(ext)) continue;
|
||||||
|
|
||||||
|
const bytes = await fs.readFile(fullPath);
|
||||||
|
files.push({ relPath, bytes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await walk(root);
|
||||||
|
files.sort((left, right) => left.relPath.localeCompare(right.relPath));
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectReleaseFiles(root) {
|
||||||
|
const baseFiles = await listTextFiles(root);
|
||||||
|
const fileMap = new Map(baseFiles.map((file) => [file.relPath, file.bytes]));
|
||||||
|
const vendoredPackages = new Set();
|
||||||
|
|
||||||
|
for (const file of baseFiles.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) {
|
||||||
|
const packageDirRel = normalizeDirRel(path.posix.dirname(file.relPath));
|
||||||
|
const rewritten = await rewritePackageJsonForRelease({
|
||||||
|
root,
|
||||||
|
packageDirRel,
|
||||||
|
bytes: file.bytes,
|
||||||
|
fileMap,
|
||||||
|
vendoredPackages,
|
||||||
|
});
|
||||||
|
if (rewritten) {
|
||||||
|
fileMap.set(file.relPath, rewritten);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...fileMap.entries()]
|
||||||
|
.map(([relPath, bytes]) => ({ relPath, bytes }))
|
||||||
|
.sort((left, right) => left.relPath.localeCompare(right.relPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function materializeReleaseFiles(files, outDir) {
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
for (const file of files) {
|
||||||
|
const outputPath = path.join(outDir, fromPosixRel(file.relPath));
|
||||||
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
|
await fs.writeFile(outputPath, file.bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rewritePackageJsonForRelease({ root, packageDirRel, bytes, fileMap, vendoredPackages }) {
|
||||||
|
const packageJson = JSON.parse(bytes.toString("utf8"));
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||||
|
const dependencies = packageJson[section];
|
||||||
|
if (!dependencies || typeof dependencies !== "object") continue;
|
||||||
|
|
||||||
|
for (const [name, spec] of Object.entries(dependencies)) {
|
||||||
|
if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
|
||||||
|
|
||||||
|
const sourceDir = path.resolve(root, fromPosixRel(packageDirRel), spec.slice(5));
|
||||||
|
const vendorDirRel = normalizeDirRel(path.posix.join(packageDirRel, "vendor", name));
|
||||||
|
await vendorPackageTree({
|
||||||
|
sourceDir,
|
||||||
|
targetDirRel: vendorDirRel,
|
||||||
|
fileMap,
|
||||||
|
vendoredPackages,
|
||||||
|
});
|
||||||
|
dependencies[name] = toFileDependencySpec(packageDirRel, vendorDirRel);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return null;
|
||||||
|
return Buffer.from(`${JSON.stringify(packageJson, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vendorPackageTree({ sourceDir, targetDirRel, fileMap, vendoredPackages }) {
|
||||||
|
const dedupeKey = `${path.resolve(sourceDir)}=>${targetDirRel}`;
|
||||||
|
if (vendoredPackages.has(dedupeKey)) return;
|
||||||
|
vendoredPackages.add(dedupeKey);
|
||||||
|
|
||||||
|
const files = await listTextFiles(sourceDir);
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`Local package has no text files: ${sourceDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = files.find((file) => file.relPath === "package.json");
|
||||||
|
if (!packageJson) {
|
||||||
|
throw new Error(`Local package is missing package.json: ${sourceDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.relPath === "package.json") continue;
|
||||||
|
fileMap.set(joinReleasePath(targetDirRel, file.relPath), file.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewrittenPackageJson = await rewritePackageJsonForRelease({
|
||||||
|
root: sourceDir,
|
||||||
|
packageDirRel: ".",
|
||||||
|
bytes: packageJson.bytes,
|
||||||
|
fileMap: {
|
||||||
|
set(relPath, outputBytes) {
|
||||||
|
fileMap.set(joinReleasePath(targetDirRel, relPath), outputBytes);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vendoredPackages,
|
||||||
|
});
|
||||||
|
|
||||||
|
fileMap.set(
|
||||||
|
joinReleasePath(targetDirRel, "package.json"),
|
||||||
|
rewrittenPackageJson ?? packageJson.bytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDirRel(relPath) {
|
||||||
|
return relPath === "." ? "." : relPath.split(path.sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromPosixRel(relPath) {
|
||||||
|
return relPath === "." ? "." : relPath.split("/").join(path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinReleasePath(base, relPath) {
|
||||||
|
const joined = normalizeDirRel(path.posix.join(base === "." ? "" : base, relPath));
|
||||||
|
return joined.replace(/^\.\//, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFileDependencySpec(fromDirRel, targetDirRel) {
|
||||||
|
const fromDir = fromDirRel === "." ? "" : fromDirRel;
|
||||||
|
const relative = path.posix.relative(fromDir || ".", targetDirRel);
|
||||||
|
const normalized = relative === "" ? "." : relative;
|
||||||
|
return `file:${normalized.startsWith(".") ? normalized : `./${normalized}`}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { collectReleaseFiles, materializeReleaseFiles } from "./lib/skill-artifact.mjs";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
if (!options.skillDir || !options.outDir) {
|
||||||
|
throw new Error("--skill-dir and --out-dir are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillDir = path.resolve(options.skillDir);
|
||||||
|
const outDir = path.resolve(options.outDir);
|
||||||
|
const files = await collectReleaseFiles(skillDir);
|
||||||
|
await materializeReleaseFiles(files, outDir);
|
||||||
|
|
||||||
|
console.log(`Prepared artifact for ${path.basename(skillDir)}`);
|
||||||
|
console.log(`Source: ${skillDir}`);
|
||||||
|
console.log(`Output: ${outDir}`);
|
||||||
|
console.log(`Files: ${files.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
skillDir: "",
|
||||||
|
outDir: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--skill-dir") {
|
||||||
|
options.skillDir = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--out-dir") {
|
||||||
|
options.outDir = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "-h" || arg === "--help") {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`Usage: prepare-skill-artifact.mjs --skill-dir <dir> --out-dir <dir>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--skill-dir <dir> Source skill directory
|
||||||
|
--out-dir <dir> Artifact output directory
|
||||||
|
-h, --help Show help`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { listTextFiles } from "./lib/skill-artifact.mjs";
|
||||||
|
|
||||||
|
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
if (!options.skillDir || !options.artifactDir || !options.version) {
|
||||||
|
throw new Error("--skill-dir, --artifact-dir, and --version are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillDir = path.resolve(options.skillDir);
|
||||||
|
const artifactDir = path.resolve(options.artifactDir);
|
||||||
|
const skill = buildSkillEntry(skillDir, options.slug, options.displayName);
|
||||||
|
const changelog = options.changelogFile
|
||||||
|
? await fs.readFile(path.resolve(options.changelogFile), "utf8")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const files = await listTextFiles(artifactDir);
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`Artifact directory is empty: ${artifactDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dryRun) {
|
||||||
|
console.log(`Dry run: would publish ${skill.slug}@${options.version}`);
|
||||||
|
console.log(`Artifact: ${artifactDir}`);
|
||||||
|
console.log(`Files: ${files.length}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readClawhubConfig();
|
||||||
|
const registry = (
|
||||||
|
options.registry ||
|
||||||
|
process.env.CLAWHUB_REGISTRY ||
|
||||||
|
process.env.CLAWDHUB_REGISTRY ||
|
||||||
|
config.registry ||
|
||||||
|
DEFAULT_REGISTRY
|
||||||
|
).replace(/\/+$/, "");
|
||||||
|
|
||||||
|
if (!config.token) {
|
||||||
|
throw new Error("Not logged in. Run: clawhub login");
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiJson(registry, config.token, "/api/v1/whoami");
|
||||||
|
|
||||||
|
const tags = options.tags
|
||||||
|
.split(",")
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
await publishSkill({
|
||||||
|
registry,
|
||||||
|
token: config.token,
|
||||||
|
skill,
|
||||||
|
files,
|
||||||
|
version: options.version,
|
||||||
|
changelog,
|
||||||
|
tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
skillDir: "",
|
||||||
|
artifactDir: "",
|
||||||
|
version: "",
|
||||||
|
changelogFile: "",
|
||||||
|
registry: "",
|
||||||
|
tags: "latest",
|
||||||
|
dryRun: false,
|
||||||
|
slug: "",
|
||||||
|
displayName: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const arg = argv[index];
|
||||||
|
if (arg === "--skill-dir") {
|
||||||
|
options.skillDir = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--artifact-dir") {
|
||||||
|
options.artifactDir = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--version") {
|
||||||
|
options.version = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--changelog-file") {
|
||||||
|
options.changelogFile = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--registry") {
|
||||||
|
options.registry = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--tags") {
|
||||||
|
options.tags = argv[index + 1] ?? "latest";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--slug") {
|
||||||
|
options.slug = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--display-name") {
|
||||||
|
options.displayName = argv[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--dry-run") {
|
||||||
|
const next = argv[index + 1];
|
||||||
|
if (next && !next.startsWith("-")) {
|
||||||
|
options.dryRun = parseBoolean(next);
|
||||||
|
index += 1;
|
||||||
|
} else {
|
||||||
|
options.dryRun = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "-h" || arg === "--help") {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.log(`Usage: publish-skill-artifact.mjs --skill-dir <dir> --artifact-dir <dir> --version <semver> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--skill-dir <dir> Source skill directory (used for slug/display name)
|
||||||
|
--artifact-dir <dir> Prepared artifact directory
|
||||||
|
--version <semver> Version to publish
|
||||||
|
--changelog-file <file> Release notes file
|
||||||
|
--registry <url> Override registry base URL
|
||||||
|
--tags <tags> Comma-separated tags (default: latest)
|
||||||
|
--slug <value> Override slug
|
||||||
|
--display-name <value> Override display name
|
||||||
|
--dry-run Print publish plan without network calls
|
||||||
|
-h, --help Show help`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSkillEntry(folder, slugOverride, displayNameOverride) {
|
||||||
|
const base = path.basename(folder);
|
||||||
|
return {
|
||||||
|
folder,
|
||||||
|
slug: slugOverride || sanitizeSlug(base),
|
||||||
|
displayName: displayNameOverride || titleCase(base),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readClawhubConfig() {
|
||||||
|
const configPath = getConfigPath();
|
||||||
|
try {
|
||||||
|
return JSON.parse(await fs.readFile(configPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfigPath() {
|
||||||
|
const override =
|
||||||
|
process.env.CLAWHUB_CONFIG_PATH?.trim() || process.env.CLAWDHUB_CONFIG_PATH?.trim();
|
||||||
|
if (override) {
|
||||||
|
return path.resolve(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = os.homedir();
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
const clawhub = path.join(home, "Library", "Application Support", "clawhub", "config.json");
|
||||||
|
const clawdhub = path.join(home, "Library", "Application Support", "clawdhub", "config.json");
|
||||||
|
return pathForExistingConfig(clawhub, clawdhub);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xdg = process.env.XDG_CONFIG_HOME;
|
||||||
|
if (xdg) {
|
||||||
|
const clawhub = path.join(xdg, "clawhub", "config.json");
|
||||||
|
const clawdhub = path.join(xdg, "clawdhub", "config.json");
|
||||||
|
return pathForExistingConfig(clawhub, clawdhub);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === "win32" && process.env.APPDATA) {
|
||||||
|
const clawhub = path.join(process.env.APPDATA, "clawhub", "config.json");
|
||||||
|
const clawdhub = path.join(process.env.APPDATA, "clawdhub", "config.json");
|
||||||
|
return pathForExistingConfig(clawhub, clawdhub);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clawhub = path.join(home, ".config", "clawhub", "config.json");
|
||||||
|
const clawdhub = path.join(home, ".config", "clawdhub", "config.json");
|
||||||
|
return pathForExistingConfig(clawhub, clawdhub);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathForExistingConfig(primary, legacy) {
|
||||||
|
if (existsSync(primary)) return path.resolve(primary);
|
||||||
|
if (existsSync(legacy)) return path.resolve(legacy);
|
||||||
|
return path.resolve(primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishSkill({ registry, token, skill, files, version, changelog, tags }) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set(
|
||||||
|
"payload",
|
||||||
|
JSON.stringify({
|
||||||
|
slug: skill.slug,
|
||||||
|
displayName: skill.displayName,
|
||||||
|
version,
|
||||||
|
changelog,
|
||||||
|
tags,
|
||||||
|
acceptLicenseTerms: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
form.append("files", new Blob([file.bytes], { type: "text/plain" }), file.relPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${registry}/api/v1/skills`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(text || `Publish failed for ${skill.slug} (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = text ? JSON.parse(text) : {};
|
||||||
|
console.log(`OK. Published ${skill.slug}@${version}${result.versionId ? ` (${result.versionId})` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiJson(registry, token, requestPath) {
|
||||||
|
const response = await fetch(`${registry}${requestPath}`, {
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let body = null;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
body = { message: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status < 200 || response.status >= 300) {
|
||||||
|
throw new Error(body?.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSlug(value) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9-]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
.replace(/--+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(value) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/[-_]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoolean(value) {
|
||||||
|
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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 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 { Endpoint, Headers } from '../constants.js';
|
||||||
import { logger } from './logger.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 { read_cookie_file, type CookieMap, write_cookie_file } from './cookie-file.js';
|
||||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||||
|
|
||||||
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
const GEMINI_APP_URL = 'https://gemini.google.com/app';
|
||||||
|
|
||||||
class CdpConnection {
|
const CHROME_CANDIDATES_FULL: PlatformCandidates = {
|
||||||
private ws: WebSocket;
|
darwin: [
|
||||||
private nextId = 0;
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
private pending = new Map<
|
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||||
number,
|
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||||
>();
|
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||||
|
],
|
||||||
private constructor(ws: WebSocket) {
|
win32: [
|
||||||
this.ws = ws;
|
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||||
this.ws.addEventListener('message', (event) => {
|
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||||
try {
|
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
],
|
||||||
if (msg.id) {
|
default: [
|
||||||
const p = this.pending.get(msg.id);
|
'/usr/bin/google-chrome',
|
||||||
if (p) {
|
'/usr/bin/google-chrome-stable',
|
||||||
this.pending.delete(msg.id);
|
'/usr/bin/chromium',
|
||||||
if (p.timer) clearTimeout(p.timer);
|
'/usr/bin/chromium-browser',
|
||||||
if (msg.error?.message) p.reject(new Error(msg.error.message));
|
'/snap/bin/chromium',
|
||||||
else p.resolve(msg.result);
|
'/usr/bin/microsoft-edge',
|
||||||
}
|
],
|
||||||
}
|
};
|
||||||
} 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 get_free_port(): Promise<number> {
|
async function get_free_port(): Promise<number> {
|
||||||
const fixed = parseInt(process.env.GEMINI_WEB_DEBUG_PORT || '', 10);
|
return await getFreePort('GEMINI_WEB_DEBUG_PORT');
|
||||||
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)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function find_chrome_executable(): string | null {
|
function find_chrome_executable(): string | null {
|
||||||
const override = process.env.GEMINI_WEB_CHROME_PATH?.trim();
|
return findChromeExecutableBase({
|
||||||
if (override && fs.existsSync(override)) return override;
|
candidates: CHROME_CANDIDATES_FULL,
|
||||||
|
envNames: ['GEMINI_WEB_CHROME_PATH'],
|
||||||
const candidates: string[] = [];
|
}) ?? null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wait_for_chrome_debug_port(port: number, timeoutMs: number): Promise<string> {
|
async function find_existing_chrome_debug_port(profileDir: string): Promise<number | null> {
|
||||||
const start = Date.now();
|
return await findExistingChromeDebugPort({ profileDir });
|
||||||
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 launch_chrome(profileDir: string, port: number): Promise<ChildProcess> {
|
async function launch_chrome(profileDir: string, port: number) {
|
||||||
const chrome = find_chrome_executable();
|
const chromePath = find_chrome_executable();
|
||||||
if (!chrome) throw new Error('Chrome executable not found.');
|
if (!chromePath) throw new Error('Chrome executable not found.');
|
||||||
|
|
||||||
const args = [
|
return await launchChromeBase({
|
||||||
`--remote-debugging-port=${port}`,
|
chromePath,
|
||||||
`--user-data-dir=${profileDir}`,
|
profileDir,
|
||||||
'--no-first-run',
|
port,
|
||||||
'--no-default-browser-check',
|
url: GEMINI_APP_URL,
|
||||||
'--disable-popup-blocking',
|
extraArgs: ['--disable-popup-blocking'],
|
||||||
'https://gemini.google.com/app',
|
});
|
||||||
];
|
|
||||||
|
|
||||||
return spawn(chrome, args, { stdio: 'ignore' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function is_gemini_session_ready(cookies: CookieMap, verbose: boolean): Promise<boolean> {
|
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,
|
timeoutMs: number,
|
||||||
verbose: boolean,
|
verbose: boolean,
|
||||||
): Promise<CookieMap> {
|
): Promise<CookieMap> {
|
||||||
await mkdir(profileDir, { recursive: true });
|
const existingPort = await find_existing_chrome_debug_port(profileDir);
|
||||||
|
const reusing = existingPort !== null;
|
||||||
const port = await get_free_port();
|
const port = existingPort ?? await get_free_port();
|
||||||
const chrome = await launch_chrome(profileDir, port);
|
const chrome = reusing ? null : await launch_chrome(profileDir, port);
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
let targetId: string | null = null;
|
||||||
try {
|
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);
|
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) {
|
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();
|
const start = Date.now();
|
||||||
let last: CookieMap = {};
|
let last: CookieMap = {};
|
||||||
|
|
||||||
@@ -240,14 +139,14 @@ async function fetch_google_cookies_via_cdp(
|
|||||||
{ sessionId, timeoutMs: 10_000 },
|
{ sessionId, timeoutMs: 10_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const m: CookieMap = {};
|
const cookieMap: CookieMap = {};
|
||||||
for (const c of cookies) {
|
for (const cookie of cookies) {
|
||||||
if (c?.name && typeof c.value === 'string') m[c.name] = c.value;
|
if (cookie?.name && typeof cookie.value === 'string') cookieMap[cookie.name] = cookie.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
last = m;
|
last = cookieMap;
|
||||||
if (await is_gemini_session_ready(m, verbose)) {
|
if (await is_gemini_session_ready(cookieMap, verbose)) {
|
||||||
return m;
|
return cookieMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(1000);
|
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(', ')}`);
|
throw new Error(`Timed out waiting for a valid Gemini session. Last keys: ${Object.keys(last).join(', ')}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (cdp) {
|
if (cdp) {
|
||||||
try {
|
if (reusing && targetId) {
|
||||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
try {
|
||||||
} catch {}
|
await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 });
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
cdp.close();
|
cdp.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (chrome) killChrome(chrome);
|
||||||
chrome.kill('SIGTERM');
|
|
||||||
} catch {}
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!chrome.killed) {
|
|
||||||
try {
|
|
||||||
chrome.kill('SIGKILL');
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}, 2_000).unref?.();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
|
||||||
|
|
||||||
const filtered: CookieMap = {};
|
const filtered: CookieMap = {};
|
||||||
for (const [k, v] of Object.entries(cookies)) {
|
for (const [key, value] of Object.entries(cookies)) {
|
||||||
if (typeof v === 'string' && v.length > 0) filtered[k] = v;
|
if (typeof value === 'string' && value.length > 0) filtered[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp');
|
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 {
|
||||||
import fs from "node:fs";
|
CdpConnection,
|
||||||
import { mkdir } from "node:fs/promises";
|
findChromeExecutable as findChromeExecutableBase,
|
||||||
import net from "node:net";
|
findExistingChromeDebugPort,
|
||||||
|
getFreePort,
|
||||||
|
killChrome,
|
||||||
|
launchChrome as launchChromeBase,
|
||||||
|
openPageSession,
|
||||||
|
sleep,
|
||||||
|
waitForChromeDebugPort,
|
||||||
|
type PlatformCandidates,
|
||||||
|
} from "baoyu-chrome-cdp";
|
||||||
|
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
|
||||||
import { read_cookie_file, write_cookie_file } from "./cookie-file.js";
|
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 { X_COOKIE_NAMES, X_REQUIRED_COOKIES, X_LOGIN_URL, X_USER_DATA_DIR } from "./constants.js";
|
||||||
import type { CookieLike } from "./types.js";
|
import type { CookieLike } from "./types.js";
|
||||||
|
|
||||||
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
const CHROME_CANDIDATES_FULL: PlatformCandidates = {
|
||||||
|
darwin: [
|
||||||
function sleep(ms: number): Promise<void> {
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
"/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",
|
||||||
async function fetchWithTimeout(
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||||
url: string,
|
],
|
||||||
init: RequestInit & { timeoutMs?: number } = {}
|
win32: [
|
||||||
): Promise<Response> {
|
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||||
const { timeoutMs, ...rest } = init;
|
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||||
if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest);
|
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||||
|
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||||
const ctl = new AbortController();
|
],
|
||||||
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
default: [
|
||||||
try {
|
"/usr/bin/google-chrome",
|
||||||
return await fetch(url, { ...rest, signal: ctl.signal });
|
"/usr/bin/google-chrome-stable",
|
||||||
} finally {
|
"/usr/bin/chromium",
|
||||||
clearTimeout(t);
|
"/usr/bin/chromium-browser",
|
||||||
}
|
"/snap/bin/chromium",
|
||||||
}
|
"/usr/bin/microsoft-edge",
|
||||||
|
],
|
||||||
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)));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function findChromeExecutable(): string | null {
|
function findChromeExecutable(): string | null {
|
||||||
const override = process.env.X_CHROME_PATH?.trim();
|
return findChromeExecutableBase({
|
||||||
if (override && fs.existsSync(override)) return override;
|
candidates: CHROME_CANDIDATES_FULL,
|
||||||
|
envNames: ["X_CHROME_PATH"],
|
||||||
const candidates: string[] = [];
|
}) ?? null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
async function launchChrome(profileDir: string, port: number) {
|
||||||
const start = Date.now();
|
const chromePath = findChromeExecutable();
|
||||||
while (Date.now() - start < timeoutMs) {
|
if (!chromePath) throw new Error("Chrome executable not found.");
|
||||||
try {
|
return await launchChromeBase({
|
||||||
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 });
|
chromePath,
|
||||||
if (!res.ok) throw new Error(`status=${res.status}`);
|
profileDir,
|
||||||
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
port,
|
||||||
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
url: X_LOGIN_URL,
|
||||||
} catch {}
|
extraArgs: ["--disable-popup-blocking"],
|
||||||
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 fetchXCookiesViaCdp(
|
async function fetchXCookiesViaCdp(
|
||||||
@@ -212,25 +67,33 @@ async function fetchXCookiesViaCdp(
|
|||||||
verbose: boolean,
|
verbose: boolean,
|
||||||
log?: (message: string) => void
|
log?: (message: string) => void
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
await mkdir(profileDir, { recursive: true });
|
const existingPort = await findExistingChromeDebugPort({ profileDir });
|
||||||
|
const reusing = existingPort !== null;
|
||||||
const port = await getFreePort();
|
const port = existingPort ?? await getFreePort("X_DEBUG_PORT");
|
||||||
const chrome = await launchChrome(profileDir, port);
|
const chrome = reusing ? null : await launchChrome(profileDir, port);
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
let targetId: string | null = null;
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||||
cdp = await CdpConnection.connect(wsUrl, 15_000);
|
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,
|
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 });
|
const { sessionId } = page;
|
||||||
await cdp.send("Network.enable", {}, { sessionId });
|
targetId = page.targetId;
|
||||||
|
|
||||||
if (verbose) {
|
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();
|
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(", ")}`);
|
throw new Error(`Timed out waiting for X cookies. Last keys: ${Object.keys(last).join(", ")}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (cdp) {
|
if (cdp) {
|
||||||
try {
|
if (reusing && targetId) {
|
||||||
await cdp.send("Browser.close", {}, { timeoutMs: 5_000 });
|
try {
|
||||||
} catch {}
|
await cdp.send("Target.closeTarget", { targetId }, { timeoutMs: 5_000 });
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await cdp.send("Browser.close", {}, { timeoutMs: 5_000 });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
cdp.close();
|
cdp.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (chrome) killChrome(chrome);
|
||||||
chrome.kill("SIGTERM");
|
|
||||||
} catch {}
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!chrome.killed) {
|
|
||||||
try {
|
|
||||||
chrome.kill("SIGKILL");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}, 2_000).unref?.();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { execSync, type ChildProcess } 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 process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
import {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
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> {
|
export async function getFreePort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return await getFreePortBase('WECHAT_BROWSER_DEBUG_PORT');
|
||||||
const server = net.createServer();
|
}
|
||||||
server.unref();
|
|
||||||
server.on('error', reject);
|
export function findChromeExecutable(chromePathOverride?: string): string | undefined {
|
||||||
server.listen(0, '127.0.0.1', () => {
|
if (chromePathOverride?.trim()) return chromePathOverride.trim();
|
||||||
const address = server.address();
|
return findChromeExecutableBase({
|
||||||
if (!address || typeof address === 'string') {
|
candidates: CHROME_CANDIDATES_FULL,
|
||||||
server.close(() => reject(new Error('Unable to allocate a free TCP port.')));
|
envNames: ['WECHAT_BROWSER_CHROME_PATH'],
|
||||||
return;
|
|
||||||
}
|
|
||||||
const port = address.port;
|
|
||||||
server.close((err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
export function getDefaultProfileDir(): string {
|
||||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim();
|
return resolveSharedChromeProfileDir({
|
||||||
if (override) return path.resolve(override);
|
envNames: ['BAOYU_CHROME_PROFILE_DIR', 'WECHAT_BROWSER_PROFILE_DIR'],
|
||||||
const base = process.platform === 'darwin'
|
wslWindowsHome: getWslWindowsHome(),
|
||||||
? 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 {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChromeSession {
|
export interface ChromeSession {
|
||||||
@@ -177,56 +88,38 @@ export interface ChromeSession {
|
|||||||
|
|
||||||
export async function tryConnectExisting(port: number): Promise<CdpConnection | null> {
|
export async function tryConnectExisting(port: number): Promise<CdpConnection | null> {
|
||||||
try {
|
try {
|
||||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
const wsUrl = await waitForChromeDebugPort(port, 5_000, { includeLastError: true });
|
||||||
if (version.webSocketDebuggerUrl) {
|
return await CdpConnection.connect(wsUrl, 5_000);
|
||||||
const cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, 5_000);
|
} catch {
|
||||||
return cdp;
|
return null;
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findExistingChromeDebugPort(): Promise<number | null> {
|
export async function findExistingChromeDebugPort(profileDir = getDefaultProfileDir()): Promise<number | null> {
|
||||||
if (process.platform !== 'darwin' && process.platform !== 'linux') return null;
|
return await findExistingChromeDebugPortBase({ profileDir });
|
||||||
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 launchChrome(url: string, profileDir?: string): Promise<{ cdp: CdpConnection; chrome: ReturnType<typeof spawn> }> {
|
export async function launchChrome(
|
||||||
const chromePath = findChromeExecutable();
|
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.');
|
if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.');
|
||||||
|
|
||||||
const profile = profileDir ?? getDefaultProfileDir();
|
const profile = profileDir ?? getDefaultProfileDir();
|
||||||
await mkdir(profile, { recursive: true });
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
console.log(`[cdp] Launching Chrome (profile: ${profile})`);
|
console.log(`[cdp] Launching Chrome (profile: ${profile})`);
|
||||||
|
|
||||||
const chrome = spawn(chromePath, [
|
const chrome = await launchChromeBase({
|
||||||
`--remote-debugging-port=${port}`,
|
chromePath,
|
||||||
`--user-data-dir=${profile}`,
|
profileDir: profile,
|
||||||
'--no-first-run',
|
port,
|
||||||
'--no-default-browser-check',
|
|
||||||
'--disable-blink-features=AutomationControlled',
|
|
||||||
'--start-maximized',
|
|
||||||
url,
|
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);
|
const cdp = await CdpConnection.connect(wsUrl, 30_000);
|
||||||
|
|
||||||
return { cdp, chrome };
|
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> {
|
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');
|
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}`);
|
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('Page.enable', {}, { sessionId });
|
||||||
await cdp.send('Runtime.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 };
|
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();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
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;
|
if (newTab) return newTab.targetId;
|
||||||
await sleep(500);
|
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> {
|
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: `
|
expression: `
|
||||||
(function() {
|
(function() {
|
||||||
const el = document.querySelector('${selector}');
|
const el = document.querySelector('${selector}');
|
||||||
@@ -272,23 +177,46 @@ export async function clickElement(session: ChromeSession, selector: string): Pr
|
|||||||
returnByValue: true,
|
returnByValue: true,
|
||||||
}, { sessionId: session.sessionId });
|
}, { sessionId: session.sessionId });
|
||||||
|
|
||||||
if (posResult.result.value === 'null') throw new Error(`Element not found: ${selector}`);
|
if (position.result.value === 'null') throw new Error(`Element not found: ${selector}`);
|
||||||
const pos = JSON.parse(posResult.result.value);
|
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 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> {
|
export async function typeText(session: ChromeSession, text: string): Promise<void> {
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let index = 0; index < lines.length; index += 1) {
|
||||||
if (lines[i].length > 0) {
|
const line = lines[index];
|
||||||
await session.cdp.send('Input.insertText', { text: lines[i] }, { sessionId: session.sessionId });
|
if (line.length > 0) {
|
||||||
|
await session.cdp.send('Input.insertText', { text: line }, { sessionId: session.sessionId });
|
||||||
}
|
}
|
||||||
if (i < lines.length - 1) {
|
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', {
|
||||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId });
|
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);
|
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> {
|
export async function pasteFromClipboard(session: ChromeSession): Promise<void> {
|
||||||
const modifiers = process.platform === 'darwin' ? 4 : 2;
|
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', {
|
||||||
await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId });
|
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> {
|
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 fs from 'node:fs';
|
||||||
import { mkdir, readdir } from 'node:fs/promises';
|
import { readdir } from 'node:fs/promises';
|
||||||
import net from 'node:net';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CdpConnection,
|
||||||
|
findChromeExecutable,
|
||||||
|
getDefaultProfileDir,
|
||||||
|
launchChrome,
|
||||||
|
sleep,
|
||||||
|
} from './cdp.ts';
|
||||||
|
|
||||||
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||||
|
|
||||||
interface MarkdownMeta {
|
interface MarkdownMeta {
|
||||||
@@ -104,195 +109,6 @@ async function loadImagesFromDir(dir: string): Promise<string[]> {
|
|||||||
return images;
|
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 {
|
interface WeChatBrowserOptions {
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: 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}`);
|
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.');
|
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})`);
|
console.log(`[wechat-browser] Launching Chrome (profile: ${profileDir})`);
|
||||||
|
|
||||||
const chrome = spawn(chromePath, [
|
const launched = await launchChrome(WECHAT_URL, profileDir, chromePath);
|
||||||
`--remote-debugging-port=${port}`,
|
const chrome = launched.chrome;
|
||||||
`--user-data-dir=${profileDir}`,
|
|
||||||
'--no-first-run',
|
|
||||||
'--no-default-browser-check',
|
|
||||||
'--disable-blink-features=AutomationControlled',
|
|
||||||
'--start-maximized',
|
|
||||||
WECHAT_URL,
|
|
||||||
], { stdio: 'ignore' });
|
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
cdp = launched.cdp;
|
||||||
cdp = await CdpConnection.connect(wsUrl, 30_000);
|
|
||||||
|
|
||||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
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'));
|
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 fs from 'node:fs';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
|
||||||
import {
|
import {
|
||||||
CdpConnection,
|
CdpConnection,
|
||||||
copyHtmlToClipboard,
|
copyHtmlToClipboard,
|
||||||
@@ -11,7 +9,7 @@ import {
|
|||||||
findChromeExecutable,
|
findChromeExecutable,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getDefaultProfileDir,
|
getDefaultProfileDir,
|
||||||
getFreePort,
|
launchChrome,
|
||||||
pasteFromClipboard,
|
pasteFromClipboard,
|
||||||
sleep,
|
sleep,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
@@ -74,36 +72,17 @@ export async function publishArticle(options: ArticleOptions): Promise<void> {
|
|||||||
await mkdir(profileDir, { recursive: true });
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
|
||||||
// Try reusing an existing Chrome instance with the same profile
|
// Try reusing an existing Chrome instance with the same profile
|
||||||
const existingPort = findExistingChromeDebugPort(profileDir);
|
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||||
let port: number;
|
let port: number;
|
||||||
let launched = false;
|
|
||||||
|
|
||||||
if (existingPort) {
|
if (existingPort) {
|
||||||
console.log(`[weibo-article] Found existing Chrome on port ${existingPort}, reusing...`);
|
console.log(`[weibo-article] Found existing Chrome on port ${existingPort}, reusing...`);
|
||||||
port = existingPort;
|
port = existingPort;
|
||||||
} else {
|
} 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.');
|
if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.');
|
||||||
|
|
||||||
port = await getFreePort();
|
port = await launchChrome(WEIBO_ARTICLE_URL, profileDir, chromePath);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -8,8 +7,8 @@ import {
|
|||||||
findChromeExecutable,
|
findChromeExecutable,
|
||||||
findExistingChromeDebugPort,
|
findExistingChromeDebugPort,
|
||||||
getDefaultProfileDir,
|
getDefaultProfileDir,
|
||||||
getFreePort,
|
|
||||||
killChromeByProfile,
|
killChromeByProfile,
|
||||||
|
launchChrome as launchWeiboChrome,
|
||||||
sleep,
|
sleep,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from './weibo-utils.js';
|
} from './weibo-utils.js';
|
||||||
@@ -37,32 +36,11 @@ export async function postToWeibo(options: WeiboPostOptions): Promise<void> {
|
|||||||
|
|
||||||
await mkdir(profileDir, { recursive: true });
|
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.');
|
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;
|
let port: number;
|
||||||
const existingPort = findExistingChromeDebugPort(profileDir);
|
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||||
|
|
||||||
if (existingPort) {
|
if (existingPort) {
|
||||||
console.log(`[weibo-post] Found existing Chrome on port ${existingPort}, checking health...`);
|
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...');
|
console.log('[weibo-post] Existing Chrome unresponsive, restarting...');
|
||||||
killChromeByProfile(profileDir);
|
killChromeByProfile(profileDir);
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
port = await launchChrome();
|
port = await launchWeiboChrome(WEIBO_HOME_URL, profileDir, chromePath);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
port = await launchChrome();
|
port = await launchWeiboChrome(WEIBO_HOME_URL, profileDir, chromePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
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 path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
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: [
|
darwin: [
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||||
@@ -23,183 +34,81 @@ export const CHROME_CANDIDATES = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function findChromeExecutable(): string | undefined {
|
let wslHome: string | null | undefined;
|
||||||
const override = process.env.WEIBO_BROWSER_CHROME_PATH?.trim();
|
function getWslWindowsHome(): string | null {
|
||||||
if (override && fs.existsSync(override)) return override;
|
if (wslHome !== undefined) return wslHome;
|
||||||
|
if (!process.env.WSL_DISTRO_NAME) {
|
||||||
const candidates = process.platform === 'darwin'
|
wslHome = null;
|
||||||
? CHROME_CANDIDATES.darwin
|
return null;
|
||||||
: process.platform === 'win32'
|
|
||||||
? CHROME_CANDIDATES.win32
|
|
||||||
: CHROME_CANDIDATES.default;
|
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (fs.existsSync(candidate)) return candidate;
|
|
||||||
}
|
}
|
||||||
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 {
|
export function findChromeExecutable(chromePathOverride?: string): string | undefined {
|
||||||
try {
|
if (chromePathOverride?.trim()) return chromePathOverride.trim();
|
||||||
const result = spawnSync('ps', ['aux'], { encoding: 'utf-8', timeout: 5000 });
|
return findChromeExecutableBase({
|
||||||
if (result.status !== 0 || !result.stdout) return null;
|
candidates: CHROME_CANDIDATES,
|
||||||
const lines = result.stdout.split('\n');
|
envNames: ['WEIBO_BROWSER_CHROME_PATH'],
|
||||||
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]);
|
export async function findExistingChromeDebugPort(profileDir: string): Promise<number | null> {
|
||||||
}
|
return await findExistingChromeDebugPortBase({ profileDir });
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function killChromeByProfile(profileDir: string): void {
|
export function killChromeByProfile(profileDir: string): void {
|
||||||
try {
|
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;
|
if (result.status !== 0 || !result.stdout) return;
|
||||||
for (const line of result.stdout.split('\n')) {
|
for (const line of result.stdout.split('\n')) {
|
||||||
if (!line.includes(profileDir) || !line.includes('--remote-debugging-port=')) continue;
|
if (!line.includes(profileDir) || !line.includes('--remote-debugging-port=')) continue;
|
||||||
const pidMatch = line.trim().split(/\s+/)[1];
|
const pid = line.trim().split(/\s+/)[1];
|
||||||
if (pidMatch) {
|
if (pid) {
|
||||||
try { process.kill(Number(pidMatch), 'SIGTERM'); } catch {}
|
try {
|
||||||
|
process.kill(Number(pid), 'SIGTERM');
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultProfileDir(): string {
|
export function getDefaultProfileDir(): string {
|
||||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.WEIBO_BROWSER_PROFILE_DIR?.trim();
|
return resolveSharedChromeProfileDir({
|
||||||
if (override) return path.resolve(override);
|
envNames: ['BAOYU_CHROME_PROFILE_DIR', 'WEIBO_BROWSER_PROFILE_DIR'],
|
||||||
const base = process.platform === 'darwin'
|
wslWindowsHome: getWslWindowsHome(),
|
||||||
? 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
export async function getFreePort(): Promise<number> {
|
||||||
const res = await fetch(url, { redirect: 'follow' });
|
return await getFreePortBase('WEIBO_BROWSER_DEBUG_PORT');
|
||||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
export async function launchChrome(url: string, profileDir: string, chromePathOverride?: string): Promise<number> {
|
||||||
const start = Date.now();
|
const chromePath = findChromeExecutable(chromePathOverride);
|
||||||
let lastError: unknown = null;
|
if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.');
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
const port = await getFreePort();
|
||||||
try {
|
console.log(`[weibo-cdp] Launching Chrome (profile: ${profileDir})`);
|
||||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
await launchChromeBase({
|
||||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
chromePath,
|
||||||
lastError = new Error('Missing webSocketDebuggerUrl');
|
profileDir,
|
||||||
} catch (error) {
|
port,
|
||||||
lastError = error;
|
url,
|
||||||
}
|
extraArgs: ['--disable-blink-features=AutomationControlled', '--start-maximized'],
|
||||||
await sleep(200);
|
});
|
||||||
}
|
return port;
|
||||||
|
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScriptDir(): string {
|
export function getScriptDir(): string {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "baoyu-post-to-x-scripts",
|
"name": "baoyu-post-to-x-scripts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
@@ -27,6 +28,8 @@
|
|||||||
|
|
||||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
"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=="],
|
"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=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^15.0.6",
|
"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 { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import process from 'node:process';
|
|
||||||
|
|
||||||
import { parseMarkdown } from './md-to-html.js';
|
import { parseMarkdown } from './md-to-html.js';
|
||||||
import {
|
import {
|
||||||
@@ -11,9 +8,10 @@ import {
|
|||||||
CdpConnection,
|
CdpConnection,
|
||||||
copyHtmlToClipboard,
|
copyHtmlToClipboard,
|
||||||
copyImageToClipboard,
|
copyImageToClipboard,
|
||||||
findChromeExecutable,
|
findExistingChromeDebugPort,
|
||||||
getDefaultProfileDir,
|
getDefaultProfileDir,
|
||||||
getFreePort,
|
launchChrome,
|
||||||
|
openPageSession,
|
||||||
pasteFromClipboard,
|
pasteFromClipboard,
|
||||||
sleep,
|
sleep,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
@@ -61,25 +59,6 @@ interface ArticleOptions {
|
|||||||
chromePath?: string;
|
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> {
|
export async function publishArticle(options: ArticleOptions): Promise<void> {
|
||||||
const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options;
|
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');
|
await writeFile(htmlPath, parsed.html, 'utf-8');
|
||||||
console.log(`[x-article] HTML saved to: ${htmlPath}`);
|
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 });
|
await mkdir(profileDir, { recursive: true });
|
||||||
const existingPort = await findExistingDebugPort(profileDir);
|
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||||
const port = existingPort ?? await getFreePort();
|
const reusing = existingPort !== null;
|
||||||
|
let port = existingPort ?? 0;
|
||||||
|
|
||||||
if (existingPort) {
|
if (reusing) {
|
||||||
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
|
console.log(`[x-article] Reusing existing Chrome instance on port ${port}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[x-article] Launching Chrome...`);
|
console.log(`[x-article] Launching Chrome...`);
|
||||||
const chromeArgs = [
|
const launched = await launchChrome(X_ARTICLES_URL, profileDir, CHROME_CANDIDATES_BASIC, options.chromePath);
|
||||||
`--remote-debugging-port=${port}`,
|
port = launched.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' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
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 });
|
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 60_000 });
|
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 60_000 });
|
||||||
|
|
||||||
// Get page target
|
const page = await openPageSession({
|
||||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
cdp,
|
||||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.startsWith(X_ARTICLES_URL));
|
reusing,
|
||||||
|
url: X_ARTICLES_URL,
|
||||||
if (!pageTarget) {
|
matchTarget: (target) => target.type === 'page' && target.url.startsWith(X_ARTICLES_URL),
|
||||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL });
|
enablePage: true,
|
||||||
pageTarget = { targetId, url: X_ARTICLES_URL, type: 'page' };
|
enableRuntime: true,
|
||||||
}
|
enableDom: true,
|
||||||
|
});
|
||||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
|
const { sessionId } = page;
|
||||||
|
|
||||||
await cdp.send('Page.enable', {}, { sessionId });
|
|
||||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
|
||||||
await cdp.send('DOM.enable', {}, { sessionId });
|
|
||||||
|
|
||||||
console.log('[x-article] Waiting for articles page...');
|
console.log('[x-article] Waiting for articles page...');
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
@@ -6,9 +5,10 @@ import {
|
|||||||
CHROME_CANDIDATES_FULL,
|
CHROME_CANDIDATES_FULL,
|
||||||
CdpConnection,
|
CdpConnection,
|
||||||
copyImageToClipboard,
|
copyImageToClipboard,
|
||||||
findChromeExecutable,
|
findExistingChromeDebugPort,
|
||||||
getDefaultProfileDir,
|
getDefaultProfileDir,
|
||||||
getFreePort,
|
launchChrome,
|
||||||
|
openPageSession,
|
||||||
pasteFromClipboard,
|
pasteFromClipboard,
|
||||||
sleep,
|
sleep,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
@@ -28,22 +28,20 @@ interface XBrowserOptions {
|
|||||||
export async function postToX(options: XBrowserOptions): Promise<void> {
|
export async function postToX(options: XBrowserOptions): Promise<void> {
|
||||||
const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
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 });
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
|
||||||
const port = await getFreePort();
|
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||||
console.log(`[x-browser] Launching Chrome (profile: ${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, [
|
if (reusing) console.log(`[x-browser] Reusing existing Chrome on port ${port}`);
|
||||||
`--remote-debugging-port=${port}`,
|
else console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
|
||||||
`--user-data-dir=${profileDir}`,
|
|
||||||
'--no-first-run',
|
|
||||||
'--no-default-browser-check',
|
|
||||||
'--start-maximized',
|
|
||||||
X_COMPOSE_URL,
|
|
||||||
], { stdio: 'ignore' });
|
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
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 });
|
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });
|
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');
|
const page = await openPageSession({
|
||||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
cdp,
|
||||||
|
reusing,
|
||||||
if (!pageTarget) {
|
url: X_COMPOSE_URL,
|
||||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL });
|
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
|
||||||
pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' };
|
enablePage: true,
|
||||||
}
|
enableRuntime: true,
|
||||||
|
});
|
||||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
|
const { sessionId } = page;
|
||||||
|
|
||||||
await cdp.send('Page.enable', {}, { sessionId });
|
|
||||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
|
||||||
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
|
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
|
||||||
|
|
||||||
console.log('[x-browser] Waiting for X editor...');
|
console.log('[x-browser] Waiting for X editor...');
|
||||||
@@ -192,7 +187,9 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
|
|||||||
if (cdp) {
|
if (cdp) {
|
||||||
cdp.close();
|
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 { mkdir } from 'node:fs/promises';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import {
|
import {
|
||||||
CHROME_CANDIDATES_FULL,
|
CHROME_CANDIDATES_FULL,
|
||||||
CdpConnection,
|
CdpConnection,
|
||||||
findChromeExecutable,
|
findExistingChromeDebugPort,
|
||||||
getDefaultProfileDir,
|
getDefaultProfileDir,
|
||||||
getFreePort,
|
killChrome,
|
||||||
|
launchChrome,
|
||||||
|
openPageSession,
|
||||||
sleep,
|
sleep,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from './x-utils.js';
|
} from './x-utils.js';
|
||||||
@@ -31,42 +32,39 @@ interface QuoteOptions {
|
|||||||
export async function quotePost(options: QuoteOptions): Promise<void> {
|
export async function quotePost(options: QuoteOptions): Promise<void> {
|
||||||
const { tweetUrl, comment, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
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 });
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
|
||||||
const port = await getFreePort();
|
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||||
console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
|
const reusing = existingPort !== null;
|
||||||
|
let port = existingPort ?? 0;
|
||||||
console.log(`[x-quote] Opening tweet: ${tweetUrl}`);
|
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, [
|
if (reusing) console.log(`[x-quote] Reusing existing Chrome on port ${port}`);
|
||||||
`--remote-debugging-port=${port}`,
|
else console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
|
||||||
`--user-data-dir=${profileDir}`,
|
|
||||||
'--no-first-run',
|
|
||||||
'--no-default-browser-check',
|
|
||||||
'--start-maximized',
|
|
||||||
tweetUrl,
|
|
||||||
], { stdio: 'ignore' });
|
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
let targetId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 });
|
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');
|
const page = await openPageSession({
|
||||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
cdp,
|
||||||
|
reusing,
|
||||||
if (!pageTarget) {
|
url: tweetUrl,
|
||||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: tweetUrl });
|
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
|
||||||
pageTarget = { targetId, url: tweetUrl, type: 'page' };
|
enablePage: true,
|
||||||
}
|
enableRuntime: true,
|
||||||
|
});
|
||||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
|
const { sessionId } = page;
|
||||||
|
targetId = page.targetId;
|
||||||
await cdp.send('Page.enable', {}, { sessionId });
|
|
||||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
|
||||||
|
|
||||||
console.log('[x-quote] Waiting for tweet to load...');
|
console.log('[x-quote] Waiting for tweet to load...');
|
||||||
await sleep(3000);
|
await sleep(3000);
|
||||||
@@ -175,14 +173,14 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (cdp) {
|
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();
|
cdp.close();
|
||||||
}
|
}
|
||||||
|
if (chrome) killChrome(chrome);
|
||||||
setTimeout(() => {
|
|
||||||
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
|
|
||||||
}, 2_000).unref?.();
|
|
||||||
try { chrome.kill('SIGTERM'); } catch {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { execSync, spawnSync } from 'node:child_process';
|
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 path from 'node:path';
|
||||||
import process from 'node:process';
|
import process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
export type PlatformCandidates = {
|
import {
|
||||||
darwin?: string[];
|
CdpConnection,
|
||||||
win32?: string[];
|
findChromeExecutable as findChromeExecutableBase,
|
||||||
default: string[];
|
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 = {
|
export const CHROME_CANDIDATES_BASIC: PlatformCandidates = {
|
||||||
darwin: [
|
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 {
|
export function findChromeExecutable(candidates: PlatformCandidates): string | undefined {
|
||||||
const override = process.env.X_BROWSER_CHROME_PATH?.trim();
|
return findChromeExecutableBase({
|
||||||
if (override && fs.existsSync(override)) return override;
|
candidates,
|
||||||
|
envNames: ['X_BROWSER_CHROME_PATH'],
|
||||||
for (const candidate of getCandidatesForPlatform(candidates)) {
|
});
|
||||||
if (fs.existsSync(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let _wslHome: string | null | undefined;
|
let _wslHome: string | null | undefined;
|
||||||
@@ -81,158 +80,39 @@ function getWslWindowsHome(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultProfileDir(): string {
|
export function getDefaultProfileDir(): string {
|
||||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.X_BROWSER_PROFILE_DIR?.trim();
|
return resolveSharedChromeProfileDir({
|
||||||
if (override) return path.resolve(override);
|
envNames: ['BAOYU_CHROME_PROFILE_DIR', 'X_BROWSER_PROFILE_DIR'],
|
||||||
const wslHome = getWslWindowsHome();
|
wslWindowsHome: 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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T = unknown>(url: string): Promise<T> {
|
export async function getFreePort(): Promise<number> {
|
||||||
const res = await fetch(url, { redirect: 'follow' });
|
return await getFreePortBase('X_BROWSER_DEBUG_PORT');
|
||||||
if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`);
|
|
||||||
return (await res.json()) as T;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(
|
export async function findExistingChromeDebugPort(profileDir: string): Promise<number | null> {
|
||||||
port: number,
|
return await findExistingChromeDebugPortBase({ profileDir });
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingRequest = {
|
export async function launchChrome(
|
||||||
resolve: (value: unknown) => void;
|
url: string,
|
||||||
reject: (error: Error) => void;
|
profileDir: string,
|
||||||
timer: ReturnType<typeof setTimeout> | null;
|
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 {
|
const port = await getFreePort();
|
||||||
private ws: WebSocket;
|
const chrome = await launchChromeBase({
|
||||||
private nextId = 0;
|
chromePath,
|
||||||
private pending = new Map<number, PendingRequest>();
|
profileDir,
|
||||||
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
port,
|
||||||
private defaultTimeoutMs: number;
|
url,
|
||||||
|
extraArgs: ['--start-maximized'],
|
||||||
|
});
|
||||||
|
|
||||||
private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) {
|
return { chrome, port };
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getScriptDir(): string {
|
export function getScriptDir(): string {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -6,9 +5,11 @@ import process from 'node:process';
|
|||||||
import {
|
import {
|
||||||
CHROME_CANDIDATES_FULL,
|
CHROME_CANDIDATES_FULL,
|
||||||
CdpConnection,
|
CdpConnection,
|
||||||
findChromeExecutable,
|
findExistingChromeDebugPort,
|
||||||
getDefaultProfileDir,
|
getDefaultProfileDir,
|
||||||
getFreePort,
|
killChrome,
|
||||||
|
launchChrome,
|
||||||
|
openPageSession,
|
||||||
sleep,
|
sleep,
|
||||||
waitForChromeDebugPort,
|
waitForChromeDebugPort,
|
||||||
} from './x-utils.js';
|
} from './x-utils.js';
|
||||||
@@ -27,9 +28,6 @@ interface XVideoOptions {
|
|||||||
export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
||||||
const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options;
|
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}`);
|
if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`);
|
||||||
|
|
||||||
const absVideoPath = path.resolve(videoPath);
|
const absVideoPath = path.resolve(videoPath);
|
||||||
@@ -37,37 +35,37 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
|||||||
|
|
||||||
await mkdir(profileDir, { recursive: true });
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
|
||||||
const port = await getFreePort();
|
const existingPort = await findExistingChromeDebugPort(profileDir);
|
||||||
console.log(`[x-video] Launching Chrome (profile: ${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, [
|
if (reusing) console.log(`[x-video] Reusing existing Chrome on port ${port}`);
|
||||||
`--remote-debugging-port=${port}`,
|
else console.log(`[x-video] Launching Chrome (profile: ${profileDir})`);
|
||||||
`--user-data-dir=${profileDir}`,
|
|
||||||
'--no-first-run',
|
|
||||||
'--no-default-browser-check',
|
|
||||||
'--start-maximized',
|
|
||||||
X_COMPOSE_URL,
|
|
||||||
], { stdio: 'ignore' });
|
|
||||||
|
|
||||||
let cdp: CdpConnection | null = null;
|
let cdp: CdpConnection | null = null;
|
||||||
|
let targetId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||||
cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 });
|
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');
|
const page = await openPageSession({
|
||||||
let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com'));
|
cdp,
|
||||||
|
reusing,
|
||||||
if (!pageTarget) {
|
url: X_COMPOSE_URL,
|
||||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL });
|
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
|
||||||
pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' };
|
enablePage: true,
|
||||||
}
|
enableRuntime: true,
|
||||||
|
enableDom: true,
|
||||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true });
|
});
|
||||||
|
const { sessionId } = page;
|
||||||
await cdp.send('Page.enable', {}, { sessionId });
|
targetId = page.targetId;
|
||||||
await cdp.send('Runtime.enable', {}, { sessionId });
|
|
||||||
await cdp.send('DOM.enable', {}, { sessionId });
|
|
||||||
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
|
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
|
||||||
|
|
||||||
console.log('[x-video] Waiting for X editor...');
|
console.log('[x-video] Waiting for X editor...');
|
||||||
@@ -182,15 +180,12 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (cdp) {
|
if (cdp) {
|
||||||
|
if (reusing && submit && targetId) {
|
||||||
|
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
|
||||||
|
}
|
||||||
cdp.close();
|
cdp.close();
|
||||||
}
|
}
|
||||||
// Don't kill Chrome in preview mode, let user review
|
if (chrome && submit) killChrome(chrome);
|
||||||
if (submit) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {}
|
|
||||||
}, 2_000).unref?.();
|
|
||||||
try { chrome.kill('SIGTERM'); } catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"name": "baoyu-url-to-markdown-scripts",
|
"name": "baoyu-url-to-markdown-scripts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||||
"defuddle": "^0.10.0",
|
"defuddle": "^0.10.0",
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
@@ -36,6 +37,8 @@
|
|||||||
|
|
||||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
"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=="],
|
"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=="],
|
"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 {
|
||||||
import fs from "node:fs";
|
CdpConnection,
|
||||||
import { mkdir, readFile } from "node:fs/promises";
|
findChromeExecutable as findChromeExecutableBase,
|
||||||
import net from "node:net";
|
findExistingChromeDebugPort,
|
||||||
import path from "node:path";
|
getFreePort,
|
||||||
import process from "node:process";
|
killChrome,
|
||||||
|
launchChrome as launchChromeBase,
|
||||||
|
sleep,
|
||||||
|
waitForChromeDebugPort,
|
||||||
|
type PlatformCandidates,
|
||||||
|
} from 'baoyu-chrome-cdp';
|
||||||
|
|
||||||
import { resolveUrlToMarkdownChromeProfileDir } from "./paths.js";
|
import { resolveUrlToMarkdownChromeProfileDir } from './paths.js';
|
||||||
import { CDP_CONNECT_TIMEOUT_MS, NETWORK_IDLE_TIMEOUT_MS } from "./constants.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> {
|
export { CdpConnection, getFreePort, killChrome, sleep, waitForChromeDebugPort };
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithTimeout(url: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<Response> {
|
export async function findExistingChromePort(): Promise<number | null> {
|
||||||
const { timeoutMs, ...rest } = init;
|
return await findExistingChromeDebugPort({
|
||||||
if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest);
|
profileDir: resolveUrlToMarkdownChromeProfileDir(),
|
||||||
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> {
|
|
||||||
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 {
|
export function findChromeExecutable(): string | null {
|
||||||
const override = process.env.URL_CHROME_PATH?.trim();
|
return findChromeExecutableBase({
|
||||||
if (override && fs.existsSync(override)) return override;
|
candidates: CHROME_CANDIDATES_FULL,
|
||||||
|
envNames: ['URL_CHROME_PATH'],
|
||||||
const candidates: string[] = [];
|
}) ?? null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
export async function launchChrome(url: string, port: number, headless = false) {
|
||||||
const start = Date.now();
|
const chromePath = findChromeExecutable();
|
||||||
while (Date.now() - start < timeoutMs) {
|
if (!chromePath) throw new Error('Chrome executable not found. Install Chrome or set URL_CHROME_PATH env.');
|
||||||
try {
|
|
||||||
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 });
|
return await launchChromeBase({
|
||||||
if (!res.ok) throw new Error(`status=${res.status}`);
|
chromePath,
|
||||||
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
profileDir: resolveUrlToMarkdownChromeProfileDir(),
|
||||||
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
port,
|
||||||
} catch {}
|
url,
|
||||||
await sleep(200);
|
headless,
|
||||||
}
|
extraArgs: ['--disable-popup-blocking'],
|
||||||
throw new Error("Chrome debug port not ready");
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function launchChrome(url: string, port: number, headless: boolean = false): Promise<ChildProcess> {
|
export async function waitForNetworkIdle(
|
||||||
const chrome = findChromeExecutable();
|
cdp: CdpConnection,
|
||||||
if (!chrome) throw new Error("Chrome executable not found. Install Chrome or set URL_CHROME_PATH env.");
|
sessionId: string,
|
||||||
const profileDir = resolveUrlToMarkdownChromeProfileDir();
|
timeoutMs: number = NETWORK_IDLE_TIMEOUT_MS,
|
||||||
await mkdir(profileDir, { recursive: true });
|
): Promise<void> {
|
||||||
|
|
||||||
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> {
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let pending = 0;
|
let pending = 0;
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
cdp.off("Network.requestWillBeSent", onRequest);
|
cdp.off('Network.requestWillBeSent', onRequest);
|
||||||
cdp.off("Network.loadingFinished", onFinish);
|
cdp.off('Network.loadingFinished', onFinish);
|
||||||
cdp.off("Network.loadingFailed", onFinish);
|
cdp.off('Network.loadingFailed', onFinish);
|
||||||
};
|
};
|
||||||
const done = () => { cleanup(); resolve(); };
|
const done = () => { cleanup(); resolve(); };
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
@@ -248,79 +87,93 @@ export async function waitForNetworkIdle(cdp: CdpConnection, sessionId: string,
|
|||||||
};
|
};
|
||||||
const onRequest = () => { pending++; resetTimer(); };
|
const onRequest = () => { pending++; resetTimer(); };
|
||||||
const onFinish = () => { pending = Math.max(0, pending - 1); if (pending <= 2) resetTimer(); };
|
const onFinish = () => { pending = Math.max(0, pending - 1); if (pending <= 2) resetTimer(); };
|
||||||
cdp.on("Network.requestWillBeSent", onRequest);
|
cdp.on('Network.requestWillBeSent', onRequest);
|
||||||
cdp.on("Network.loadingFinished", onFinish);
|
cdp.on('Network.loadingFinished', onFinish);
|
||||||
cdp.on("Network.loadingFailed", onFinish);
|
cdp.on('Network.loadingFailed', onFinish);
|
||||||
resetTimer();
|
resetTimer();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForPageLoad(cdp: CdpConnection, sessionId: string, timeoutMs: number = 30_000): Promise<void> {
|
export async function waitForPageLoad(
|
||||||
return new Promise((resolve, reject) => {
|
cdp: CdpConnection,
|
||||||
|
sessionId: string,
|
||||||
|
timeoutMs: number = 30_000,
|
||||||
|
): Promise<void> {
|
||||||
|
void sessionId;
|
||||||
|
return new Promise((resolve) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
cdp.off("Page.loadEventFired", handler);
|
cdp.off('Page.loadEventFired', handler);
|
||||||
resolve();
|
resolve();
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
cdp.off("Page.loadEventFired", handler);
|
cdp.off('Page.loadEventFired', handler);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
cdp.on("Page.loadEventFired", handler);
|
cdp.on('Page.loadEventFired', handler);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTargetAndAttach(cdp: CdpConnection, url: string): Promise<{ targetId: string; sessionId: string }> {
|
export async function createTargetAndAttach(
|
||||||
const { targetId } = await cdp.send<{ targetId: string }>("Target.createTarget", { url });
|
cdp: CdpConnection,
|
||||||
const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true });
|
url: string,
|
||||||
await cdp.send("Network.enable", {}, { sessionId });
|
): Promise<{ targetId: string; sessionId: string }> {
|
||||||
await cdp.send("Page.enable", {}, { sessionId });
|
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 };
|
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 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 handler = (params: unknown) => {
|
||||||
const p = params as { name?: string };
|
const event = params as { name?: string };
|
||||||
if (p.name === "load" || p.name === "DOMContentLoaded") {
|
if (event.name === 'load' || event.name === 'DOMContentLoaded') {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
cdp.off("Page.lifecycleEvent", handler);
|
cdp.off('Page.lifecycleEvent', handler);
|
||||||
resolve();
|
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;
|
await loadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function evaluateScript<T>(cdp: CdpConnection, sessionId: string, expression: string, timeoutMs: number = 30_000): Promise<T> {
|
export async function evaluateScript<T>(
|
||||||
const result = await cdp.send<{ result: { value?: T; type?: string; description?: string } }>(
|
cdp: CdpConnection,
|
||||||
"Runtime.evaluate",
|
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 },
|
{ expression, returnByValue: true, awaitPromise: true },
|
||||||
{ sessionId, timeoutMs }
|
{ sessionId, timeoutMs },
|
||||||
);
|
);
|
||||||
return result.result.value as T;
|
return result.result.value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function autoScroll(cdp: CdpConnection, sessionId: string, steps: number = 8, waitMs: number = 600): Promise<void> {
|
export async function autoScroll(
|
||||||
let lastHeight = await evaluateScript<number>(cdp, sessionId, "document.body.scrollHeight");
|
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++) {
|
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);
|
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;
|
if (newHeight === lastHeight) break;
|
||||||
lastHeight = newHeight;
|
lastHeight = newHeight;
|
||||||
}
|
}
|
||||||
await evaluateScript<void>(cdp, sessionId, "window.scrollTo(0, 0)");
|
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?.();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||||
"defuddle": "^0.10.0",
|
"defuddle": "^0.10.0",
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
|
|||||||
Reference in New Issue
Block a user