From 3bba18c1fee55741010c0428fd28e34e7bd9e52a 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 20:45:25 -0500 Subject: [PATCH] build: commit vendored shared skill packages --- .claude/skills/release-skills/SKILL.md | 2 +- .githooks/pre-push | 7 + .gitignore | 1 + CLAUDE.md | 15 +- scripts/install-git-hooks.mjs | 25 ++ scripts/lib/shared-skill-packages.mjs | 259 +++++++++++ scripts/lib/skill-artifact.mjs | 122 ++---- scripts/sync-shared-skill-packages.mjs | 61 +++ .../baoyu-danger-gemini-web/scripts/bun.lock | 4 +- .../scripts/package.json | 2 +- .../vendor/baoyu-chrome-cdp/package.json | 9 + .../vendor/baoyu-chrome-cdp/src/index.ts | 408 ++++++++++++++++++ .../scripts/bun.lock | 4 +- .../scripts/package.json | 2 +- .../vendor/baoyu-chrome-cdp/package.json | 9 + .../vendor/baoyu-chrome-cdp/src/index.ts | 408 ++++++++++++++++++ skills/baoyu-post-to-wechat/scripts/bun.lock | 4 +- .../baoyu-post-to-wechat/scripts/package.json | 2 +- .../vendor/baoyu-chrome-cdp/package.json | 9 + .../vendor/baoyu-chrome-cdp/src/index.ts | 408 ++++++++++++++++++ skills/baoyu-post-to-weibo/scripts/bun.lock | 4 +- .../baoyu-post-to-weibo/scripts/package.json | 2 +- .../vendor/baoyu-chrome-cdp/package.json | 9 + .../vendor/baoyu-chrome-cdp/src/index.ts | 408 ++++++++++++++++++ skills/baoyu-post-to-x/scripts/bun.lock | 4 +- skills/baoyu-post-to-x/scripts/package.json | 2 +- .../vendor/baoyu-chrome-cdp/package.json | 9 + .../vendor/baoyu-chrome-cdp/src/index.ts | 408 ++++++++++++++++++ skills/baoyu-url-to-markdown/scripts/bun.lock | 4 +- .../scripts/package.json | 2 +- .../vendor/baoyu-chrome-cdp/package.json | 9 + .../vendor/baoyu-chrome-cdp/src/index.ts | 408 ++++++++++++++++++ 32 files changed, 2914 insertions(+), 116 deletions(-) create mode 100755 .githooks/pre-push create mode 100755 scripts/install-git-hooks.mjs create mode 100644 scripts/lib/shared-skill-packages.mjs create mode 100755 scripts/sync-shared-skill-packages.mjs create mode 100644 skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/package.json create mode 100644 skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.ts create mode 100644 skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/package.json create mode 100644 skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/src/index.ts create mode 100644 skills/baoyu-post-to-wechat/scripts/vendor/baoyu-chrome-cdp/package.json create mode 100644 skills/baoyu-post-to-wechat/scripts/vendor/baoyu-chrome-cdp/src/index.ts create mode 100644 skills/baoyu-post-to-weibo/scripts/vendor/baoyu-chrome-cdp/package.json create mode 100644 skills/baoyu-post-to-weibo/scripts/vendor/baoyu-chrome-cdp/src/index.ts create mode 100644 skills/baoyu-post-to-x/scripts/vendor/baoyu-chrome-cdp/package.json create mode 100644 skills/baoyu-post-to-x/scripts/vendor/baoyu-chrome-cdp/src/index.ts create mode 100644 skills/baoyu-url-to-markdown/scripts/vendor/baoyu-chrome-cdp/package.json create mode 100644 skills/baoyu-url-to-markdown/scripts/vendor/baoyu-chrome-cdp/src/index.ts diff --git a/.claude/skills/release-skills/SKILL.md b/.claude/skills/release-skills/SKILL.md index a951854..372ac6c 100644 --- a/.claude/skills/release-skills/SKILL.md +++ b/.claude/skills/release-skills/SKILL.md @@ -57,7 +57,7 @@ Supported hooks: | Hook | Purpose | Expected Responsibility | |------|---------|-------------------------| -| `prepare_artifact` | Build a releasable artifact for one target | Vendor local deps, rewrite package metadata, stage files | +| `prepare_artifact` | Build a releasable artifact for one target | Validate the target is self-contained, stage files, apply any project-specific packaging | | `publish_artifact` | Publish one prepared artifact | Upload artifact, attach version/changelog/tags | Supported placeholders: diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..e204622 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +REPO_ROOT=$(git rev-parse --show-toplevel) +cd "$REPO_ROOT" + +node scripts/sync-shared-skill-packages.mjs --repo-root "$REPO_ROOT" --enforce-clean diff --git a/.gitignore b/.gitignore index 9ca54a5..c37c4df 100644 --- a/.gitignore +++ b/.gitignore @@ -164,4 +164,5 @@ posts/ # ClawHub local state (current and legacy directory names from the official CLI) .clawhub/ .clawdhub/ +.release-artifacts/ .worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 080610b..a41e91d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,9 @@ 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/sync-shared-skill-packages.mjs` - Sync committed `vendor/` copies of shared workspace packages into skill script directories +- `scripts/install-git-hooks.mjs` - Configure `.githooks/` as the repository-local git hooks path +- `scripts/prepare-skill-artifact.mjs` - Build one releasable skill artifact from the already self-contained skill tree - `scripts/publish-skill-artifact.mjs` - Publish one prepared skill artifact - `scripts/sync-md-to-wechat.sh` - Sync markdown content to WeChat @@ -217,6 +219,17 @@ 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. +### Shared Workspace Packages + +Shared workspace packages under `packages/` are the **only** source of truth. Do not edit copies under `skills/*/scripts/vendor/` directly. + +When updating a shared package: +1. Edit the package under `packages/` first +2. Run `node scripts/sync-shared-skill-packages.mjs` +3. Review and commit the synced `skills/*/scripts/vendor/`, `package.json`, and `bun.lock` changes together + +Use `node scripts/install-git-hooks.mjs` to enable the repository `pre-push` hook. The hook reruns the sync and blocks push if the committed vendor copies are stale. The release flow assumes `main` already contains the final installable tree and only validates/copies it. + ## Skill Loading Rules **IMPORTANT**: When working in this project, follow these rules: diff --git a/scripts/install-git-hooks.mjs b/scripts/install-git-hooks.mjs new file mode 100755 index 0000000..96cab13 --- /dev/null +++ b/scripts/install-git-hooks.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +async function main() { + const repoRoot = path.resolve(process.cwd()); + const hooksPath = path.join(repoRoot, ".githooks"); + + const result = spawnSync("git", ["config", "core.hooksPath", hooksPath], { + cwd: repoRoot, + stdio: "inherit", + }); + + if (result.status !== 0) { + throw new Error("Failed to configure core.hooksPath"); + } + + console.log(`Configured git hooks path: ${hooksPath}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/lib/shared-skill-packages.mjs b/scripts/lib/shared-skill-packages.mjs new file mode 100644 index 0000000..81f259a --- /dev/null +++ b/scripts/lib/shared-skill-packages.mjs @@ -0,0 +1,259 @@ +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const PACKAGE_DEPENDENCY_SECTIONS = [ + "dependencies", + "optionalDependencies", + "peerDependencies", + "devDependencies", +]; + +const SKIPPED_DIRS = new Set([".git", ".clawhub", ".clawdhub", "node_modules"]); +const SKIPPED_FILES = new Set([".DS_Store"]); + +export async function syncSharedSkillPackages(repoRoot, options = {}) { + const root = path.resolve(repoRoot); + const workspacePackages = await discoverWorkspacePackages(root); + const consumers = await discoverSkillScriptPackages(root); + const runtime = options.install === false ? null : resolveBunRuntime(); + const managedPaths = new Set(); + const packageDirs = []; + + for (const consumer of consumers) { + const result = await syncConsumerPackage({ + consumer, + root, + workspacePackages, + runtime, + }); + if (!result) continue; + + packageDirs.push(consumer.dir); + for (const managedPath of result.managedPaths) { + managedPaths.add(managedPath); + } + } + + return { + packageDirs, + managedPaths: [...managedPaths].sort(), + }; +} + +export function ensureManagedPathsClean(repoRoot, managedPaths) { + if (managedPaths.length === 0) return; + + const result = spawnSync("git", ["status", "--porcelain", "--", ...managedPaths], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + throw new Error(result.stderr.trim() || "Failed to inspect git status for managed paths"); + } + + const output = result.stdout.trim(); + if (!output) return; + + throw new Error( + [ + "Shared skill package sync produced uncommitted managed changes.", + "Review and commit these files before pushing:", + output, + ].join("\n"), + ); +} + +async function syncConsumerPackage({ consumer, root, workspacePackages, runtime }) { + const packageJsonPath = path.join(consumer.dir, "package.json"); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); + const localDeps = collectLocalDependencies(packageJson, workspacePackages); + if (localDeps.length === 0) { + return null; + } + + const vendorRoot = path.join(consumer.dir, "vendor"); + await fs.rm(vendorRoot, { recursive: true, force: true }); + + for (const name of localDeps) { + const sourceDir = workspacePackages.get(name); + if (!sourceDir) continue; + await syncPackageTree({ + sourceDir, + targetDir: path.join(vendorRoot, name), + workspacePackages, + }); + } + + rewriteLocalDependencySpecs(packageJson, localDeps); + await writeJson(packageJsonPath, packageJson); + + if (runtime) { + runInstall(runtime, consumer.dir); + } + + const managedPaths = [ + path.relative(root, packageJsonPath).split(path.sep).join("/"), + path.relative(root, path.join(consumer.dir, "bun.lock")).split(path.sep).join("/"), + path.relative(root, vendorRoot).split(path.sep).join("/"), + ]; + + return { managedPaths }; +} + +async function syncPackageTree({ sourceDir, targetDir, workspacePackages }) { + await fs.rm(targetDir, { recursive: true, force: true }); + await fs.mkdir(targetDir, { recursive: true }); + + const sourcePackageJsonPath = path.join(sourceDir, "package.json"); + const packageJson = JSON.parse(await fs.readFile(sourcePackageJsonPath, "utf8")); + const localDeps = collectLocalDependencies(packageJson, workspacePackages); + + const entries = await fs.readdir(sourceDir, { withFileTypes: true }); + for (const entry of entries) { + if (SKIPPED_DIRS.has(entry.name) || SKIPPED_FILES.has(entry.name)) continue; + + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + await copyDirectory(sourcePath, targetPath); + continue; + } + + if (!entry.isFile() || entry.name === "package.json") continue; + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.copyFile(sourcePath, targetPath); + } + + for (const name of localDeps) { + const nestedSourceDir = workspacePackages.get(name); + if (!nestedSourceDir) continue; + await syncPackageTree({ + sourceDir: nestedSourceDir, + targetDir: path.join(targetDir, "vendor", name), + workspacePackages, + }); + } + + rewriteLocalDependencySpecs(packageJson, localDeps); + await writeJson(path.join(targetDir, "package.json"), packageJson); +} + +async function copyDirectory(sourceDir, targetDir) { + await fs.mkdir(targetDir, { recursive: true }); + const entries = await fs.readdir(sourceDir, { withFileTypes: true }); + for (const entry of entries) { + if (SKIPPED_DIRS.has(entry.name) || SKIPPED_FILES.has(entry.name)) continue; + + const sourcePath = path.join(sourceDir, entry.name); + const targetPath = path.join(targetDir, entry.name); + + if (entry.isDirectory()) { + await copyDirectory(sourcePath, targetPath); + continue; + } + + if (!entry.isFile()) continue; + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.copyFile(sourcePath, targetPath); + } +} + +async function discoverWorkspacePackages(repoRoot) { + const packagesRoot = path.join(repoRoot, "packages"); + const map = new Map(); + if (!existsSync(packagesRoot)) return map; + + const entries = await fs.readdir(packagesRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const packageJsonPath = path.join(packagesRoot, entry.name, "package.json"); + if (!existsSync(packageJsonPath)) continue; + + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); + if (!packageJson.name) continue; + map.set(packageJson.name, path.join(packagesRoot, entry.name)); + } + + return map; +} + +async function discoverSkillScriptPackages(repoRoot) { + const skillsRoot = path.join(repoRoot, "skills"); + const consumers = []; + const skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true }); + for (const entry of skillEntries) { + if (!entry.isDirectory()) continue; + const scriptsDir = path.join(skillsRoot, entry.name, "scripts"); + const packageJsonPath = path.join(scriptsDir, "package.json"); + if (!existsSync(packageJsonPath)) continue; + consumers.push({ dir: scriptsDir, packageJsonPath }); + } + return consumers.sort((left, right) => left.dir.localeCompare(right.dir)); +} + +function collectLocalDependencies(packageJson, workspacePackages) { + const localDeps = []; + for (const section of PACKAGE_DEPENDENCY_SECTIONS) { + const dependencies = packageJson[section]; + if (!dependencies || typeof dependencies !== "object") continue; + + for (const name of Object.keys(dependencies)) { + if (!workspacePackages.has(name)) continue; + localDeps.push(name); + } + } + + return [...new Set(localDeps)].sort(); +} + +function rewriteLocalDependencySpecs(packageJson, localDeps) { + for (const section of PACKAGE_DEPENDENCY_SECTIONS) { + const dependencies = packageJson[section]; + if (!dependencies || typeof dependencies !== "object") continue; + + for (const name of localDeps) { + if (!(name in dependencies)) continue; + dependencies[name] = `file:./vendor/${name}`; + } + } +} + +async function writeJson(filePath, value) { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function resolveBunRuntime() { + if (commandExists("bun")) { + return { command: "bun", args: [] }; + } + if (commandExists("npx")) { + return { command: "npx", args: ["-y", "bun"] }; + } + throw new Error( + "Neither bun nor npx is installed. Install bun with `brew install oven-sh/bun/bun` or `npm install -g bun`.", + ); +} + +function commandExists(command) { + const result = spawnSync("sh", ["-lc", `command -v ${command}`], { + stdio: "ignore", + }); + return result.status === 0; +} + +function runInstall(runtime, cwd) { + const result = spawnSync(runtime.command, [...runtime.args, "install"], { + cwd, + stdio: "inherit", + }); + + if (result.status !== 0) { + throw new Error(`Failed to refresh Bun dependencies in ${cwd}`); + } +} diff --git a/scripts/lib/skill-artifact.mjs b/scripts/lib/skill-artifact.mjs index 15411f4..578e06f 100644 --- a/scripts/lib/skill-artifact.mjs +++ b/scripts/lib/skill-artifact.mjs @@ -81,27 +81,8 @@ export async function listTextFiles(root) { } 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)); + await validateSelfContainedRelease(root); + return listTextFiles(root); } export async function materializeReleaseFiles(files, outDir) { @@ -113,88 +94,37 @@ export async function materializeReleaseFiles(files, outDir) { } } -async function rewritePackageJsonForRelease({ root, packageDirRel, bytes, fileMap, vendoredPackages }) { - const packageJson = JSON.parse(bytes.toString("utf8")); - let changed = false; +export async function validateSelfContainedRelease(root) { + const files = await listTextFiles(root); + for (const file of files.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) { + const packageDir = path.resolve(root, fromPosixRel(path.posix.dirname(file.relPath))); + const packageJson = JSON.parse(file.bytes.toString("utf8")); + for (const section of PACKAGE_DEPENDENCY_SECTIONS) { + const dependencies = packageJson[section]; + if (!dependencies || typeof dependencies !== "object") continue; - 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; + for (const [name, spec] of Object.entries(dependencies)) { + if (typeof spec !== "string" || !spec.startsWith("file:")) continue; + const targetDir = path.resolve(packageDir, spec.slice(5)); + if (!isWithinRoot(root, targetDir)) { + throw new Error( + `Release artifact is not self-contained: ${file.relPath} depends on ${name} via ${spec}`, + ); + } + await fs.access(targetDir).catch(() => { + throw new Error(`Missing local dependency for release: ${file.relPath} -> ${spec}`); + }); + } } } - - 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}`}`; +function isWithinRoot(root, target) { + const resolvedRoot = path.resolve(root); + const relative = path.relative(resolvedRoot, path.resolve(target)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } diff --git a/scripts/sync-shared-skill-packages.mjs b/scripts/sync-shared-skill-packages.mjs new file mode 100755 index 0000000..b5d9a87 --- /dev/null +++ b/scripts/sync-shared-skill-packages.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +import path from "node:path"; + +import { + ensureManagedPathsClean, + syncSharedSkillPackages, +} from "./lib/shared-skill-packages.mjs"; + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const repoRoot = path.resolve(options.repoRoot); + const result = await syncSharedSkillPackages(repoRoot); + + if (options.enforceClean) { + ensureManagedPathsClean(repoRoot, result.managedPaths); + } + + console.log(`Synced shared workspace packages into ${result.packageDirs.length} skill script package(s).`); +} + +function parseArgs(argv) { + const options = { + repoRoot: process.cwd(), + enforceClean: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--repo-root") { + options.repoRoot = argv[index + 1] ?? options.repoRoot; + index += 1; + continue; + } + if (arg === "--enforce-clean") { + options.enforceClean = true; + continue; + } + if (arg === "-h" || arg === "--help") { + printUsage(); + process.exit(0); + } + throw new Error(`Unknown argument: ${arg}`); + } + + return options; +} + +function printUsage() { + console.log(`Usage: sync-shared-skill-packages.mjs [options] + +Options: + --repo-root Repository root (default: current directory) + --enforce-clean Fail if managed files change after sync + -h, --help Show help`); +} + +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 index 2a9b3f7..74bda3c 100644 --- a/skills/baoyu-danger-gemini-web/scripts/bun.lock +++ b/skills/baoyu-danger-gemini-web/scripts/bun.lock @@ -4,11 +4,11 @@ "": { "name": "baoyu-danger-gemini-web-scripts", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp", }, }, }, "packages": { - "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}], + "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}], } } diff --git a/skills/baoyu-danger-gemini-web/scripts/package.json b/skills/baoyu-danger-gemini-web/scripts/package.json index c239634..9fe2447 100644 --- a/skills/baoyu-danger-gemini-web/scripts/package.json +++ b/skills/baoyu-danger-gemini-web/scripts/package.json @@ -3,6 +3,6 @@ "private": true, "type": "module", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp" + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp" } } diff --git a/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/package.json b/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/package.json new file mode 100644 index 0000000..0014ad3 --- /dev/null +++ b/skills/baoyu-danger-gemini-web/scripts/vendor/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/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.ts b/skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.ts new file mode 100644 index 0000000..1fcd241 --- /dev/null +++ b/skills/baoyu-danger-gemini-web/scripts/vendor/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/skills/baoyu-danger-x-to-markdown/scripts/bun.lock b/skills/baoyu-danger-x-to-markdown/scripts/bun.lock index 5a1d327..85143c8 100644 --- a/skills/baoyu-danger-x-to-markdown/scripts/bun.lock +++ b/skills/baoyu-danger-x-to-markdown/scripts/bun.lock @@ -4,11 +4,11 @@ "": { "name": "baoyu-danger-x-to-markdown-scripts", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp", }, }, }, "packages": { - "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}], + "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}], } } diff --git a/skills/baoyu-danger-x-to-markdown/scripts/package.json b/skills/baoyu-danger-x-to-markdown/scripts/package.json index 490828b..9d275f7 100644 --- a/skills/baoyu-danger-x-to-markdown/scripts/package.json +++ b/skills/baoyu-danger-x-to-markdown/scripts/package.json @@ -3,6 +3,6 @@ "private": true, "type": "module", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp" + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp" } } diff --git a/skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/package.json b/skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/package.json new file mode 100644 index 0000000..0014ad3 --- /dev/null +++ b/skills/baoyu-danger-x-to-markdown/scripts/vendor/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/skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/src/index.ts b/skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/src/index.ts new file mode 100644 index 0000000..1fcd241 --- /dev/null +++ b/skills/baoyu-danger-x-to-markdown/scripts/vendor/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/skills/baoyu-post-to-wechat/scripts/bun.lock b/skills/baoyu-post-to-wechat/scripts/bun.lock index 8a0795d..1cab200 100644 --- a/skills/baoyu-post-to-wechat/scripts/bun.lock +++ b/skills/baoyu-post-to-wechat/scripts/bun.lock @@ -4,11 +4,11 @@ "": { "name": "baoyu-post-to-wechat-scripts", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp", }, }, }, "packages": { - "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}], + "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}], } } diff --git a/skills/baoyu-post-to-wechat/scripts/package.json b/skills/baoyu-post-to-wechat/scripts/package.json index 3ea6184..b0c15ca 100644 --- a/skills/baoyu-post-to-wechat/scripts/package.json +++ b/skills/baoyu-post-to-wechat/scripts/package.json @@ -3,6 +3,6 @@ "private": true, "type": "module", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp" + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp" } } diff --git a/skills/baoyu-post-to-wechat/scripts/vendor/baoyu-chrome-cdp/package.json b/skills/baoyu-post-to-wechat/scripts/vendor/baoyu-chrome-cdp/package.json new file mode 100644 index 0000000..0014ad3 --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/vendor/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/skills/baoyu-post-to-wechat/scripts/vendor/baoyu-chrome-cdp/src/index.ts b/skills/baoyu-post-to-wechat/scripts/vendor/baoyu-chrome-cdp/src/index.ts new file mode 100644 index 0000000..1fcd241 --- /dev/null +++ b/skills/baoyu-post-to-wechat/scripts/vendor/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/skills/baoyu-post-to-weibo/scripts/bun.lock b/skills/baoyu-post-to-weibo/scripts/bun.lock index 918cb98..00e2fa8 100644 --- a/skills/baoyu-post-to-weibo/scripts/bun.lock +++ b/skills/baoyu-post-to-weibo/scripts/bun.lock @@ -4,7 +4,7 @@ "": { "name": "baoyu-post-to-weibo-scripts", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "marked": "^15.0.6", @@ -14,7 +14,7 @@ "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", {}], + "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], diff --git a/skills/baoyu-post-to-weibo/scripts/package.json b/skills/baoyu-post-to-weibo/scripts/package.json index 6ffd8e7..44b0089 100644 --- a/skills/baoyu-post-to-weibo/scripts/package.json +++ b/skills/baoyu-post-to-weibo/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", + "baoyu-chrome-cdp": "file:./vendor/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/vendor/baoyu-chrome-cdp/package.json b/skills/baoyu-post-to-weibo/scripts/vendor/baoyu-chrome-cdp/package.json new file mode 100644 index 0000000..0014ad3 --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/vendor/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/skills/baoyu-post-to-weibo/scripts/vendor/baoyu-chrome-cdp/src/index.ts b/skills/baoyu-post-to-weibo/scripts/vendor/baoyu-chrome-cdp/src/index.ts new file mode 100644 index 0000000..1fcd241 --- /dev/null +++ b/skills/baoyu-post-to-weibo/scripts/vendor/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/skills/baoyu-post-to-x/scripts/bun.lock b/skills/baoyu-post-to-x/scripts/bun.lock index edd677e..6f50e45 100644 --- a/skills/baoyu-post-to-x/scripts/bun.lock +++ b/skills/baoyu-post-to-x/scripts/bun.lock @@ -4,7 +4,7 @@ "": { "name": "baoyu-post-to-x-scripts", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp", "front-matter": "^4.0.2", "highlight.js": "^11.11.1", "marked": "^15.0.6", @@ -28,7 +28,7 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}], + "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], diff --git a/skills/baoyu-post-to-x/scripts/package.json b/skills/baoyu-post-to-x/scripts/package.json index 5b81600..caa9b93 100644 --- a/skills/baoyu-post-to-x/scripts/package.json +++ b/skills/baoyu-post-to-x/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "dependencies": { - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", + "baoyu-chrome-cdp": "file:./vendor/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/vendor/baoyu-chrome-cdp/package.json b/skills/baoyu-post-to-x/scripts/vendor/baoyu-chrome-cdp/package.json new file mode 100644 index 0000000..0014ad3 --- /dev/null +++ b/skills/baoyu-post-to-x/scripts/vendor/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/skills/baoyu-post-to-x/scripts/vendor/baoyu-chrome-cdp/src/index.ts b/skills/baoyu-post-to-x/scripts/vendor/baoyu-chrome-cdp/src/index.ts new file mode 100644 index 0000000..1fcd241 --- /dev/null +++ b/skills/baoyu-post-to-x/scripts/vendor/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/skills/baoyu-url-to-markdown/scripts/bun.lock b/skills/baoyu-url-to-markdown/scripts/bun.lock index c68e09c..50109d2 100644 --- a/skills/baoyu-url-to-markdown/scripts/bun.lock +++ b/skills/baoyu-url-to-markdown/scripts/bun.lock @@ -5,7 +5,7 @@ "name": "baoyu-url-to-markdown-scripts", "dependencies": { "@mozilla/readability": "^0.6.0", - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp", "defuddle": "^0.10.0", "jsdom": "^24.1.3", "linkedom": "^0.18.12", @@ -37,7 +37,7 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}], + "baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], diff --git a/skills/baoyu-url-to-markdown/scripts/package.json b/skills/baoyu-url-to-markdown/scripts/package.json index 23c82c3..e37fdae 100644 --- a/skills/baoyu-url-to-markdown/scripts/package.json +++ b/skills/baoyu-url-to-markdown/scripts/package.json @@ -4,7 +4,7 @@ "type": "module", "dependencies": { "@mozilla/readability": "^0.6.0", - "baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp", + "baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp", "defuddle": "^0.10.0", "jsdom": "^24.1.3", "linkedom": "^0.18.12", diff --git a/skills/baoyu-url-to-markdown/scripts/vendor/baoyu-chrome-cdp/package.json b/skills/baoyu-url-to-markdown/scripts/vendor/baoyu-chrome-cdp/package.json new file mode 100644 index 0000000..0014ad3 --- /dev/null +++ b/skills/baoyu-url-to-markdown/scripts/vendor/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/skills/baoyu-url-to-markdown/scripts/vendor/baoyu-chrome-cdp/src/index.ts b/skills/baoyu-url-to-markdown/scripts/vendor/baoyu-chrome-cdp/src/index.ts new file mode 100644 index 0000000..1fcd241 --- /dev/null +++ b/skills/baoyu-url-to-markdown/scripts/vendor/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 }; +}