From 069c5dc7d7c48359edf7bb0636451b9834ef7133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Wed, 11 Mar 2026 19:38:59 -0500 Subject: [PATCH] refactor: unify skill cdp and release artifacts --- .claude/skills/release-skills/SKILL.md | 29 ++ .gitignore | 1 + .releaserc.yml | 7 + CLAUDE.md | 6 + packages/baoyu-chrome-cdp/package.json | 9 + packages/baoyu-chrome-cdp/src/index.ts | 408 ++++++++++++++++++ scripts/lib/skill-artifact.mjs | 200 +++++++++ scripts/prepare-skill-artifact.mjs | 64 +++ scripts/publish-skill-artifact.mjs | 298 +++++++++++++ .../baoyu-danger-gemini-web/scripts/bun.lock | 14 + .../utils/load-browser-cookies.ts | 288 ++++--------- .../scripts/package.json | 8 + .../scripts/bun.lock | 14 + .../scripts/cookies.ts | 296 ++++--------- .../scripts/package.json | 8 + skills/baoyu-post-to-wechat/scripts/bun.lock | 14 + skills/baoyu-post-to-wechat/scripts/cdp.ts | 370 +++++++--------- .../baoyu-post-to-wechat/scripts/package.json | 8 + .../scripts/wechat-browser.ts | 221 +--------- skills/baoyu-post-to-weibo/scripts/bun.lock | 31 ++ .../baoyu-post-to-weibo/scripts/package.json | 11 + .../scripts/weibo-article.ts | 29 +- .../baoyu-post-to-weibo/scripts/weibo-post.ts | 32 +- .../scripts/weibo-utils.ts | 229 +++------- skills/baoyu-post-to-x/scripts/bun.lock | 3 + skills/baoyu-post-to-x/scripts/package.json | 1 + skills/baoyu-post-to-x/scripts/x-article.ts | 77 +--- skills/baoyu-post-to-x/scripts/x-browser.ts | 55 ++- skills/baoyu-post-to-x/scripts/x-quote.ts | 66 ++- skills/baoyu-post-to-x/scripts/x-utils.ts | 208 ++------- skills/baoyu-post-to-x/scripts/x-video.ts | 67 ++- skills/baoyu-url-to-markdown/scripts/bun.lock | 3 + skills/baoyu-url-to-markdown/scripts/cdp.ts | 379 +++++----------- .../scripts/package.json | 1 + 34 files changed, 1822 insertions(+), 1633 deletions(-) create mode 100644 .releaserc.yml create mode 100644 packages/baoyu-chrome-cdp/package.json create mode 100644 packages/baoyu-chrome-cdp/src/index.ts create mode 100644 scripts/lib/skill-artifact.mjs create mode 100644 scripts/prepare-skill-artifact.mjs create mode 100644 scripts/publish-skill-artifact.mjs create mode 100644 skills/baoyu-danger-gemini-web/scripts/bun.lock create mode 100644 skills/baoyu-danger-gemini-web/scripts/package.json create mode 100644 skills/baoyu-danger-x-to-markdown/scripts/bun.lock create mode 100644 skills/baoyu-danger-x-to-markdown/scripts/package.json create mode 100644 skills/baoyu-post-to-wechat/scripts/bun.lock create mode 100644 skills/baoyu-post-to-wechat/scripts/package.json create mode 100644 skills/baoyu-post-to-weibo/scripts/bun.lock create mode 100644 skills/baoyu-post-to-weibo/scripts/package.json diff --git a/.claude/skills/release-skills/SKILL.md b/.claude/skills/release-skills/SKILL.md index a4a3bd8..a951854 100644 --- a/.claude/skills/release-skills/SKILL.md +++ b/.claude/skills/release-skills/SKILL.md @@ -35,6 +35,7 @@ Just run `/release-skills` - auto-detects your project configuration. ### Step 1: Detect Project Configuration 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): - `package.json` (Node.js) - `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 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**: Changelog files follow the pattern `CHANGELOG_{LANG}.md` or `CHANGELOG.{lang}.md`, where `{lang}` / `{LANG}` is a language or region code. diff --git a/.gitignore b/.gitignore index 7fc53b7..9ca54a5 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,4 @@ posts/ # ClawHub local state (current and legacy directory names from the official CLI) .clawhub/ .clawdhub/ +.worktrees/ diff --git a/.releaserc.yml b/.releaserc.yml new file mode 100644 index 0000000..e6dbccc --- /dev/null +++ b/.releaserc.yml @@ -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}" diff --git a/CLAUDE.md b/CLAUDE.md index 7531903..080610b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,8 @@ skills/ Top-level `scripts/` directory contains repository maintenance utilities: - `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 **Plugin Categories**: @@ -213,6 +215,8 @@ bash scripts/sync-clawhub.sh # sync one skill 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 **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. +If `.releaserc.yml` defines `release.hooks.prepare_artifact` / `publish_artifact`, use those hooks to build and publish the final skill artifact. + **Never skip**: 1. `CHANGELOG.md` + `CHANGELOG.zh.md` - Both must be updated 2. `marketplace.json` version bump diff --git a/packages/baoyu-chrome-cdp/package.json b/packages/baoyu-chrome-cdp/package.json new file mode 100644 index 0000000..0014ad3 --- /dev/null +++ b/packages/baoyu-chrome-cdp/package.json @@ -0,0 +1,9 @@ +{ + "name": "baoyu-chrome-cdp", + "private": true, + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/baoyu-chrome-cdp/src/index.ts b/packages/baoyu-chrome-cdp/src/index.ts new file mode 100644 index 0000000..1fcd241 --- /dev/null +++ b/packages/baoyu-chrome-cdp/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 | 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function getFreePort(fixedEnvName?: string): Promise { + 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 { + 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(url: string, options: FetchJsonOptions = {}): Promise { + 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 { + 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 { + 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 { + 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(); + private eventHandlers = new Map 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 { + const ws = new WebSocket(url); + await new Promise((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(method: string, params?: Record, options?: CdpSendOptions): Promise { + const id = ++this.nextId; + const message: Record = { 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((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 { + 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 { + 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 }; +} diff --git a/scripts/lib/skill-artifact.mjs b/scripts/lib/skill-artifact.mjs new file mode 100644 index 0000000..15411f4 --- /dev/null +++ b/scripts/lib/skill-artifact.mjs @@ -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}`}`; +} diff --git a/scripts/prepare-skill-artifact.mjs b/scripts/prepare-skill-artifact.mjs new file mode 100644 index 0000000..e519758 --- /dev/null +++ b/scripts/prepare-skill-artifact.mjs @@ -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 --out-dir + +Options: + --skill-dir Source skill directory + --out-dir Artifact output directory + -h, --help Show help`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/publish-skill-artifact.mjs b/scripts/publish-skill-artifact.mjs new file mode 100644 index 0000000..2d5f987 --- /dev/null +++ b/scripts/publish-skill-artifact.mjs @@ -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 --artifact-dir --version [options] + +Options: + --skill-dir Source skill directory (used for slug/display name) + --artifact-dir Prepared artifact directory + --version Version to publish + --changelog-file Release notes file + --registry Override registry base URL + --tags Comma-separated tags (default: latest) + --slug Override slug + --display-name 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); +}); diff --git a/skills/baoyu-danger-gemini-web/scripts/bun.lock b/skills/baoyu-danger-gemini-web/scripts/bun.lock new file mode 100644 index 0000000..2a9b3f7 --- /dev/null +++ b/skills/baoyu-danger-gemini-web/scripts/bun.lock @@ -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", {}], + } +} diff --git a/skills/baoyu-danger-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts b/skills/baoyu-danger-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts index 90a8220..97fa369 100644 --- a/skills/baoyu-danger-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts +++ b/skills/baoyu-danger-gemini-web/scripts/gemini-webapi/utils/load-browser-cookies.ts @@ -1,183 +1,76 @@ -import { spawn, type ChildProcess } from 'node:child_process'; -import fs from 'node:fs'; -import { mkdir } from 'node:fs/promises'; -import net from 'node:net'; import process from 'node:process'; +import { + CdpConnection, + findChromeExecutable as findChromeExecutableBase, + findExistingChromeDebugPort, + getFreePort, + killChrome, + launchChrome as launchChromeBase, + openPageSession, + sleep, + waitForChromeDebugPort, + type PlatformCandidates, +} from 'baoyu-chrome-cdp'; + import { Endpoint, Headers } from '../constants.js'; import { logger } from './logger.js'; -import { cookie_header, fetch_with_timeout, sleep } from './http.js'; +import { cookie_header, fetch_with_timeout } from './http.js'; import { read_cookie_file, type CookieMap, write_cookie_file } from './cookie-file.js'; import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js'; -type CdpSendOptions = { sessionId?: string; timeoutMs?: number }; +const GEMINI_APP_URL = 'https://gemini.google.com/app'; -class CdpConnection { - private ws: WebSocket; - private nextId = 0; - private pending = new Map< - number, - { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType | 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 { - const ws = new WebSocket(url); - await new Promise((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(method: string, params?: Record, opts?: CdpSendOptions): Promise { - const id = ++this.nextId; - const msg: Record = { 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((resolve, reject) => { - const t = - timeoutMs > 0 - ? setTimeout(() => { - this.pending.delete(id); - reject(new Error(`CDP timeout: ${method}`)); - }, timeoutMs) - : null; - this.pending.set(id, { resolve, reject, timer: t }); - this.ws.send(JSON.stringify(msg)); - }); - return out as T; - } - - close(): void { - try { - this.ws.close(); - } catch {} - } -} +const CHROME_CANDIDATES_FULL: PlatformCandidates = { + darwin: [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ], + win32: [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + ], + default: [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + '/usr/bin/microsoft-edge', + ], +}; async function get_free_port(): Promise { - const fixed = parseInt(process.env.GEMINI_WEB_DEBUG_PORT || '', 10); - if (fixed > 0) return fixed; - return await new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.unref(); - srv.on('error', reject); - srv.listen(0, '127.0.0.1', () => { - const addr = srv.address(); - if (!addr || typeof addr === 'string') { - srv.close(() => reject(new Error('Unable to allocate a free TCP port.'))); - return; - } - const port = addr.port; - srv.close((err) => (err ? reject(err) : resolve(port))); - }); - }); + return await getFreePort('GEMINI_WEB_DEBUG_PORT'); } function find_chrome_executable(): string | null { - const override = process.env.GEMINI_WEB_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - const candidates: string[] = []; - switch (process.platform) { - case 'darwin': - candidates.push( - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', - ); - break; - case 'win32': - candidates.push( - 'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', - 'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', - 'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe', - 'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe', - ); - break; - default: - candidates.push( - '/usr/bin/google-chrome', - '/usr/bin/google-chrome-stable', - '/usr/bin/chromium', - '/usr/bin/chromium-browser', - '/snap/bin/chromium', - '/usr/bin/microsoft-edge', - ); - break; - } - - for (const p of candidates) { - if (fs.existsSync(p)) return p; - } - return null; + return findChromeExecutableBase({ + candidates: CHROME_CANDIDATES_FULL, + envNames: ['GEMINI_WEB_CHROME_PATH'], + }) ?? null; } -async function wait_for_chrome_debug_port(port: number, timeoutMs: number): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch_with_timeout(`http://127.0.0.1:${port}/json/version`, { timeout_ms: 5_000 }); - if (!res.ok) throw new Error(`status=${res.status}`); - const j = (await res.json()) as { webSocketDebuggerUrl?: string }; - if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl; - } catch {} - await sleep(200); - } - throw new Error('Chrome debug port not ready'); +async function find_existing_chrome_debug_port(profileDir: string): Promise { + return await findExistingChromeDebugPort({ profileDir }); } -async function launch_chrome(profileDir: string, port: number): Promise { - const chrome = find_chrome_executable(); - if (!chrome) throw new Error('Chrome executable not found.'); +async function launch_chrome(profileDir: string, port: number) { + const chromePath = find_chrome_executable(); + if (!chromePath) throw new Error('Chrome executable not found.'); - const args = [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - '--no-first-run', - '--no-default-browser-check', - '--disable-popup-blocking', - 'https://gemini.google.com/app', - ]; - - return spawn(chrome, args, { stdio: 'ignore' }); + return await launchChromeBase({ + chromePath, + profileDir, + port, + url: GEMINI_APP_URL, + extraArgs: ['--disable-popup-blocking'], + }); } async function is_gemini_session_ready(cookies: CookieMap, verbose: boolean): Promise { @@ -209,27 +102,33 @@ async function fetch_google_cookies_via_cdp( timeoutMs: number, verbose: boolean, ): Promise { - await mkdir(profileDir, { recursive: true }); - - const port = await get_free_port(); - const chrome = await launch_chrome(profileDir, port); + const existingPort = await find_existing_chrome_debug_port(profileDir); + const reusing = existingPort !== null; + const port = existingPort ?? await get_free_port(); + const chrome = reusing ? null : await launch_chrome(profileDir, port); let cdp: CdpConnection | null = null; + let targetId: string | null = null; try { - const wsUrl = await wait_for_chrome_debug_port(port, 30_000); + const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); cdp = await CdpConnection.connect(wsUrl, 15_000); - const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { - url: 'https://gemini.google.com/app', - newWindow: true, - }); - const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true }); - await cdp.send('Network.enable', {}, { sessionId }); - if (verbose) { - logger.info('Chrome opened. If needed, complete Google login in the window. Waiting for a valid Gemini session...'); + logger.info(reusing + ? `Reusing existing Chrome on port ${port}. Waiting for a valid Gemini session...` + : 'Chrome opened. If needed, complete Google login in the window. Waiting for a valid Gemini session...'); } + const page = await openPageSession({ + cdp, + reusing, + url: GEMINI_APP_URL, + matchTarget: (target) => target.type === 'page' && target.url.includes('gemini.google.com'), + enableNetwork: true, + }); + const { sessionId } = page; + targetId = page.targetId; + const start = Date.now(); let last: CookieMap = {}; @@ -240,14 +139,14 @@ async function fetch_google_cookies_via_cdp( { sessionId, timeoutMs: 10_000 }, ); - const m: CookieMap = {}; - for (const c of cookies) { - if (c?.name && typeof c.value === 'string') m[c.name] = c.value; + const cookieMap: CookieMap = {}; + for (const cookie of cookies) { + if (cookie?.name && typeof cookie.value === 'string') cookieMap[cookie.name] = cookie.value; } - last = m; - if (await is_gemini_session_ready(m, verbose)) { - return m; + last = cookieMap; + if (await is_gemini_session_ready(cookieMap, verbose)) { + return cookieMap; } await sleep(1000); @@ -256,22 +155,19 @@ async function fetch_google_cookies_via_cdp( throw new Error(`Timed out waiting for a valid Gemini session. Last keys: ${Object.keys(last).join(', ')}`); } finally { if (cdp) { - try { - await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); - } catch {} + if (reusing && targetId) { + try { + await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); + } catch {} + } else { + try { + await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); + } catch {} + } cdp.close(); } - try { - chrome.kill('SIGTERM'); - } catch {} - setTimeout(() => { - if (!chrome.killed) { - try { - chrome.kill('SIGKILL'); - } catch {} - } - }, 2_000).unref?.(); + if (chrome) killChrome(chrome); } } @@ -286,8 +182,8 @@ export async function load_browser_cookies(domain_name: string = '', verbose: bo const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose); const filtered: CookieMap = {}; - for (const [k, v] of Object.entries(cookies)) { - if (typeof v === 'string' && v.length > 0) filtered[k] = v; + for (const [key, value] of Object.entries(cookies)) { + if (typeof value === 'string' && value.length > 0) filtered[key] = value; } await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp'); diff --git a/skills/baoyu-danger-gemini-web/scripts/package.json b/skills/baoyu-danger-gemini-web/scripts/package.json new file mode 100644 index 0000000..c239634 --- /dev/null +++ b/skills/baoyu-danger-gemini-web/scripts/package.json @@ -0,0 +1,8 @@ +{ + "name": "baoyu-danger-gemini-web-scripts", + "private": true, + "type": "module", + "dependencies": { + "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp" + } +} diff --git a/skills/baoyu-danger-x-to-markdown/scripts/bun.lock b/skills/baoyu-danger-x-to-markdown/scripts/bun.lock new file mode 100644 index 0000000..5a1d327 --- /dev/null +++ b/skills/baoyu-danger-x-to-markdown/scripts/bun.lock @@ -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", {}], + } +} diff --git a/skills/baoyu-danger-x-to-markdown/scripts/cookies.ts b/skills/baoyu-danger-x-to-markdown/scripts/cookies.ts index 12ac63e..3cedd94 100644 --- a/skills/baoyu-danger-x-to-markdown/scripts/cookies.ts +++ b/skills/baoyu-danger-x-to-markdown/scripts/cookies.ts @@ -1,7 +1,16 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import fs from "node:fs"; -import { mkdir } from "node:fs/promises"; -import net from "node:net"; +import { + CdpConnection, + findChromeExecutable as findChromeExecutableBase, + findExistingChromeDebugPort, + getFreePort, + killChrome, + launchChrome as launchChromeBase, + openPageSession, + sleep, + waitForChromeDebugPort, + type PlatformCandidates, +} from "baoyu-chrome-cdp"; + import process from "node:process"; import { read_cookie_file, write_cookie_file } from "./cookie-file.js"; @@ -9,201 +18,47 @@ import { resolveXToMarkdownCookiePath } from "./paths.js"; import { X_COOKIE_NAMES, X_REQUIRED_COOKIES, X_LOGIN_URL, X_USER_DATA_DIR } from "./constants.js"; import type { CookieLike } from "./types.js"; -type CdpSendOptions = { sessionId?: string; timeoutMs?: number }; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function fetchWithTimeout( - url: string, - init: RequestInit & { timeoutMs?: number } = {} -): Promise { - const { timeoutMs, ...rest } = init; - if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest); - - const ctl = new AbortController(); - const t = setTimeout(() => ctl.abort(), timeoutMs); - try { - return await fetch(url, { ...rest, signal: ctl.signal }); - } finally { - clearTimeout(t); - } -} - -class CdpConnection { - private ws: WebSocket; - private nextId = 0; - private pending = new Map< - number, - { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType | 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 { - const ws = new WebSocket(url); - await new Promise((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( - method: string, - params?: Record, - opts?: CdpSendOptions - ): Promise { - const id = ++this.nextId; - const msg: Record = { 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((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 { - const fixed = parseInt(process.env.X_DEBUG_PORT || "", 10); - if (fixed > 0) return fixed; - return await new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.unref(); - srv.on("error", reject); - srv.listen(0, "127.0.0.1", () => { - const addr = srv.address(); - if (!addr || typeof addr === "string") { - srv.close(() => reject(new Error("Unable to allocate a free TCP port."))); - return; - } - const port = addr.port; - srv.close((err) => (err ? reject(err) : resolve(port))); - }); - }); -} +const CHROME_CANDIDATES_FULL: PlatformCandidates = { + darwin: [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + ], + win32: [ + "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", + "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", + "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe", + ], + default: [ + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/usr/bin/microsoft-edge", + ], +}; function findChromeExecutable(): string | null { - const override = process.env.X_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - const candidates: string[] = []; - switch (process.platform) { - case "darwin": - candidates.push( - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", - "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" - ); - break; - case "win32": - candidates.push( - "C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", - "C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", - "C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe", - "C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe" - ); - break; - default: - candidates.push( - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - "/snap/bin/chromium", - "/usr/bin/microsoft-edge" - ); - break; - } - - for (const p of candidates) { - if (fs.existsSync(p)) return p; - } - return null; + return findChromeExecutableBase({ + candidates: CHROME_CANDIDATES_FULL, + envNames: ["X_CHROME_PATH"], + }) ?? null; } -async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 }); - if (!res.ok) throw new Error(`status=${res.status}`); - const j = (await res.json()) as { webSocketDebuggerUrl?: string }; - if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl; - } catch {} - await sleep(200); - } - throw new Error("Chrome debug port not ready"); -} - -async function launchChrome(profileDir: string, port: number): Promise { - const chrome = findChromeExecutable(); - if (!chrome) throw new Error("Chrome executable not found."); - - const args = [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-popup-blocking", - X_LOGIN_URL, - ]; - - return spawn(chrome, args, { stdio: "ignore" }); +async function launchChrome(profileDir: string, port: number) { + const chromePath = findChromeExecutable(); + if (!chromePath) throw new Error("Chrome executable not found."); + return await launchChromeBase({ + chromePath, + profileDir, + port, + url: X_LOGIN_URL, + extraArgs: ["--disable-popup-blocking"], + }); } async function fetchXCookiesViaCdp( @@ -212,25 +67,33 @@ async function fetchXCookiesViaCdp( verbose: boolean, log?: (message: string) => void ): Promise> { - await mkdir(profileDir, { recursive: true }); - - const port = await getFreePort(); - const chrome = await launchChrome(profileDir, port); + const existingPort = await findExistingChromeDebugPort({ profileDir }); + const reusing = existingPort !== null; + const port = existingPort ?? await getFreePort("X_DEBUG_PORT"); + const chrome = reusing ? null : await launchChrome(profileDir, port); let cdp: CdpConnection | null = null; + let targetId: string | null = null; try { - const wsUrl = await waitForChromeDebugPort(port, 30_000); + const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); cdp = await CdpConnection.connect(wsUrl, 15_000); - const { targetId } = await cdp.send<{ targetId: string }>("Target.createTarget", { + const page = await openPageSession({ + cdp, + reusing, url: X_LOGIN_URL, - newWindow: true, + matchTarget: (target) => target.type === "page" && ( + target.url.includes("x.com") || target.url.includes("twitter.com") + ), + enableNetwork: true, }); - const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true }); - await cdp.send("Network.enable", {}, { sessionId }); + const { sessionId } = page; + targetId = page.targetId; if (verbose) { - log?.("[x-cookies] Chrome opened. If needed, complete X login in the window. Waiting for cookies..."); + log?.(reusing + ? `[x-cookies] Reusing existing Chrome on port ${port}. Waiting for cookies...` + : "[x-cookies] Chrome opened. If needed, complete X login in the window. Waiting for cookies..."); } const start = Date.now(); @@ -255,22 +118,19 @@ async function fetchXCookiesViaCdp( throw new Error(`Timed out waiting for X cookies. Last keys: ${Object.keys(last).join(", ")}`); } finally { if (cdp) { - try { - await cdp.send("Browser.close", {}, { timeoutMs: 5_000 }); - } catch {} + if (reusing && targetId) { + try { + await cdp.send("Target.closeTarget", { targetId }, { timeoutMs: 5_000 }); + } catch {} + } else { + try { + await cdp.send("Browser.close", {}, { timeoutMs: 5_000 }); + } catch {} + } cdp.close(); } - try { - chrome.kill("SIGTERM"); - } catch {} - setTimeout(() => { - if (!chrome.killed) { - try { - chrome.kill("SIGKILL"); - } catch {} - } - }, 2_000).unref?.(); + if (chrome) killChrome(chrome); } } diff --git a/skills/baoyu-danger-x-to-markdown/scripts/package.json b/skills/baoyu-danger-x-to-markdown/scripts/package.json new file mode 100644 index 0000000..490828b --- /dev/null +++ b/skills/baoyu-danger-x-to-markdown/scripts/package.json @@ -0,0 +1,8 @@ +{ + "name": "baoyu-danger-x-to-markdown-scripts", + "private": true, + "type": "module", + "dependencies": { + "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp" + } +} diff --git a/skills/baoyu-post-to-wechat/scripts/bun.lock b/skills/baoyu-post-to-wechat/scripts/bun.lock new file mode 100644 index 0000000..8a0795d --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/bun.lock @@ -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", {}], + } +} diff --git a/skills/baoyu-post-to-wechat/scripts/cdp.ts b/skills/baoyu-post-to-wechat/scripts/cdp.ts index 9ed7e75..84432ad 100644 --- a/skills/baoyu-post-to-wechat/scripts/cdp.ts +++ b/skills/baoyu-post-to-wechat/scripts/cdp.ts @@ -1,172 +1,83 @@ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import { mkdir } from 'node:fs/promises'; -import net from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; +import { execSync, type ChildProcess } from 'node:child_process'; import process from 'node:process'; -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +import { + CdpConnection, + findChromeExecutable as findChromeExecutableBase, + findExistingChromeDebugPort as findExistingChromeDebugPortBase, + getFreePort as getFreePortBase, + launchChrome as launchChromeBase, + resolveSharedChromeProfileDir, + sleep, + waitForChromeDebugPort, + type PlatformCandidates, +} from 'baoyu-chrome-cdp'; + +export { CdpConnection, sleep, waitForChromeDebugPort }; + +const CHROME_CANDIDATES_FULL: PlatformCandidates = { + darwin: [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ], + win32: [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + ], + default: [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + '/usr/bin/microsoft-edge', + ], +}; + +let wslHome: string | null | undefined; +function getWslWindowsHome(): string | null { + if (wslHome !== undefined) return wslHome; + if (!process.env.WSL_DISTRO_NAME) { + wslHome = null; + return null; + } + try { + const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { + encoding: 'utf-8', + timeout: 5_000, + }).trim().replace(/\r/g, ''); + wslHome = execSync(`wslpath -u "${raw}"`, { + encoding: 'utf-8', + timeout: 5_000, + }).trim() || null; + } catch { + wslHome = null; + } + return wslHome; } export async function getFreePort(): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); - return; - } - const port = address.port; - server.close((err) => { - if (err) reject(err); - else resolve(port); - }); - }); + return await getFreePortBase('WECHAT_BROWSER_DEBUG_PORT'); +} + +export function findChromeExecutable(chromePathOverride?: string): string | undefined { + if (chromePathOverride?.trim()) return chromePathOverride.trim(); + return findChromeExecutableBase({ + candidates: CHROME_CANDIDATES_FULL, + envNames: ['WECHAT_BROWSER_CHROME_PATH'], }); } -export function findChromeExecutable(): string | undefined { - const override = process.env.WECHAT_BROWSER_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - const candidates: string[] = []; - switch (process.platform) { - case 'darwin': - candidates.push( - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', - '/Applications/Chromium.app/Contents/MacOS/Chromium', - ); - break; - case 'win32': - candidates.push( - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', - ); - break; - default: - candidates.push('/usr/bin/google-chrome', '/usr/bin/chromium'); - break; - } - - for (const p of candidates) { - if (fs.existsSync(p)) return p; - } - return undefined; -} - export function getDefaultProfileDir(): string { - const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim(); - if (override) return path.resolve(override); - const base = process.platform === 'darwin' - ? path.join(os.homedir(), 'Library', 'Application Support') - : process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); - return path.join(base, 'baoyu-skills', 'chrome-profile'); -} - -async function fetchJson(url: string): Promise { - 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 { - 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 void; reject: (e: Error) => void; timer: ReturnType | null }>(); - private eventHandlers = new Map 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 { - const ws = new WebSocket(url); - await new Promise((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(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { - const id = ++this.nextId; - const message: Record = { 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((resolve, reject) => { - const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null; - this.pending.set(id, { resolve, reject, timer }); - this.ws.send(JSON.stringify(message)); - }); - - return result as T; - } - - close(): void { - try { this.ws.close(); } catch {} - } + return resolveSharedChromeProfileDir({ + envNames: ['BAOYU_CHROME_PROFILE_DIR', 'WECHAT_BROWSER_PROFILE_DIR'], + wslWindowsHome: getWslWindowsHome(), + }); } export interface ChromeSession { @@ -177,56 +88,38 @@ export interface ChromeSession { export async function tryConnectExisting(port: number): Promise { try { - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); - if (version.webSocketDebuggerUrl) { - const cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, 5_000); - return cdp; - } - } catch {} - return null; + const wsUrl = await waitForChromeDebugPort(port, 5_000, { includeLastError: true }); + return await CdpConnection.connect(wsUrl, 5_000); + } catch { + return null; + } } -export async function findExistingChromeDebugPort(): Promise { - if (process.platform !== 'darwin' && process.platform !== 'linux') return null; - try { - const { execSync } = await import('node:child_process'); - const cmd = process.platform === 'darwin' - ? `lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -i 'google\\|chrome' | awk '{print $9}' | sed 's/.*://'` - : `ss -tlnp 2>/dev/null | grep -i chrome | awk '{print $4}' | sed 's/.*://'`; - const output = execSync(cmd, { encoding: 'utf-8', timeout: 5_000 }).trim(); - if (!output) return null; - const ports = output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p) && p > 0); - for (const port of ports) { - try { - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); - if (version.webSocketDebuggerUrl) return port; - } catch {} - } - } catch {} - return null; +export async function findExistingChromeDebugPort(profileDir = getDefaultProfileDir()): Promise { + return await findExistingChromeDebugPortBase({ profileDir }); } -export async function launchChrome(url: string, profileDir?: string): Promise<{ cdp: CdpConnection; chrome: ReturnType }> { - const chromePath = findChromeExecutable(); +export async function launchChrome( + url: string, + profileDir?: string, + chromePathOverride?: string, +): Promise<{ cdp: CdpConnection; chrome: ChildProcess }> { + const chromePath = findChromeExecutable(chromePathOverride); if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.'); const profile = profileDir ?? getDefaultProfileDir(); - await mkdir(profile, { recursive: true }); - const port = await getFreePort(); console.log(`[cdp] Launching Chrome (profile: ${profile})`); - const chrome = spawn(chromePath, [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profile}`, - '--no-first-run', - '--no-default-browser-check', - '--disable-blink-features=AutomationControlled', - '--start-maximized', + const chrome = await launchChromeBase({ + chromePath, + profileDir: profile, + port, url, - ], { stdio: 'ignore' }); + extraArgs: ['--disable-blink-features=AutomationControlled', '--start-maximized'], + }); - const wsUrl = await waitForChromeDebugPort(port, 30_000); + const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); const cdp = await CdpConnection.connect(wsUrl, 30_000); return { cdp, chrome }; @@ -234,11 +127,14 @@ export async function launchChrome(url: string, profileDir?: string): Promise<{ export async function getPageSession(cdp: CdpConnection, urlPattern: string): Promise { const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); - let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes(urlPattern)); + const pageTarget = targets.targetInfos.find((target) => target.type === 'page' && target.url.includes(urlPattern)); if (!pageTarget) throw new Error(`Page not found: ${urlPattern}`); - const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true }); + const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true, + }); await cdp.send('Page.enable', {}, { sessionId }); await cdp.send('Runtime.enable', {}, { sessionId }); @@ -247,11 +143,20 @@ export async function getPageSession(cdp: CdpConnection, urlPattern: string): Pr return { cdp, sessionId, targetId: pageTarget.targetId }; } -export async function waitForNewTab(cdp: CdpConnection, initialIds: Set, urlPattern: string, timeoutMs = 30_000): Promise { +export async function waitForNewTab( + cdp: CdpConnection, + initialIds: Set, + urlPattern: string, + timeoutMs = 30_000, +): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); - const newTab = targets.targetInfos.find(t => t.type === 'page' && !initialIds.has(t.targetId) && t.url.includes(urlPattern)); + const newTab = targets.targetInfos.find((target) => ( + target.type === 'page' && + !initialIds.has(target.targetId) && + target.url.includes(urlPattern) + )); if (newTab) return newTab.targetId; await sleep(500); } @@ -259,7 +164,7 @@ export async function waitForNewTab(cdp: CdpConnection, initialIds: Set, } export async function clickElement(session: ChromeSession, selector: string): Promise { - const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', { + const position = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', { expression: ` (function() { const el = document.querySelector('${selector}'); @@ -272,23 +177,46 @@ export async function clickElement(session: ChromeSession, selector: string): Pr returnByValue: true, }, { sessionId: session.sessionId }); - if (posResult.result.value === 'null') throw new Error(`Element not found: ${selector}`); - const pos = JSON.parse(posResult.result.value); + if (position.result.value === 'null') throw new Error(`Element not found: ${selector}`); + const pos = JSON.parse(position.result.value); - await session.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); + await session.cdp.send('Input.dispatchMouseEvent', { + type: 'mousePressed', + x: pos.x, + y: pos.y, + button: 'left', + clickCount: 1, + }, { sessionId: session.sessionId }); await sleep(50); - await session.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 }, { sessionId: session.sessionId }); + await session.cdp.send('Input.dispatchMouseEvent', { + type: 'mouseReleased', + x: pos.x, + y: pos.y, + button: 'left', + clickCount: 1, + }, { sessionId: session.sessionId }); } export async function typeText(session: ChromeSession, text: string): Promise { const lines = text.split('\n'); - for (let i = 0; i < lines.length; i++) { - if (lines[i].length > 0) { - await session.cdp.send('Input.insertText', { text: lines[i] }, { sessionId: session.sessionId }); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line.length > 0) { + await session.cdp.send('Input.insertText', { text: line }, { sessionId: session.sessionId }); } - if (i < lines.length - 1) { - await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId }); - await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }, { sessionId: session.sessionId }); + if (index < lines.length - 1) { + await session.cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'Enter', + code: 'Enter', + windowsVirtualKeyCode: 13, + }, { sessionId: session.sessionId }); + await session.cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'Enter', + code: 'Enter', + windowsVirtualKeyCode: 13, + }, { sessionId: session.sessionId }); } await sleep(30); } @@ -296,8 +224,20 @@ export async function typeText(session: ChromeSession, text: string): Promise { const modifiers = process.platform === 'darwin' ? 4 : 2; - await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); - await session.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'v', code: 'KeyV', modifiers, windowsVirtualKeyCode: 86 }, { sessionId: session.sessionId }); + await session.cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', + key: 'v', + code: 'KeyV', + modifiers, + windowsVirtualKeyCode: 86, + }, { sessionId: session.sessionId }); + await session.cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', + key: 'v', + code: 'KeyV', + modifiers, + windowsVirtualKeyCode: 86, + }, { sessionId: session.sessionId }); } export async function evaluate(session: ChromeSession, expression: string): Promise { diff --git a/skills/baoyu-post-to-wechat/scripts/package.json b/skills/baoyu-post-to-wechat/scripts/package.json new file mode 100644 index 0000000..3ea6184 --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/package.json @@ -0,0 +1,8 @@ +{ + "name": "baoyu-post-to-wechat-scripts", + "private": true, + "type": "module", + "dependencies": { + "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp" + } +} diff --git a/skills/baoyu-post-to-wechat/scripts/wechat-browser.ts b/skills/baoyu-post-to-wechat/scripts/wechat-browser.ts index d8f85eb..b922008 100644 --- a/skills/baoyu-post-to-wechat/scripts/wechat-browser.ts +++ b/skills/baoyu-post-to-wechat/scripts/wechat-browser.ts @@ -1,11 +1,16 @@ -import { execSync, spawn } from 'node:child_process'; import fs from 'node:fs'; -import { mkdir, readdir } from 'node:fs/promises'; -import net from 'node:net'; -import os from 'node:os'; +import { readdir } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; +import { + CdpConnection, + findChromeExecutable, + getDefaultProfileDir, + launchChrome, + sleep, +} from './cdp.ts'; + const WECHAT_URL = 'https://mp.weixin.qq.com/'; interface MarkdownMeta { @@ -104,195 +109,6 @@ async function loadImagesFromDir(dir: string): Promise { return images; } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function getFreePort(): Promise { - 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(url: string): Promise { - 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 { - 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 void; reject: (e: Error) => void; timer: ReturnType | null }>(); - private eventHandlers = new Map 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 { - const ws = new WebSocket(url); - await new Promise((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(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { - const id = ++this.nextId; - const message: Record = { 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((resolve, reject) => { - const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) : null; - this.pending.set(id, { resolve, reject, timer }); - this.ws.send(JSON.stringify(message)); - }); - - return result as T; - } - - close(): void { - try { this.ws.close(); } catch {} - } -} - interface WeChatBrowserOptions { title?: string; content?: string; @@ -348,29 +164,18 @@ export async function postToWeChat(options: WeChatBrowserOptions): Promise if (!fs.existsSync(img)) throw new Error(`Image not found: ${img}`); } - const chromePath = options.chromePath ?? findChromeExecutable(); + const chromePath = findChromeExecutable(options.chromePath); if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.'); - await mkdir(profileDir, { recursive: true }); - - const port = await getFreePort(); console.log(`[wechat-browser] Launching Chrome (profile: ${profileDir})`); - const chrome = spawn(chromePath, [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - '--no-first-run', - '--no-default-browser-check', - '--disable-blink-features=AutomationControlled', - '--start-maximized', - WECHAT_URL, - ], { stdio: 'ignore' }); + const launched = await launchChrome(WECHAT_URL, profileDir, chromePath); + const chrome = launched.chrome; let cdp: CdpConnection | null = null; try { - const wsUrl = await waitForChromeDebugPort(port, 30_000); - cdp = await CdpConnection.connect(wsUrl, 30_000); + cdp = launched.cdp; const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('mp.weixin.qq.com')); diff --git a/skills/baoyu-post-to-weibo/scripts/bun.lock b/skills/baoyu-post-to-weibo/scripts/bun.lock new file mode 100644 index 0000000..918cb98 --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/bun.lock @@ -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=="], + } +} diff --git a/skills/baoyu-post-to-weibo/scripts/package.json b/skills/baoyu-post-to-weibo/scripts/package.json new file mode 100644 index 0000000..6ffd8e7 --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/package.json @@ -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" + } +} diff --git a/skills/baoyu-post-to-weibo/scripts/weibo-article.ts b/skills/baoyu-post-to-weibo/scripts/weibo-article.ts index 2862c57..eea56b5 100644 --- a/skills/baoyu-post-to-weibo/scripts/weibo-article.ts +++ b/skills/baoyu-post-to-weibo/scripts/weibo-article.ts @@ -1,9 +1,7 @@ -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import process from 'node:process'; import { CdpConnection, copyHtmlToClipboard, @@ -11,7 +9,7 @@ import { findChromeExecutable, findExistingChromeDebugPort, getDefaultProfileDir, - getFreePort, + launchChrome, pasteFromClipboard, sleep, waitForChromeDebugPort, @@ -74,36 +72,17 @@ export async function publishArticle(options: ArticleOptions): Promise { await mkdir(profileDir, { recursive: true }); // Try reusing an existing Chrome instance with the same profile - const existingPort = findExistingChromeDebugPort(profileDir); + const existingPort = await findExistingChromeDebugPort(profileDir); let port: number; - let launched = false; if (existingPort) { console.log(`[weibo-article] Found existing Chrome on port ${existingPort}, reusing...`); port = existingPort; } else { - const chromePath = options.chromePath ?? findChromeExecutable(); + const chromePath = findChromeExecutable(options.chromePath); if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.'); - port = await getFreePort(); - console.log(`[weibo-article] Launching Chrome...`); - const chromeArgs = [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - '--no-first-run', - '--no-default-browser-check', - '--disable-blink-features=AutomationControlled', - '--start-maximized', - WEIBO_ARTICLE_URL, - ]; - - if (process.platform === 'darwin') { - const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, ''); - spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' }); - } else { - spawn(chromePath, chromeArgs, { stdio: 'ignore' }); - } - launched = true; + port = await launchChrome(WEIBO_ARTICLE_URL, profileDir, chromePath); } let cdp: CdpConnection | null = null; diff --git a/skills/baoyu-post-to-weibo/scripts/weibo-post.ts b/skills/baoyu-post-to-weibo/scripts/weibo-post.ts index 2e8d6dc..5066986 100644 --- a/skills/baoyu-post-to-weibo/scripts/weibo-post.ts +++ b/skills/baoyu-post-to-weibo/scripts/weibo-post.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; @@ -8,8 +7,8 @@ import { findChromeExecutable, findExistingChromeDebugPort, getDefaultProfileDir, - getFreePort, killChromeByProfile, + launchChrome as launchWeiboChrome, sleep, waitForChromeDebugPort, } from './weibo-utils.js'; @@ -37,32 +36,11 @@ export async function postToWeibo(options: WeiboPostOptions): Promise { await mkdir(profileDir, { recursive: true }); - const chromePath = options.chromePath ?? findChromeExecutable(); + const chromePath = findChromeExecutable(options.chromePath); if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.'); - const launchChrome = async (): Promise => { - const port = await getFreePort(); - console.log(`[weibo-post] Launching Chrome (profile: ${profileDir})`); - const chromeArgs = [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - '--no-first-run', - '--no-default-browser-check', - '--disable-blink-features=AutomationControlled', - '--start-maximized', - WEIBO_HOME_URL, - ]; - if (process.platform === 'darwin') { - const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, ''); - spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' }); - } else { - spawn(chromePath, chromeArgs, { stdio: 'ignore' }); - } - return port; - }; - let port: number; - const existingPort = findExistingChromeDebugPort(profileDir); + const existingPort = await findExistingChromeDebugPort(profileDir); if (existingPort) { console.log(`[weibo-post] Found existing Chrome on port ${existingPort}, checking health...`); @@ -77,10 +55,10 @@ export async function postToWeibo(options: WeiboPostOptions): Promise { console.log('[weibo-post] Existing Chrome unresponsive, restarting...'); killChromeByProfile(profileDir); await sleep(2000); - port = await launchChrome(); + port = await launchWeiboChrome(WEIBO_HOME_URL, profileDir, chromePath); } } else { - port = await launchChrome(); + port = await launchWeiboChrome(WEIBO_HOME_URL, profileDir, chromePath); } let cdp: CdpConnection | null = null; diff --git a/skills/baoyu-post-to-weibo/scripts/weibo-utils.ts b/skills/baoyu-post-to-weibo/scripts/weibo-utils.ts index c13d561..5609b9a 100644 --- a/skills/baoyu-post-to-weibo/scripts/weibo-utils.ts +++ b/skills/baoyu-post-to-weibo/scripts/weibo-utils.ts @@ -1,12 +1,23 @@ -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import net from 'node:net'; -import os from 'node:os'; +import { execSync, spawnSync } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; -export const CHROME_CANDIDATES = { +import { + CdpConnection, + findChromeExecutable as findChromeExecutableBase, + findExistingChromeDebugPort as findExistingChromeDebugPortBase, + getFreePort as getFreePortBase, + launchChrome as launchChromeBase, + resolveSharedChromeProfileDir, + sleep, + waitForChromeDebugPort, + type PlatformCandidates, +} from 'baoyu-chrome-cdp'; + +export { CdpConnection, sleep, waitForChromeDebugPort }; + +export const CHROME_CANDIDATES: PlatformCandidates = { darwin: [ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', @@ -23,183 +34,81 @@ export const CHROME_CANDIDATES = { ], }; -export function findChromeExecutable(): string | undefined { - const override = process.env.WEIBO_BROWSER_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - const candidates = process.platform === 'darwin' - ? CHROME_CANDIDATES.darwin - : process.platform === 'win32' - ? CHROME_CANDIDATES.win32 - : CHROME_CANDIDATES.default; - - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; +let wslHome: string | null | undefined; +function getWslWindowsHome(): string | null { + if (wslHome !== undefined) return wslHome; + if (!process.env.WSL_DISTRO_NAME) { + wslHome = null; + return null; } - return undefined; + try { + const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { + encoding: 'utf-8', + timeout: 5_000, + }).trim().replace(/\r/g, ''); + wslHome = execSync(`wslpath -u "${raw}"`, { + encoding: 'utf-8', + timeout: 5_000, + }).trim() || null; + } catch { + wslHome = null; + } + return wslHome; } -export function findExistingChromeDebugPort(profileDir: string): number | null { - try { - const result = spawnSync('ps', ['aux'], { encoding: 'utf-8', timeout: 5000 }); - if (result.status !== 0 || !result.stdout) return null; - const lines = result.stdout.split('\n'); - for (const line of lines) { - if (!line.includes('--remote-debugging-port=') || !line.includes(profileDir)) continue; - const portMatch = line.match(/--remote-debugging-port=(\d+)/); - if (portMatch) return Number(portMatch[1]); - } - } catch {} - return null; +export function findChromeExecutable(chromePathOverride?: string): string | undefined { + if (chromePathOverride?.trim()) return chromePathOverride.trim(); + return findChromeExecutableBase({ + candidates: CHROME_CANDIDATES, + envNames: ['WEIBO_BROWSER_CHROME_PATH'], + }); +} + +export async function findExistingChromeDebugPort(profileDir: string): Promise { + return await findExistingChromeDebugPortBase({ profileDir }); } export function killChromeByProfile(profileDir: string): void { try { - const result = spawnSync('ps', ['aux'], { encoding: 'utf-8', timeout: 5000 }); + const result = spawnSync('ps', ['aux'], { encoding: 'utf-8', timeout: 5_000 }); if (result.status !== 0 || !result.stdout) return; for (const line of result.stdout.split('\n')) { if (!line.includes(profileDir) || !line.includes('--remote-debugging-port=')) continue; - const pidMatch = line.trim().split(/\s+/)[1]; - if (pidMatch) { - try { process.kill(Number(pidMatch), 'SIGTERM'); } catch {} + const pid = line.trim().split(/\s+/)[1]; + if (pid) { + try { + process.kill(Number(pid), 'SIGTERM'); + } catch {} } } } catch {} } export function getDefaultProfileDir(): string { - const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.WEIBO_BROWSER_PROFILE_DIR?.trim(); - if (override) return path.resolve(override); - const base = process.platform === 'darwin' - ? path.join(os.homedir(), 'Library', 'Application Support') - : process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); - return path.join(base, 'baoyu-skills', 'chrome-profile'); -} - -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function getFreePort(): Promise { - const fixed = parseInt(process.env.WEIBO_BROWSER_DEBUG_PORT || '', 10); - if (fixed > 0) return fixed; - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); - return; - } - const port = address.port; - server.close((err) => { - if (err) reject(err); - else resolve(port); - }); - }); + return resolveSharedChromeProfileDir({ + envNames: ['BAOYU_CHROME_PROFILE_DIR', 'WEIBO_BROWSER_PROFILE_DIR'], + wslWindowsHome: getWslWindowsHome(), }); } -async function fetchJson(url: string): Promise { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); - return (await res.json()) as T; +export async function getFreePort(): Promise { + return await getFreePortBase('WEIBO_BROWSER_DEBUG_PORT'); } -export async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { - const start = Date.now(); - let lastError: unknown = null; +export async function launchChrome(url: string, profileDir: string, chromePathOverride?: string): Promise { + const chromePath = findChromeExecutable(chromePathOverride); + if (!chromePath) throw new Error('Chrome not found. Set WEIBO_BROWSER_CHROME_PATH env var.'); - while (Date.now() - start < timeoutMs) { - try { - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); - if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; - lastError = new Error('Missing webSocketDebuggerUrl'); - } catch (error) { - lastError = error; - } - await sleep(200); - } - - throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`); -} - -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer: ReturnType | null; -}; - -export class CdpConnection { - private ws: WebSocket; - private nextId = 0; - private pending = new Map(); - 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 { - const ws = new WebSocket(url); - await new Promise((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(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { - const id = ++this.nextId; - const message: Record = { 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((resolve, reject) => { - const timer = timeoutMs > 0 - ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) - : null; - this.pending.set(id, { resolve, reject, timer }); - this.ws.send(JSON.stringify(message)); - }); - - return result as T; - } - - close(): void { - try { this.ws.close(); } catch {} - } + const port = await getFreePort(); + console.log(`[weibo-cdp] Launching Chrome (profile: ${profileDir})`); + await launchChromeBase({ + chromePath, + profileDir, + port, + url, + extraArgs: ['--disable-blink-features=AutomationControlled', '--start-maximized'], + }); + return port; } export function getScriptDir(): string { diff --git a/skills/baoyu-post-to-x/scripts/bun.lock b/skills/baoyu-post-to-x/scripts/bun.lock index a8637f0..edd677e 100644 --- a/skills/baoyu-post-to-x/scripts/bun.lock +++ b/skills/baoyu-post-to-x/scripts/bun.lock @@ -4,6 +4,7 @@ "": { "name": "baoyu-post-to-x-scripts", "dependencies": { + "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "marked": "^15.0.6", @@ -27,6 +28,8 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], diff --git a/skills/baoyu-post-to-x/scripts/package.json b/skills/baoyu-post-to-x/scripts/package.json index 32d0772..5b81600 100644 --- a/skills/baoyu-post-to-x/scripts/package.json +++ b/skills/baoyu-post-to-x/scripts/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "dependencies": { + "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "marked": "^15.0.6", diff --git a/skills/baoyu-post-to-x/scripts/x-article.ts b/skills/baoyu-post-to-x/scripts/x-article.ts index b9783a1..f34e5ac 100644 --- a/skills/baoyu-post-to-x/scripts/x-article.ts +++ b/skills/baoyu-post-to-x/scripts/x-article.ts @@ -1,9 +1,6 @@ -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import process from 'node:process'; import { parseMarkdown } from './md-to-html.js'; import { @@ -11,9 +8,10 @@ import { CdpConnection, copyHtmlToClipboard, copyImageToClipboard, - findChromeExecutable, + findExistingChromeDebugPort, getDefaultProfileDir, - getFreePort, + launchChrome, + openPageSession, pasteFromClipboard, sleep, waitForChromeDebugPort, @@ -61,25 +59,6 @@ interface ArticleOptions { chromePath?: string; } -async function findExistingDebugPort(profileDir: string): Promise { - 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 { const { markdownPath, submit = false, profileDir = getDefaultProfileDir() } = options; @@ -98,31 +77,17 @@ export async function publishArticle(options: ArticleOptions): Promise { await writeFile(htmlPath, parsed.html, 'utf-8'); console.log(`[x-article] HTML saved to: ${htmlPath}`); - const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_BASIC); - if (!chromePath) throw new Error('Chrome not found'); - await mkdir(profileDir, { recursive: true }); - const existingPort = await findExistingDebugPort(profileDir); - const port = existingPort ?? await getFreePort(); + const existingPort = await findExistingChromeDebugPort(profileDir); + const reusing = existingPort !== null; + let port = existingPort ?? 0; - if (existingPort) { + if (reusing) { console.log(`[x-article] Reusing existing Chrome instance on port ${port}`); } else { console.log(`[x-article] Launching Chrome...`); - const chromeArgs = [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - '--no-first-run', - '--no-default-browser-check', - '--start-maximized', - X_ARTICLES_URL, - ]; - if (process.platform === 'darwin') { - const appPath = chromePath.replace(/\/Contents\/MacOS\/Google Chrome$/, ''); - spawn('open', ['-na', appPath, '--args', ...chromeArgs], { stdio: 'ignore' }); - } else { - spawn(chromePath, chromeArgs, { stdio: 'ignore' }); - } + const launched = await launchChrome(X_ARTICLES_URL, profileDir, CHROME_CANDIDATES_BASIC, options.chromePath); + port = launched.port; } let cdp: CdpConnection | null = null; @@ -131,20 +96,16 @@ export async function publishArticle(options: ArticleOptions): Promise { const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 60_000 }); - // Get page target - const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); - let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.startsWith(X_ARTICLES_URL)); - - if (!pageTarget) { - const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_ARTICLES_URL }); - pageTarget = { targetId, url: X_ARTICLES_URL, type: 'page' }; - } - - const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true }); - - await cdp.send('Page.enable', {}, { sessionId }); - await cdp.send('Runtime.enable', {}, { sessionId }); - await cdp.send('DOM.enable', {}, { sessionId }); + const page = await openPageSession({ + cdp, + reusing, + url: X_ARTICLES_URL, + matchTarget: (target) => target.type === 'page' && target.url.startsWith(X_ARTICLES_URL), + enablePage: true, + enableRuntime: true, + enableDom: true, + }); + const { sessionId } = page; console.log('[x-article] Waiting for articles page...'); await sleep(1000); diff --git a/skills/baoyu-post-to-x/scripts/x-browser.ts b/skills/baoyu-post-to-x/scripts/x-browser.ts index 451fa3c..0c1b390 100644 --- a/skills/baoyu-post-to-x/scripts/x-browser.ts +++ b/skills/baoyu-post-to-x/scripts/x-browser.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import { mkdir } from 'node:fs/promises'; import process from 'node:process'; @@ -6,9 +5,10 @@ import { CHROME_CANDIDATES_FULL, CdpConnection, copyImageToClipboard, - findChromeExecutable, + findExistingChromeDebugPort, getDefaultProfileDir, - getFreePort, + launchChrome, + openPageSession, pasteFromClipboard, sleep, waitForChromeDebugPort, @@ -28,22 +28,20 @@ interface XBrowserOptions { export async function postToX(options: XBrowserOptions): Promise { const { text, images = [], submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; - const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL); - if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.'); - await mkdir(profileDir, { recursive: true }); - const port = await getFreePort(); - console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`); + const existingPort = await findExistingChromeDebugPort(profileDir); + const reusing = existingPort !== null; + let port = existingPort ?? 0; + let chrome: Awaited>['chrome'] | null = null; + if (!reusing) { + const launched = await launchChrome(X_COMPOSE_URL, profileDir, CHROME_CANDIDATES_FULL, options.chromePath); + port = launched.port; + chrome = launched.chrome; + } - const chrome = spawn(chromePath, [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - '--no-first-run', - '--no-default-browser-check', - '--start-maximized', - X_COMPOSE_URL, - ], { stdio: 'ignore' }); + if (reusing) console.log(`[x-browser] Reusing existing Chrome on port ${port}`); + else console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`); let cdp: CdpConnection | null = null; @@ -51,18 +49,15 @@ export async function postToX(options: XBrowserOptions): Promise { const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 }); - const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); - let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com')); - - if (!pageTarget) { - const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL }); - pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' }; - } - - const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true }); - - await cdp.send('Page.enable', {}, { sessionId }); - await cdp.send('Runtime.enable', {}, { sessionId }); + const page = await openPageSession({ + cdp, + reusing, + url: X_COMPOSE_URL, + matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'), + enablePage: true, + enableRuntime: true, + }); + const { sessionId } = page; await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId }); console.log('[x-browser] Waiting for X editor...'); @@ -192,7 +187,9 @@ export async function postToX(options: XBrowserOptions): Promise { if (cdp) { cdp.close(); } - chrome.unref(); + if (chrome) { + chrome.unref(); + } } } diff --git a/skills/baoyu-post-to-x/scripts/x-quote.ts b/skills/baoyu-post-to-x/scripts/x-quote.ts index f3a3087..87014b5 100644 --- a/skills/baoyu-post-to-x/scripts/x-quote.ts +++ b/skills/baoyu-post-to-x/scripts/x-quote.ts @@ -1,12 +1,13 @@ -import { spawn } from 'node:child_process'; import { mkdir } from 'node:fs/promises'; import process from 'node:process'; import { CHROME_CANDIDATES_FULL, CdpConnection, - findChromeExecutable, + findExistingChromeDebugPort, getDefaultProfileDir, - getFreePort, + killChrome, + launchChrome, + openPageSession, sleep, waitForChromeDebugPort, } from './x-utils.js'; @@ -31,42 +32,39 @@ interface QuoteOptions { export async function quotePost(options: QuoteOptions): Promise { const { tweetUrl, comment, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; - const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL); - if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.'); - await mkdir(profileDir, { recursive: true }); - const port = await getFreePort(); - console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`); + const existingPort = await findExistingChromeDebugPort(profileDir); + const reusing = existingPort !== null; + let port = existingPort ?? 0; console.log(`[x-quote] Opening tweet: ${tweetUrl}`); + let chrome: Awaited>['chrome'] | null = null; + if (!reusing) { + const launched = await launchChrome(tweetUrl, profileDir, CHROME_CANDIDATES_FULL, options.chromePath); + port = launched.port; + chrome = launched.chrome; + } - const chrome = spawn(chromePath, [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - '--no-first-run', - '--no-default-browser-check', - '--start-maximized', - tweetUrl, - ], { stdio: 'ignore' }); + if (reusing) console.log(`[x-quote] Reusing existing Chrome on port ${port}`); + else console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`); let cdp: CdpConnection | null = null; + let targetId: string | null = null; try { const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 15_000 }); - const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); - let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com')); - - if (!pageTarget) { - const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: tweetUrl }); - pageTarget = { targetId, url: tweetUrl, type: 'page' }; - } - - const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true }); - - await cdp.send('Page.enable', {}, { sessionId }); - await cdp.send('Runtime.enable', {}, { sessionId }); + const page = await openPageSession({ + cdp, + reusing, + url: tweetUrl, + matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'), + enablePage: true, + enableRuntime: true, + }); + const { sessionId } = page; + targetId = page.targetId; console.log('[x-quote] Waiting for tweet to load...'); await sleep(3000); @@ -175,14 +173,14 @@ export async function quotePost(options: QuoteOptions): Promise { } } finally { if (cdp) { - try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {} + if (reusing && targetId) { + try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {} + } else if (!reusing) { + try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {} + } cdp.close(); } - - setTimeout(() => { - if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {} - }, 2_000).unref?.(); - try { chrome.kill('SIGTERM'); } catch {} + if (chrome) killChrome(chrome); } } diff --git a/skills/baoyu-post-to-x/scripts/x-utils.ts b/skills/baoyu-post-to-x/scripts/x-utils.ts index ac60dc5..2ef5670 100644 --- a/skills/baoyu-post-to-x/scripts/x-utils.ts +++ b/skills/baoyu-post-to-x/scripts/x-utils.ts @@ -1,16 +1,24 @@ import { execSync, spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import net from 'node:net'; -import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; -export type PlatformCandidates = { - darwin?: string[]; - win32?: string[]; - default: string[]; -}; +import { + CdpConnection, + findChromeExecutable as findChromeExecutableBase, + findExistingChromeDebugPort as findExistingChromeDebugPortBase, + getFreePort as getFreePortBase, + killChrome, + launchChrome as launchChromeBase, + openPageSession, + resolveSharedChromeProfileDir, + sleep, + waitForChromeDebugPort, + type PlatformCandidates, +} from 'baoyu-chrome-cdp'; + +export { CdpConnection, killChrome, openPageSession, sleep, waitForChromeDebugPort }; +export type { PlatformCandidates } from 'baoyu-chrome-cdp'; export const CHROME_CANDIDATES_BASIC: PlatformCandidates = { darwin: [ @@ -53,20 +61,11 @@ export const CHROME_CANDIDATES_FULL: PlatformCandidates = { ], }; -function getCandidatesForPlatform(candidates: PlatformCandidates): string[] { - if (process.platform === 'darwin' && candidates.darwin?.length) return candidates.darwin; - if (process.platform === 'win32' && candidates.win32?.length) return candidates.win32; - return candidates.default; -} - export function findChromeExecutable(candidates: PlatformCandidates): string | undefined { - const override = process.env.X_BROWSER_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - for (const candidate of getCandidatesForPlatform(candidates)) { - if (fs.existsSync(candidate)) return candidate; - } - return undefined; + return findChromeExecutableBase({ + candidates, + envNames: ['X_BROWSER_CHROME_PATH'], + }); } let _wslHome: string | null | undefined; @@ -81,158 +80,39 @@ function getWslWindowsHome(): string | null { } export function getDefaultProfileDir(): string { - const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.X_BROWSER_PROFILE_DIR?.trim(); - if (override) return path.resolve(override); - const wslHome = getWslWindowsHome(); - if (wslHome) return path.join(wslHome, '.local', 'share', 'baoyu-skills', 'chrome-profile'); - const base = process.platform === 'darwin' - ? path.join(os.homedir(), 'Library', 'Application Support') - : process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); - return path.join(base, 'baoyu-skills', 'chrome-profile'); -} - -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function getFreePort(): Promise { - const fixed = parseInt(process.env.X_BROWSER_DEBUG_PORT || '', 10); - if (fixed > 0) return fixed; - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Unable to allocate a free TCP port.'))); - return; - } - const port = address.port; - server.close((err) => { - if (err) reject(err); - else resolve(port); - }); - }); + return resolveSharedChromeProfileDir({ + envNames: ['BAOYU_CHROME_PROFILE_DIR', 'X_BROWSER_PROFILE_DIR'], + wslWindowsHome: getWslWindowsHome(), }); } -async function fetchJson(url: string): Promise { - const res = await fetch(url, { redirect: 'follow' }); - if (!res.ok) throw new Error(`Request failed: ${res.status} ${res.statusText}`); - return (await res.json()) as T; +export async function getFreePort(): Promise { + return await getFreePortBase('X_BROWSER_DEBUG_PORT'); } -export async function waitForChromeDebugPort( - port: number, - timeoutMs: number, - options?: { includeLastError?: boolean }, -): Promise { - const start = Date.now(); - let lastError: unknown = null; - - while (Date.now() - start < timeoutMs) { - try { - const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`); - if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl; - lastError = new Error('Missing webSocketDebuggerUrl'); - } catch (error) { - lastError = error; - } - await sleep(200); - } - - if (options?.includeLastError && lastError) { - throw new Error(`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`); - } - throw new Error('Chrome debug port not ready'); +export async function findExistingChromeDebugPort(profileDir: string): Promise { + return await findExistingChromeDebugPortBase({ profileDir }); } -type PendingRequest = { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - timer: ReturnType | null; -}; +export async function launchChrome( + url: string, + profileDir: string, + candidates: PlatformCandidates, + chromePathOverride?: string, +): Promise<{ chrome: Awaited>; port: number }> { + const chromePath = chromePathOverride?.trim() || findChromeExecutable(candidates); + if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.'); -export class CdpConnection { - private ws: WebSocket; - private nextId = 0; - private pending = new Map(); - private eventHandlers = new Map void>>(); - private defaultTimeoutMs: number; + const port = await getFreePort(); + const chrome = await launchChromeBase({ + chromePath, + profileDir, + port, + url, + extraArgs: ['--start-maximized'], + }); - private constructor(ws: WebSocket, options?: { defaultTimeoutMs?: number }) { - this.ws = ws; - this.defaultTimeoutMs = options?.defaultTimeoutMs ?? 15_000; - - this.ws.addEventListener('message', (event) => { - try { - const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); - const msg = JSON.parse(data) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: { message?: string } }; - - if (msg.method) { - const handlers = this.eventHandlers.get(msg.method); - if (handlers) handlers.forEach((h) => h(msg.params)); - } - - if (msg.id) { - const pending = this.pending.get(msg.id); - if (pending) { - this.pending.delete(msg.id); - if (pending.timer) clearTimeout(pending.timer); - if (msg.error?.message) pending.reject(new Error(msg.error.message)); - else pending.resolve(msg.result); - } - } - } catch {} - }); - - this.ws.addEventListener('close', () => { - for (const [id, pending] of this.pending.entries()) { - this.pending.delete(id); - if (pending.timer) clearTimeout(pending.timer); - pending.reject(new Error('CDP connection closed.')); - } - }); - } - - static async connect(url: string, timeoutMs: number, options?: { defaultTimeoutMs?: number }): Promise { - const ws = new WebSocket(url); - await new Promise((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(method: string, params?: Record, options?: { sessionId?: string; timeoutMs?: number }): Promise { - const id = ++this.nextId; - const message: Record = { 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((resolve, reject) => { - const timer = timeoutMs > 0 - ? setTimeout(() => { this.pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, timeoutMs) - : null; - this.pending.set(id, { resolve, reject, timer }); - this.ws.send(JSON.stringify(message)); - }); - - return result as T; - } - - close(): void { - try { this.ws.close(); } catch {} - } + return { chrome, port }; } export function getScriptDir(): string { diff --git a/skills/baoyu-post-to-x/scripts/x-video.ts b/skills/baoyu-post-to-x/scripts/x-video.ts index ae88fb4..43ccc55 100644 --- a/skills/baoyu-post-to-x/scripts/x-video.ts +++ b/skills/baoyu-post-to-x/scripts/x-video.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import { mkdir } from 'node:fs/promises'; import path from 'node:path'; @@ -6,9 +5,11 @@ import process from 'node:process'; import { CHROME_CANDIDATES_FULL, CdpConnection, - findChromeExecutable, + findExistingChromeDebugPort, getDefaultProfileDir, - getFreePort, + killChrome, + launchChrome, + openPageSession, sleep, waitForChromeDebugPort, } from './x-utils.js'; @@ -27,9 +28,6 @@ interface XVideoOptions { export async function postVideoToX(options: XVideoOptions): Promise { const { text, videoPath, submit = false, timeoutMs = 120_000, profileDir = getDefaultProfileDir() } = options; - const chromePath = options.chromePath ?? findChromeExecutable(CHROME_CANDIDATES_FULL); - if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.'); - if (!fs.existsSync(videoPath)) throw new Error(`Video not found: ${videoPath}`); const absVideoPath = path.resolve(videoPath); @@ -37,37 +35,37 @@ export async function postVideoToX(options: XVideoOptions): Promise { await mkdir(profileDir, { recursive: true }); - const port = await getFreePort(); - console.log(`[x-video] Launching Chrome (profile: ${profileDir})`); + const existingPort = await findExistingChromeDebugPort(profileDir); + const reusing = existingPort !== null; + let port = existingPort ?? 0; + let chrome: Awaited>['chrome'] | null = null; + if (!reusing) { + const launched = await launchChrome(X_COMPOSE_URL, profileDir, CHROME_CANDIDATES_FULL, options.chromePath); + port = launched.port; + chrome = launched.chrome; + } - const chrome = spawn(chromePath, [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - '--no-first-run', - '--no-default-browser-check', - '--start-maximized', - X_COMPOSE_URL, - ], { stdio: 'ignore' }); + if (reusing) console.log(`[x-video] Reusing existing Chrome on port ${port}`); + else console.log(`[x-video] Launching Chrome (profile: ${profileDir})`); let cdp: CdpConnection | null = null; + let targetId: string | null = null; try { const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true }); cdp = await CdpConnection.connect(wsUrl, 30_000, { defaultTimeoutMs: 30_000 }); - const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets'); - let pageTarget = targets.targetInfos.find((t) => t.type === 'page' && t.url.includes('x.com')); - - if (!pageTarget) { - const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url: X_COMPOSE_URL }); - pageTarget = { targetId, url: X_COMPOSE_URL, type: 'page' }; - } - - const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: pageTarget.targetId, flatten: true }); - - await cdp.send('Page.enable', {}, { sessionId }); - await cdp.send('Runtime.enable', {}, { sessionId }); - await cdp.send('DOM.enable', {}, { sessionId }); + const page = await openPageSession({ + cdp, + reusing, + url: X_COMPOSE_URL, + matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'), + enablePage: true, + enableRuntime: true, + enableDom: true, + }); + const { sessionId } = page; + targetId = page.targetId; await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId }); console.log('[x-video] Waiting for X editor...'); @@ -182,15 +180,12 @@ export async function postVideoToX(options: XVideoOptions): Promise { } } finally { if (cdp) { + if (reusing && submit && targetId) { + try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {} + } cdp.close(); } - // Don't kill Chrome in preview mode, let user review - if (submit) { - setTimeout(() => { - if (!chrome.killed) try { chrome.kill('SIGKILL'); } catch {} - }, 2_000).unref?.(); - try { chrome.kill('SIGTERM'); } catch {} - } + if (chrome && submit) killChrome(chrome); } } diff --git a/skills/baoyu-url-to-markdown/scripts/bun.lock b/skills/baoyu-url-to-markdown/scripts/bun.lock index c167bc3..c68e09c 100644 --- a/skills/baoyu-url-to-markdown/scripts/bun.lock +++ b/skills/baoyu-url-to-markdown/scripts/bun.lock @@ -5,6 +5,7 @@ "name": "baoyu-url-to-markdown-scripts", "dependencies": { "@mozilla/readability": "^0.6.0", + "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", "defuddle": "^0.10.0", "jsdom": "^24.1.3", "linkedom": "^0.18.12", @@ -36,6 +37,8 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], diff --git a/skills/baoyu-url-to-markdown/scripts/cdp.ts b/skills/baoyu-url-to-markdown/scripts/cdp.ts index 611c840..5345263 100644 --- a/skills/baoyu-url-to-markdown/scripts/cdp.ts +++ b/skills/baoyu-url-to-markdown/scripts/cdp.ts @@ -1,245 +1,84 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import fs from "node:fs"; -import { mkdir, readFile } from "node:fs/promises"; -import net from "node:net"; -import path from "node:path"; -import process from "node:process"; +import { + CdpConnection, + findChromeExecutable as findChromeExecutableBase, + findExistingChromeDebugPort, + getFreePort, + killChrome, + launchChrome as launchChromeBase, + sleep, + waitForChromeDebugPort, + type PlatformCandidates, +} from 'baoyu-chrome-cdp'; -import { resolveUrlToMarkdownChromeProfileDir } from "./paths.js"; -import { CDP_CONNECT_TIMEOUT_MS, NETWORK_IDLE_TIMEOUT_MS } from "./constants.js"; +import { resolveUrlToMarkdownChromeProfileDir } from './paths.js'; +import { NETWORK_IDLE_TIMEOUT_MS } from './constants.js'; -type CdpSendOptions = { sessionId?: string; timeoutMs?: number }; +const CHROME_CANDIDATES_FULL: PlatformCandidates = { + darwin: [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary', + '/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + ], + win32: [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', + 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', + ], + default: [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', + '/usr/bin/microsoft-edge', + ], +}; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +export { CdpConnection, getFreePort, killChrome, sleep, waitForChromeDebugPort }; -async function fetchWithTimeout(url: string, init: RequestInit & { timeoutMs?: number } = {}): Promise { - const { timeoutMs, ...rest } = init; - if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest); - const ctl = new AbortController(); - const t = setTimeout(() => ctl.abort(), timeoutMs); - try { - return await fetch(url, { ...rest, signal: ctl.signal }); - } finally { - clearTimeout(t); - } -} - -export class CdpConnection { - private ws: WebSocket; - private nextId = 0; - private pending = new Map void; reject: (e: Error) => void; timer: ReturnType | null }>(); - private eventHandlers = new Map 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 { - const ws = new WebSocket(url); - await new Promise((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(method: string, params?: Record, opts?: CdpSendOptions): Promise { - const id = ++this.nextId; - const msg: Record = { 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((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 { - 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 { + return await findExistingChromeDebugPort({ + profileDir: resolveUrlToMarkdownChromeProfileDir(), }); } -export async function findExistingChromePort(): Promise { - const profileDir = resolveUrlToMarkdownChromeProfileDir(); - - const activePortPath = path.join(profileDir, "DevToolsActivePort"); - try { - const content = await readFile(activePortPath, "utf-8"); - const port = parseInt(content.split("\n")[0].trim(), 10); - if (port && !isNaN(port)) { - const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 3_000 }); - if (res.ok) return port; - } - } catch {} - - if (process.platform !== "win32") { - try { - const { execSync } = await import("node:child_process"); - const ps = execSync("ps aux", { encoding: "utf-8", timeout: 5_000 }); - const escapedDir = profileDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const lines = ps.split("\n").filter(l => l.includes(profileDir) && l.includes("--remote-debugging-port=")); - for (const line of lines) { - const portMatch = line.match(/--remote-debugging-port=(\d+)/); - if (portMatch) { - const port = parseInt(portMatch[1], 10); - if (port && !isNaN(port)) { - const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 3_000 }); - if (res.ok) return port; - } - } - } - } catch {} - } - - return null; -} - export function findChromeExecutable(): string | null { - const override = process.env.URL_CHROME_PATH?.trim(); - if (override && fs.existsSync(override)) return override; - - const candidates: string[] = []; - switch (process.platform) { - case "darwin": - candidates.push( - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", - "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" - ); - break; - case "win32": - candidates.push( - "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", - "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe", - "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe", - "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" - ); - break; - default: - candidates.push( - "/usr/bin/google-chrome", - "/usr/bin/google-chrome-stable", - "/usr/bin/chromium", - "/usr/bin/chromium-browser", - "/snap/bin/chromium", - "/usr/bin/microsoft-edge" - ); - break; - } - - for (const p of candidates) { - if (fs.existsSync(p)) return p; - } - return null; + return findChromeExecutableBase({ + candidates: CHROME_CANDIDATES_FULL, + envNames: ['URL_CHROME_PATH'], + }) ?? null; } -export async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 }); - if (!res.ok) throw new Error(`status=${res.status}`); - const j = (await res.json()) as { webSocketDebuggerUrl?: string }; - if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl; - } catch {} - await sleep(200); - } - throw new Error("Chrome debug port not ready"); +export async function launchChrome(url: string, port: number, headless = false) { + const chromePath = findChromeExecutable(); + if (!chromePath) throw new Error('Chrome executable not found. Install Chrome or set URL_CHROME_PATH env.'); + + return await launchChromeBase({ + chromePath, + profileDir: resolveUrlToMarkdownChromeProfileDir(), + port, + url, + headless, + extraArgs: ['--disable-popup-blocking'], + }); } -export async function launchChrome(url: string, port: number, headless: boolean = false): Promise { - const chrome = findChromeExecutable(); - if (!chrome) throw new Error("Chrome executable not found. Install Chrome or set URL_CHROME_PATH env."); - const profileDir = resolveUrlToMarkdownChromeProfileDir(); - await mkdir(profileDir, { recursive: true }); - - const args = [ - `--remote-debugging-port=${port}`, - `--user-data-dir=${profileDir}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-popup-blocking", - ]; - if (headless) args.push("--headless=new"); - args.push(url); - - return spawn(chrome, args, { stdio: "ignore" }); -} - -export async function waitForNetworkIdle(cdp: CdpConnection, sessionId: string, timeoutMs: number = NETWORK_IDLE_TIMEOUT_MS): Promise { +export async function waitForNetworkIdle( + cdp: CdpConnection, + sessionId: string, + timeoutMs: number = NETWORK_IDLE_TIMEOUT_MS, +): Promise { return new Promise((resolve) => { let timer: ReturnType | null = null; let pending = 0; const cleanup = () => { if (timer) clearTimeout(timer); - cdp.off("Network.requestWillBeSent", onRequest); - cdp.off("Network.loadingFinished", onFinish); - cdp.off("Network.loadingFailed", onFinish); + cdp.off('Network.requestWillBeSent', onRequest); + cdp.off('Network.loadingFinished', onFinish); + cdp.off('Network.loadingFailed', onFinish); }; const done = () => { cleanup(); resolve(); }; const resetTimer = () => { @@ -248,79 +87,93 @@ export async function waitForNetworkIdle(cdp: CdpConnection, sessionId: string, }; const onRequest = () => { pending++; resetTimer(); }; const onFinish = () => { pending = Math.max(0, pending - 1); if (pending <= 2) resetTimer(); }; - cdp.on("Network.requestWillBeSent", onRequest); - cdp.on("Network.loadingFinished", onFinish); - cdp.on("Network.loadingFailed", onFinish); + cdp.on('Network.requestWillBeSent', onRequest); + cdp.on('Network.loadingFinished', onFinish); + cdp.on('Network.loadingFailed', onFinish); resetTimer(); }); } -export async function waitForPageLoad(cdp: CdpConnection, sessionId: string, timeoutMs: number = 30_000): Promise { - return new Promise((resolve, reject) => { +export async function waitForPageLoad( + cdp: CdpConnection, + sessionId: string, + timeoutMs: number = 30_000, +): Promise { + void sessionId; + return new Promise((resolve) => { const timer = setTimeout(() => { - cdp.off("Page.loadEventFired", handler); + cdp.off('Page.loadEventFired', handler); resolve(); }, timeoutMs); const handler = () => { clearTimeout(timer); - cdp.off("Page.loadEventFired", handler); + cdp.off('Page.loadEventFired', handler); resolve(); }; - cdp.on("Page.loadEventFired", handler); + cdp.on('Page.loadEventFired', handler); }); } -export async function createTargetAndAttach(cdp: CdpConnection, url: string): Promise<{ targetId: string; sessionId: string }> { - const { targetId } = await cdp.send<{ targetId: string }>("Target.createTarget", { url }); - const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true }); - await cdp.send("Network.enable", {}, { sessionId }); - await cdp.send("Page.enable", {}, { sessionId }); +export async function createTargetAndAttach( + cdp: CdpConnection, + url: string, +): Promise<{ targetId: string; sessionId: string }> { + const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', { url }); + const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true }); + await cdp.send('Network.enable', {}, { sessionId }); + await cdp.send('Page.enable', {}, { sessionId }); return { targetId, sessionId }; } -export async function navigateAndWait(cdp: CdpConnection, sessionId: string, url: string, timeoutMs: number): Promise { +export async function navigateAndWait( + cdp: CdpConnection, + sessionId: string, + url: string, + timeoutMs: number, +): Promise { const loadPromise = new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("Page load timeout")), timeoutMs); + const timer = setTimeout(() => reject(new Error('Page load timeout')), timeoutMs); const handler = (params: unknown) => { - const p = params as { name?: string }; - if (p.name === "load" || p.name === "DOMContentLoaded") { + const event = params as { name?: string }; + if (event.name === 'load' || event.name === 'DOMContentLoaded') { clearTimeout(timer); - cdp.off("Page.lifecycleEvent", handler); + cdp.off('Page.lifecycleEvent', handler); resolve(); } }; - cdp.on("Page.lifecycleEvent", handler); + cdp.on('Page.lifecycleEvent', handler); }); - await cdp.send("Page.navigate", { url }, { sessionId }); + await cdp.send('Page.navigate', { url }, { sessionId }); await loadPromise; } -export async function evaluateScript(cdp: CdpConnection, sessionId: string, expression: string, timeoutMs: number = 30_000): Promise { - const result = await cdp.send<{ result: { value?: T; type?: string; description?: string } }>( - "Runtime.evaluate", +export async function evaluateScript( + cdp: CdpConnection, + sessionId: string, + expression: string, + timeoutMs: number = 30_000, +): Promise { + const result = await cdp.send<{ result: { value?: T } }>( + 'Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, - { sessionId, timeoutMs } + { sessionId, timeoutMs }, ); return result.result.value as T; } -export async function autoScroll(cdp: CdpConnection, sessionId: string, steps: number = 8, waitMs: number = 600): Promise { - let lastHeight = await evaluateScript(cdp, sessionId, "document.body.scrollHeight"); +export async function autoScroll( + cdp: CdpConnection, + sessionId: string, + steps: number = 8, + waitMs: number = 600, +): Promise { + let lastHeight = await evaluateScript(cdp, sessionId, 'document.body.scrollHeight'); for (let i = 0; i < steps; i++) { - await evaluateScript(cdp, sessionId, "window.scrollTo(0, document.body.scrollHeight)"); + await evaluateScript(cdp, sessionId, 'window.scrollTo(0, document.body.scrollHeight)'); await sleep(waitMs); - const newHeight = await evaluateScript(cdp, sessionId, "document.body.scrollHeight"); + const newHeight = await evaluateScript(cdp, sessionId, 'document.body.scrollHeight'); if (newHeight === lastHeight) break; lastHeight = newHeight; } - await evaluateScript(cdp, sessionId, "window.scrollTo(0, 0)"); -} - -export function killChrome(chrome: ChildProcess): void { - try { chrome.kill("SIGTERM"); } catch {} - setTimeout(() => { - if (!chrome.killed) { - try { chrome.kill("SIGKILL"); } catch {} - } - }, 2_000).unref?.(); + await evaluateScript(cdp, sessionId, 'window.scrollTo(0, 0)'); } diff --git a/skills/baoyu-url-to-markdown/scripts/package.json b/skills/baoyu-url-to-markdown/scripts/package.json index 90fc925..23c82c3 100644 --- a/skills/baoyu-url-to-markdown/scripts/package.json +++ b/skills/baoyu-url-to-markdown/scripts/package.json @@ -4,6 +4,7 @@ "type": "module", "dependencies": { "@mozilla/readability": "^0.6.0", + "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", "defuddle": "^0.10.0", "jsdom": "^24.1.3", "linkedom": "^0.18.12",