build: commit vendored shared skill packages
This commit is contained in:
parent
069c5dc7d7
commit
3bba18c1fe
|
|
@ -57,7 +57,7 @@ Supported hooks:
|
||||||
|
|
||||||
| Hook | Purpose | Expected Responsibility |
|
| 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 |
|
| `publish_artifact` | Publish one prepared artifact | Upload artifact, attach version/changelog/tags |
|
||||||
|
|
||||||
Supported placeholders:
|
Supported placeholders:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -164,4 +164,5 @@ posts/
|
||||||
# ClawHub local state (current and legacy directory names from the official CLI)
|
# ClawHub local state (current and legacy directory names from the official CLI)
|
||||||
.clawhub/
|
.clawhub/
|
||||||
.clawdhub/
|
.clawdhub/
|
||||||
|
.release-artifacts/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
|
||||||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -38,7 +38,9 @@ skills/
|
||||||
|
|
||||||
Top-level `scripts/` directory contains repository maintenance utilities:
|
Top-level `scripts/` directory contains repository maintenance utilities:
|
||||||
- `scripts/sync-clawhub.sh` - Publish skills to ClawHub/OpenClaw registry
|
- `scripts/sync-clawhub.sh` - Publish skills to ClawHub/OpenClaw registry
|
||||||
- `scripts/prepare-skill-artifact.mjs` - Build one releasable skill artifact with vendored local packages
|
- `scripts/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/publish-skill-artifact.mjs` - Publish one prepared skill artifact
|
||||||
- `scripts/sync-md-to-wechat.sh` - Sync markdown content to WeChat
|
- `scripts/sync-md-to-wechat.sh` - Sync markdown content to WeChat
|
||||||
|
|
||||||
|
|
@ -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.
|
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
|
## Skill Loading Rules
|
||||||
|
|
||||||
**IMPORTANT**: When working in this project, follow these rules:
|
**IMPORTANT**: When working in this project, follow these rules:
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -81,27 +81,8 @@ export async function listTextFiles(root) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectReleaseFiles(root) {
|
export async function collectReleaseFiles(root) {
|
||||||
const baseFiles = await listTextFiles(root);
|
await validateSelfContainedRelease(root);
|
||||||
const fileMap = new Map(baseFiles.map((file) => [file.relPath, file.bytes]));
|
return listTextFiles(root);
|
||||||
const vendoredPackages = new Set();
|
|
||||||
|
|
||||||
for (const file of baseFiles.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) {
|
|
||||||
const packageDirRel = normalizeDirRel(path.posix.dirname(file.relPath));
|
|
||||||
const rewritten = await rewritePackageJsonForRelease({
|
|
||||||
root,
|
|
||||||
packageDirRel,
|
|
||||||
bytes: file.bytes,
|
|
||||||
fileMap,
|
|
||||||
vendoredPackages,
|
|
||||||
});
|
|
||||||
if (rewritten) {
|
|
||||||
fileMap.set(file.relPath, rewritten);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...fileMap.entries()]
|
|
||||||
.map(([relPath, bytes]) => ({ relPath, bytes }))
|
|
||||||
.sort((left, right) => left.relPath.localeCompare(right.relPath));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function materializeReleaseFiles(files, outDir) {
|
export async function materializeReleaseFiles(files, outDir) {
|
||||||
|
|
@ -113,88 +94,37 @@ export async function materializeReleaseFiles(files, outDir) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rewritePackageJsonForRelease({ root, packageDirRel, bytes, fileMap, vendoredPackages }) {
|
export async function validateSelfContainedRelease(root) {
|
||||||
const packageJson = JSON.parse(bytes.toString("utf8"));
|
const files = await listTextFiles(root);
|
||||||
let changed = false;
|
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) {
|
for (const [name, spec] of Object.entries(dependencies)) {
|
||||||
const dependencies = packageJson[section];
|
if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
|
||||||
if (!dependencies || typeof dependencies !== "object") continue;
|
const targetDir = path.resolve(packageDir, spec.slice(5));
|
||||||
|
if (!isWithinRoot(root, targetDir)) {
|
||||||
for (const [name, spec] of Object.entries(dependencies)) {
|
throw new Error(
|
||||||
if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
|
`Release artifact is not self-contained: ${file.relPath} depends on ${name} via ${spec}`,
|
||||||
|
);
|
||||||
const sourceDir = path.resolve(root, fromPosixRel(packageDirRel), spec.slice(5));
|
}
|
||||||
const vendorDirRel = normalizeDirRel(path.posix.join(packageDirRel, "vendor", name));
|
await fs.access(targetDir).catch(() => {
|
||||||
await vendorPackageTree({
|
throw new Error(`Missing local dependency for release: ${file.relPath} -> ${spec}`);
|
||||||
sourceDir,
|
});
|
||||||
targetDirRel: vendorDirRel,
|
}
|
||||||
fileMap,
|
|
||||||
vendoredPackages,
|
|
||||||
});
|
|
||||||
dependencies[name] = toFileDependencySpec(packageDirRel, vendorDirRel);
|
|
||||||
changed = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!changed) return null;
|
|
||||||
return Buffer.from(`${JSON.stringify(packageJson, null, 2)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function vendorPackageTree({ sourceDir, targetDirRel, fileMap, vendoredPackages }) {
|
|
||||||
const dedupeKey = `${path.resolve(sourceDir)}=>${targetDirRel}`;
|
|
||||||
if (vendoredPackages.has(dedupeKey)) return;
|
|
||||||
vendoredPackages.add(dedupeKey);
|
|
||||||
|
|
||||||
const files = await listTextFiles(sourceDir);
|
|
||||||
if (files.length === 0) {
|
|
||||||
throw new Error(`Local package has no text files: ${sourceDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const packageJson = files.find((file) => file.relPath === "package.json");
|
|
||||||
if (!packageJson) {
|
|
||||||
throw new Error(`Local package is missing package.json: ${sourceDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (file.relPath === "package.json") continue;
|
|
||||||
fileMap.set(joinReleasePath(targetDirRel, file.relPath), file.bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rewrittenPackageJson = await rewritePackageJsonForRelease({
|
|
||||||
root: sourceDir,
|
|
||||||
packageDirRel: ".",
|
|
||||||
bytes: packageJson.bytes,
|
|
||||||
fileMap: {
|
|
||||||
set(relPath, outputBytes) {
|
|
||||||
fileMap.set(joinReleasePath(targetDirRel, relPath), outputBytes);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vendoredPackages,
|
|
||||||
});
|
|
||||||
|
|
||||||
fileMap.set(
|
|
||||||
joinReleasePath(targetDirRel, "package.json"),
|
|
||||||
rewrittenPackageJson ?? packageJson.bytes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDirRel(relPath) {
|
|
||||||
return relPath === "." ? "." : relPath.split(path.sep).join("/");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromPosixRel(relPath) {
|
function fromPosixRel(relPath) {
|
||||||
return relPath === "." ? "." : relPath.split("/").join(path.sep);
|
return relPath === "." ? "." : relPath.split("/").join(path.sep);
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinReleasePath(base, relPath) {
|
function isWithinRoot(root, target) {
|
||||||
const joined = normalizeDirRel(path.posix.join(base === "." ? "" : base, relPath));
|
const resolvedRoot = path.resolve(root);
|
||||||
return joined.replace(/^\.\//, "");
|
const relative = path.relative(resolvedRoot, path.resolve(target));
|
||||||
}
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||||
|
|
||||||
function toFileDependencySpec(fromDirRel, targetDirRel) {
|
|
||||||
const fromDir = fromDirRel === "." ? "" : fromDirRel;
|
|
||||||
const relative = path.posix.relative(fromDir || ".", targetDirRel);
|
|
||||||
const normalized = relative === "" ? "." : relative;
|
|
||||||
return `file:${normalized.startsWith(".") ? normalized : `./${normalized}`}`;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,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 <dir> 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);
|
||||||
|
});
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "baoyu-danger-gemini-web-scripts",
|
"name": "baoyu-danger-gemini-web-scripts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp"
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "baoyu-chrome-cdp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
408
skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.ts
vendored
Normal file
408
skills/baoyu-danger-gemini-web/scripts/vendor/baoyu-chrome-cdp/src/index.ts
vendored
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
export type PlatformCandidates = {
|
||||||
|
darwin?: string[];
|
||||||
|
win32?: string[];
|
||||||
|
default: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CdpSendOptions = {
|
||||||
|
sessionId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchJsonOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindChromeExecutableOptions = {
|
||||||
|
candidates: PlatformCandidates;
|
||||||
|
envNames?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolveSharedChromeProfileDirOptions = {
|
||||||
|
envNames?: string[];
|
||||||
|
appDataDirName?: string;
|
||||||
|
profileDirName?: string;
|
||||||
|
wslWindowsHome?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindExistingChromeDebugPortOptions = {
|
||||||
|
profileDir: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LaunchChromeOptions = {
|
||||||
|
chromePath: string;
|
||||||
|
profileDir: string;
|
||||||
|
port: number;
|
||||||
|
url?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
extraArgs?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChromeTargetInfo = {
|
||||||
|
targetId: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenPageSessionOptions = {
|
||||||
|
cdp: CdpConnection;
|
||||||
|
reusing: boolean;
|
||||||
|
url: string;
|
||||||
|
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||||
|
enablePage?: boolean;
|
||||||
|
enableRuntime?: boolean;
|
||||||
|
enableDom?: boolean;
|
||||||
|
enableNetwork?: boolean;
|
||||||
|
activateTarget?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageSession = {
|
||||||
|
sessionId: string;
|
||||||
|
targetId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||||
|
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||||
|
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override && fs.existsSync(override)) return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = process.platform === "darwin"
|
||||||
|
? options.candidates.darwin ?? options.candidates.default
|
||||||
|
: process.platform === "win32"
|
||||||
|
? options.candidates.win32 ?? options.candidates.default
|
||||||
|
: options.candidates.default;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||||
|
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||||
|
|
||||||
|
if (options.wslWindowsHome) {
|
||||||
|
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.platform === "darwin"
|
||||||
|
? path.join(os.homedir(), "Library", "Application Support")
|
||||||
|
: process.platform === "win32"
|
||||||
|
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||||
|
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||||
|
return path.join(base, appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||||
|
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs }
|
||||||
|
);
|
||||||
|
return !!version.webSocketDebuggerUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(portFile, "utf-8");
|
||||||
|
const [portLine] = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status !== 0 || !result.stdout) return null;
|
||||||
|
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForChromeDebugPort(
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { includeLastError?: boolean }
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs: 5_000 }
|
||||||
|
);
|
||||||
|
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||||
|
lastError = new Error("Missing webSocketDebuggerUrl");
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeLastError && lastError) {
|
||||||
|
throw new Error(
|
||||||
|
`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Chrome debug port not ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CdpConnection {
|
||||||
|
private ws: WebSocket;
|
||||||
|
private nextId = 0;
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||||
|
private defaultTimeoutMs: number;
|
||||||
|
|
||||||
|
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||||
|
this.ws = ws;
|
||||||
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||||
|
|
||||||
|
this.ws.addEventListener("message", (event) => {
|
||||||
|
try {
|
||||||
|
const data = typeof event.data === "string"
|
||||||
|
? event.data
|
||||||
|
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||||
|
const msg = JSON.parse(data) as {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: unknown;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (msg.method) {
|
||||||
|
const handlers = this.eventHandlers.get(msg.method);
|
||||||
|
if (handlers) {
|
||||||
|
handlers.forEach((handler) => handler(msg.params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id) {
|
||||||
|
const pending = this.pending.get(msg.id);
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||||
|
else pending.resolve(msg.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener("close", () => {
|
||||||
|
for (const [id, pending] of this.pending.entries()) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error("CDP connection closed."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { defaultTimeoutMs?: number }
|
||||||
|
): Promise<CdpConnection> {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("CDP connection failed."));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(method: string, handler: (params: unknown) => void): void {
|
||||||
|
if (!this.eventHandlers.has(method)) {
|
||||||
|
this.eventHandlers.set(method, new Set());
|
||||||
|
}
|
||||||
|
this.eventHandlers.get(method)?.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(method: string, handler: (params: unknown) => void): void {
|
||||||
|
this.eventHandlers.get(method)?.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<T> {
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const message: Record<string, unknown> = { id, method };
|
||||||
|
if (params) message.params = params;
|
||||||
|
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||||
|
|
||||||
|
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||||
|
const result = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
const timer = timeoutMs > 0
|
||||||
|
? setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`CDP timeout: ${method}`));
|
||||||
|
}, timeoutMs)
|
||||||
|
: null;
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||||
|
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
`--remote-debugging-port=${options.port}`,
|
||||||
|
`--user-data-dir=${options.profileDir}`,
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
...(options.extraArgs ?? []),
|
||||||
|
];
|
||||||
|
if (options.headless) args.push("--headless=new");
|
||||||
|
if (options.url) args.push(options.url);
|
||||||
|
|
||||||
|
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killChrome(chrome: ChildProcess): void {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGTERM");
|
||||||
|
} catch {}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chrome.killed) {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGKILL");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, 2_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
|
let targetId: string;
|
||||||
|
|
||||||
|
if (options.reusing) {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
} else {
|
||||||
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
|
if (existing) {
|
||||||
|
targetId = existing.targetId;
|
||||||
|
} else {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||||
|
"Target.attachToTarget",
|
||||||
|
{ targetId, flatten: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.activateTarget ?? true) {
|
||||||
|
await options.cdp.send("Target.activateTarget", { targetId });
|
||||||
|
}
|
||||||
|
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||||
|
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||||
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
|
return { sessionId, targetId };
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "baoyu-danger-x-to-markdown-scripts",
|
"name": "baoyu-danger-x-to-markdown-scripts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp"
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/package.json
vendored
Normal file
9
skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/package.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "baoyu-chrome-cdp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
408
skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/src/index.ts
vendored
Normal file
408
skills/baoyu-danger-x-to-markdown/scripts/vendor/baoyu-chrome-cdp/src/index.ts
vendored
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
export type PlatformCandidates = {
|
||||||
|
darwin?: string[];
|
||||||
|
win32?: string[];
|
||||||
|
default: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CdpSendOptions = {
|
||||||
|
sessionId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchJsonOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindChromeExecutableOptions = {
|
||||||
|
candidates: PlatformCandidates;
|
||||||
|
envNames?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolveSharedChromeProfileDirOptions = {
|
||||||
|
envNames?: string[];
|
||||||
|
appDataDirName?: string;
|
||||||
|
profileDirName?: string;
|
||||||
|
wslWindowsHome?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindExistingChromeDebugPortOptions = {
|
||||||
|
profileDir: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LaunchChromeOptions = {
|
||||||
|
chromePath: string;
|
||||||
|
profileDir: string;
|
||||||
|
port: number;
|
||||||
|
url?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
extraArgs?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChromeTargetInfo = {
|
||||||
|
targetId: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenPageSessionOptions = {
|
||||||
|
cdp: CdpConnection;
|
||||||
|
reusing: boolean;
|
||||||
|
url: string;
|
||||||
|
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||||
|
enablePage?: boolean;
|
||||||
|
enableRuntime?: boolean;
|
||||||
|
enableDom?: boolean;
|
||||||
|
enableNetwork?: boolean;
|
||||||
|
activateTarget?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageSession = {
|
||||||
|
sessionId: string;
|
||||||
|
targetId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||||
|
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||||
|
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override && fs.existsSync(override)) return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = process.platform === "darwin"
|
||||||
|
? options.candidates.darwin ?? options.candidates.default
|
||||||
|
: process.platform === "win32"
|
||||||
|
? options.candidates.win32 ?? options.candidates.default
|
||||||
|
: options.candidates.default;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||||
|
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||||
|
|
||||||
|
if (options.wslWindowsHome) {
|
||||||
|
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.platform === "darwin"
|
||||||
|
? path.join(os.homedir(), "Library", "Application Support")
|
||||||
|
: process.platform === "win32"
|
||||||
|
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||||
|
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||||
|
return path.join(base, appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||||
|
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs }
|
||||||
|
);
|
||||||
|
return !!version.webSocketDebuggerUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(portFile, "utf-8");
|
||||||
|
const [portLine] = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status !== 0 || !result.stdout) return null;
|
||||||
|
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForChromeDebugPort(
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { includeLastError?: boolean }
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs: 5_000 }
|
||||||
|
);
|
||||||
|
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||||
|
lastError = new Error("Missing webSocketDebuggerUrl");
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeLastError && lastError) {
|
||||||
|
throw new Error(
|
||||||
|
`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Chrome debug port not ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CdpConnection {
|
||||||
|
private ws: WebSocket;
|
||||||
|
private nextId = 0;
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||||
|
private defaultTimeoutMs: number;
|
||||||
|
|
||||||
|
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||||
|
this.ws = ws;
|
||||||
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||||
|
|
||||||
|
this.ws.addEventListener("message", (event) => {
|
||||||
|
try {
|
||||||
|
const data = typeof event.data === "string"
|
||||||
|
? event.data
|
||||||
|
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||||
|
const msg = JSON.parse(data) as {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: unknown;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (msg.method) {
|
||||||
|
const handlers = this.eventHandlers.get(msg.method);
|
||||||
|
if (handlers) {
|
||||||
|
handlers.forEach((handler) => handler(msg.params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id) {
|
||||||
|
const pending = this.pending.get(msg.id);
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||||
|
else pending.resolve(msg.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener("close", () => {
|
||||||
|
for (const [id, pending] of this.pending.entries()) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error("CDP connection closed."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { defaultTimeoutMs?: number }
|
||||||
|
): Promise<CdpConnection> {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("CDP connection failed."));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(method: string, handler: (params: unknown) => void): void {
|
||||||
|
if (!this.eventHandlers.has(method)) {
|
||||||
|
this.eventHandlers.set(method, new Set());
|
||||||
|
}
|
||||||
|
this.eventHandlers.get(method)?.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(method: string, handler: (params: unknown) => void): void {
|
||||||
|
this.eventHandlers.get(method)?.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<T> {
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const message: Record<string, unknown> = { id, method };
|
||||||
|
if (params) message.params = params;
|
||||||
|
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||||
|
|
||||||
|
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||||
|
const result = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
const timer = timeoutMs > 0
|
||||||
|
? setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`CDP timeout: ${method}`));
|
||||||
|
}, timeoutMs)
|
||||||
|
: null;
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||||
|
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
`--remote-debugging-port=${options.port}`,
|
||||||
|
`--user-data-dir=${options.profileDir}`,
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
...(options.extraArgs ?? []),
|
||||||
|
];
|
||||||
|
if (options.headless) args.push("--headless=new");
|
||||||
|
if (options.url) args.push(options.url);
|
||||||
|
|
||||||
|
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killChrome(chrome: ChildProcess): void {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGTERM");
|
||||||
|
} catch {}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chrome.killed) {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGKILL");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, 2_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
|
let targetId: string;
|
||||||
|
|
||||||
|
if (options.reusing) {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
} else {
|
||||||
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
|
if (existing) {
|
||||||
|
targetId = existing.targetId;
|
||||||
|
} else {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||||
|
"Target.attachToTarget",
|
||||||
|
{ targetId, flatten: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.activateTarget ?? true) {
|
||||||
|
await options.cdp.send("Target.activateTarget", { targetId });
|
||||||
|
}
|
||||||
|
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||||
|
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||||
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
|
return { sessionId, targetId };
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,11 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "baoyu-post-to-wechat-scripts",
|
"name": "baoyu-post-to-wechat-scripts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp"
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "baoyu-chrome-cdp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
export type PlatformCandidates = {
|
||||||
|
darwin?: string[];
|
||||||
|
win32?: string[];
|
||||||
|
default: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CdpSendOptions = {
|
||||||
|
sessionId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchJsonOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindChromeExecutableOptions = {
|
||||||
|
candidates: PlatformCandidates;
|
||||||
|
envNames?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolveSharedChromeProfileDirOptions = {
|
||||||
|
envNames?: string[];
|
||||||
|
appDataDirName?: string;
|
||||||
|
profileDirName?: string;
|
||||||
|
wslWindowsHome?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindExistingChromeDebugPortOptions = {
|
||||||
|
profileDir: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LaunchChromeOptions = {
|
||||||
|
chromePath: string;
|
||||||
|
profileDir: string;
|
||||||
|
port: number;
|
||||||
|
url?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
extraArgs?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChromeTargetInfo = {
|
||||||
|
targetId: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenPageSessionOptions = {
|
||||||
|
cdp: CdpConnection;
|
||||||
|
reusing: boolean;
|
||||||
|
url: string;
|
||||||
|
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||||
|
enablePage?: boolean;
|
||||||
|
enableRuntime?: boolean;
|
||||||
|
enableDom?: boolean;
|
||||||
|
enableNetwork?: boolean;
|
||||||
|
activateTarget?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageSession = {
|
||||||
|
sessionId: string;
|
||||||
|
targetId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||||
|
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||||
|
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override && fs.existsSync(override)) return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = process.platform === "darwin"
|
||||||
|
? options.candidates.darwin ?? options.candidates.default
|
||||||
|
: process.platform === "win32"
|
||||||
|
? options.candidates.win32 ?? options.candidates.default
|
||||||
|
: options.candidates.default;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||||
|
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||||
|
|
||||||
|
if (options.wslWindowsHome) {
|
||||||
|
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.platform === "darwin"
|
||||||
|
? path.join(os.homedir(), "Library", "Application Support")
|
||||||
|
: process.platform === "win32"
|
||||||
|
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||||
|
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||||
|
return path.join(base, appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||||
|
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs }
|
||||||
|
);
|
||||||
|
return !!version.webSocketDebuggerUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(portFile, "utf-8");
|
||||||
|
const [portLine] = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status !== 0 || !result.stdout) return null;
|
||||||
|
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForChromeDebugPort(
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { includeLastError?: boolean }
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs: 5_000 }
|
||||||
|
);
|
||||||
|
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||||
|
lastError = new Error("Missing webSocketDebuggerUrl");
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeLastError && lastError) {
|
||||||
|
throw new Error(
|
||||||
|
`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Chrome debug port not ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CdpConnection {
|
||||||
|
private ws: WebSocket;
|
||||||
|
private nextId = 0;
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||||
|
private defaultTimeoutMs: number;
|
||||||
|
|
||||||
|
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||||
|
this.ws = ws;
|
||||||
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||||
|
|
||||||
|
this.ws.addEventListener("message", (event) => {
|
||||||
|
try {
|
||||||
|
const data = typeof event.data === "string"
|
||||||
|
? event.data
|
||||||
|
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||||
|
const msg = JSON.parse(data) as {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: unknown;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (msg.method) {
|
||||||
|
const handlers = this.eventHandlers.get(msg.method);
|
||||||
|
if (handlers) {
|
||||||
|
handlers.forEach((handler) => handler(msg.params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id) {
|
||||||
|
const pending = this.pending.get(msg.id);
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||||
|
else pending.resolve(msg.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener("close", () => {
|
||||||
|
for (const [id, pending] of this.pending.entries()) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error("CDP connection closed."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { defaultTimeoutMs?: number }
|
||||||
|
): Promise<CdpConnection> {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("CDP connection failed."));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(method: string, handler: (params: unknown) => void): void {
|
||||||
|
if (!this.eventHandlers.has(method)) {
|
||||||
|
this.eventHandlers.set(method, new Set());
|
||||||
|
}
|
||||||
|
this.eventHandlers.get(method)?.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(method: string, handler: (params: unknown) => void): void {
|
||||||
|
this.eventHandlers.get(method)?.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<T> {
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const message: Record<string, unknown> = { id, method };
|
||||||
|
if (params) message.params = params;
|
||||||
|
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||||
|
|
||||||
|
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||||
|
const result = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
const timer = timeoutMs > 0
|
||||||
|
? setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`CDP timeout: ${method}`));
|
||||||
|
}, timeoutMs)
|
||||||
|
: null;
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||||
|
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
`--remote-debugging-port=${options.port}`,
|
||||||
|
`--user-data-dir=${options.profileDir}`,
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
...(options.extraArgs ?? []),
|
||||||
|
];
|
||||||
|
if (options.headless) args.push("--headless=new");
|
||||||
|
if (options.url) args.push(options.url);
|
||||||
|
|
||||||
|
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killChrome(chrome: ChildProcess): void {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGTERM");
|
||||||
|
} catch {}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chrome.killed) {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGKILL");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, 2_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
|
let targetId: string;
|
||||||
|
|
||||||
|
if (options.reusing) {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
} else {
|
||||||
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
|
if (existing) {
|
||||||
|
targetId = existing.targetId;
|
||||||
|
} else {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||||
|
"Target.attachToTarget",
|
||||||
|
{ targetId, flatten: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.activateTarget ?? true) {
|
||||||
|
await options.cdp.send("Target.activateTarget", { targetId });
|
||||||
|
}
|
||||||
|
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||||
|
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||||
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
|
return { sessionId, targetId };
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "baoyu-post-to-weibo-scripts",
|
"name": "baoyu-post-to-weibo-scripts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"packages": {
|
"packages": {
|
||||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
"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=="],
|
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^15.0.6"
|
"marked": "^15.0.6"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "baoyu-chrome-cdp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
export type PlatformCandidates = {
|
||||||
|
darwin?: string[];
|
||||||
|
win32?: string[];
|
||||||
|
default: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CdpSendOptions = {
|
||||||
|
sessionId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchJsonOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindChromeExecutableOptions = {
|
||||||
|
candidates: PlatformCandidates;
|
||||||
|
envNames?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolveSharedChromeProfileDirOptions = {
|
||||||
|
envNames?: string[];
|
||||||
|
appDataDirName?: string;
|
||||||
|
profileDirName?: string;
|
||||||
|
wslWindowsHome?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindExistingChromeDebugPortOptions = {
|
||||||
|
profileDir: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LaunchChromeOptions = {
|
||||||
|
chromePath: string;
|
||||||
|
profileDir: string;
|
||||||
|
port: number;
|
||||||
|
url?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
extraArgs?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChromeTargetInfo = {
|
||||||
|
targetId: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenPageSessionOptions = {
|
||||||
|
cdp: CdpConnection;
|
||||||
|
reusing: boolean;
|
||||||
|
url: string;
|
||||||
|
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||||
|
enablePage?: boolean;
|
||||||
|
enableRuntime?: boolean;
|
||||||
|
enableDom?: boolean;
|
||||||
|
enableNetwork?: boolean;
|
||||||
|
activateTarget?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageSession = {
|
||||||
|
sessionId: string;
|
||||||
|
targetId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||||
|
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||||
|
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override && fs.existsSync(override)) return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = process.platform === "darwin"
|
||||||
|
? options.candidates.darwin ?? options.candidates.default
|
||||||
|
: process.platform === "win32"
|
||||||
|
? options.candidates.win32 ?? options.candidates.default
|
||||||
|
: options.candidates.default;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||||
|
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||||
|
|
||||||
|
if (options.wslWindowsHome) {
|
||||||
|
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.platform === "darwin"
|
||||||
|
? path.join(os.homedir(), "Library", "Application Support")
|
||||||
|
: process.platform === "win32"
|
||||||
|
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||||
|
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||||
|
return path.join(base, appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||||
|
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs }
|
||||||
|
);
|
||||||
|
return !!version.webSocketDebuggerUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(portFile, "utf-8");
|
||||||
|
const [portLine] = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status !== 0 || !result.stdout) return null;
|
||||||
|
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForChromeDebugPort(
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { includeLastError?: boolean }
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs: 5_000 }
|
||||||
|
);
|
||||||
|
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||||
|
lastError = new Error("Missing webSocketDebuggerUrl");
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeLastError && lastError) {
|
||||||
|
throw new Error(
|
||||||
|
`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Chrome debug port not ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CdpConnection {
|
||||||
|
private ws: WebSocket;
|
||||||
|
private nextId = 0;
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||||
|
private defaultTimeoutMs: number;
|
||||||
|
|
||||||
|
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||||
|
this.ws = ws;
|
||||||
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||||
|
|
||||||
|
this.ws.addEventListener("message", (event) => {
|
||||||
|
try {
|
||||||
|
const data = typeof event.data === "string"
|
||||||
|
? event.data
|
||||||
|
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||||
|
const msg = JSON.parse(data) as {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: unknown;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (msg.method) {
|
||||||
|
const handlers = this.eventHandlers.get(msg.method);
|
||||||
|
if (handlers) {
|
||||||
|
handlers.forEach((handler) => handler(msg.params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id) {
|
||||||
|
const pending = this.pending.get(msg.id);
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||||
|
else pending.resolve(msg.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener("close", () => {
|
||||||
|
for (const [id, pending] of this.pending.entries()) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error("CDP connection closed."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { defaultTimeoutMs?: number }
|
||||||
|
): Promise<CdpConnection> {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("CDP connection failed."));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(method: string, handler: (params: unknown) => void): void {
|
||||||
|
if (!this.eventHandlers.has(method)) {
|
||||||
|
this.eventHandlers.set(method, new Set());
|
||||||
|
}
|
||||||
|
this.eventHandlers.get(method)?.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(method: string, handler: (params: unknown) => void): void {
|
||||||
|
this.eventHandlers.get(method)?.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<T> {
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const message: Record<string, unknown> = { id, method };
|
||||||
|
if (params) message.params = params;
|
||||||
|
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||||
|
|
||||||
|
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||||
|
const result = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
const timer = timeoutMs > 0
|
||||||
|
? setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`CDP timeout: ${method}`));
|
||||||
|
}, timeoutMs)
|
||||||
|
: null;
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||||
|
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
`--remote-debugging-port=${options.port}`,
|
||||||
|
`--user-data-dir=${options.profileDir}`,
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
...(options.extraArgs ?? []),
|
||||||
|
];
|
||||||
|
if (options.headless) args.push("--headless=new");
|
||||||
|
if (options.url) args.push(options.url);
|
||||||
|
|
||||||
|
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killChrome(chrome: ChildProcess): void {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGTERM");
|
||||||
|
} catch {}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chrome.killed) {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGKILL");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, 2_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
|
let targetId: string;
|
||||||
|
|
||||||
|
if (options.reusing) {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
} else {
|
||||||
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
|
if (existing) {
|
||||||
|
targetId = existing.targetId;
|
||||||
|
} else {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||||
|
"Target.attachToTarget",
|
||||||
|
{ targetId, flatten: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.activateTarget ?? true) {
|
||||||
|
await options.cdp.send("Target.activateTarget", { targetId });
|
||||||
|
}
|
||||||
|
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||||
|
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||||
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
|
return { sessionId, targetId };
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "baoyu-post-to-x-scripts",
|
"name": "baoyu-post-to-x-scripts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
|
|
||||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||||
|
|
||||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
"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=="],
|
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"front-matter": "^4.0.2",
|
"front-matter": "^4.0.2",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "baoyu-chrome-cdp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
export type PlatformCandidates = {
|
||||||
|
darwin?: string[];
|
||||||
|
win32?: string[];
|
||||||
|
default: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CdpSendOptions = {
|
||||||
|
sessionId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchJsonOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindChromeExecutableOptions = {
|
||||||
|
candidates: PlatformCandidates;
|
||||||
|
envNames?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolveSharedChromeProfileDirOptions = {
|
||||||
|
envNames?: string[];
|
||||||
|
appDataDirName?: string;
|
||||||
|
profileDirName?: string;
|
||||||
|
wslWindowsHome?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindExistingChromeDebugPortOptions = {
|
||||||
|
profileDir: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LaunchChromeOptions = {
|
||||||
|
chromePath: string;
|
||||||
|
profileDir: string;
|
||||||
|
port: number;
|
||||||
|
url?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
extraArgs?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChromeTargetInfo = {
|
||||||
|
targetId: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenPageSessionOptions = {
|
||||||
|
cdp: CdpConnection;
|
||||||
|
reusing: boolean;
|
||||||
|
url: string;
|
||||||
|
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||||
|
enablePage?: boolean;
|
||||||
|
enableRuntime?: boolean;
|
||||||
|
enableDom?: boolean;
|
||||||
|
enableNetwork?: boolean;
|
||||||
|
activateTarget?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageSession = {
|
||||||
|
sessionId: string;
|
||||||
|
targetId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||||
|
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||||
|
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override && fs.existsSync(override)) return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = process.platform === "darwin"
|
||||||
|
? options.candidates.darwin ?? options.candidates.default
|
||||||
|
: process.platform === "win32"
|
||||||
|
? options.candidates.win32 ?? options.candidates.default
|
||||||
|
: options.candidates.default;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||||
|
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||||
|
|
||||||
|
if (options.wslWindowsHome) {
|
||||||
|
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.platform === "darwin"
|
||||||
|
? path.join(os.homedir(), "Library", "Application Support")
|
||||||
|
: process.platform === "win32"
|
||||||
|
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||||
|
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||||
|
return path.join(base, appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||||
|
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs }
|
||||||
|
);
|
||||||
|
return !!version.webSocketDebuggerUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(portFile, "utf-8");
|
||||||
|
const [portLine] = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status !== 0 || !result.stdout) return null;
|
||||||
|
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForChromeDebugPort(
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { includeLastError?: boolean }
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs: 5_000 }
|
||||||
|
);
|
||||||
|
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||||
|
lastError = new Error("Missing webSocketDebuggerUrl");
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeLastError && lastError) {
|
||||||
|
throw new Error(
|
||||||
|
`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Chrome debug port not ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CdpConnection {
|
||||||
|
private ws: WebSocket;
|
||||||
|
private nextId = 0;
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||||
|
private defaultTimeoutMs: number;
|
||||||
|
|
||||||
|
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||||
|
this.ws = ws;
|
||||||
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||||
|
|
||||||
|
this.ws.addEventListener("message", (event) => {
|
||||||
|
try {
|
||||||
|
const data = typeof event.data === "string"
|
||||||
|
? event.data
|
||||||
|
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||||
|
const msg = JSON.parse(data) as {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: unknown;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (msg.method) {
|
||||||
|
const handlers = this.eventHandlers.get(msg.method);
|
||||||
|
if (handlers) {
|
||||||
|
handlers.forEach((handler) => handler(msg.params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id) {
|
||||||
|
const pending = this.pending.get(msg.id);
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||||
|
else pending.resolve(msg.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener("close", () => {
|
||||||
|
for (const [id, pending] of this.pending.entries()) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error("CDP connection closed."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { defaultTimeoutMs?: number }
|
||||||
|
): Promise<CdpConnection> {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("CDP connection failed."));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(method: string, handler: (params: unknown) => void): void {
|
||||||
|
if (!this.eventHandlers.has(method)) {
|
||||||
|
this.eventHandlers.set(method, new Set());
|
||||||
|
}
|
||||||
|
this.eventHandlers.get(method)?.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(method: string, handler: (params: unknown) => void): void {
|
||||||
|
this.eventHandlers.get(method)?.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<T> {
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const message: Record<string, unknown> = { id, method };
|
||||||
|
if (params) message.params = params;
|
||||||
|
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||||
|
|
||||||
|
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||||
|
const result = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
const timer = timeoutMs > 0
|
||||||
|
? setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`CDP timeout: ${method}`));
|
||||||
|
}, timeoutMs)
|
||||||
|
: null;
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||||
|
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
`--remote-debugging-port=${options.port}`,
|
||||||
|
`--user-data-dir=${options.profileDir}`,
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
...(options.extraArgs ?? []),
|
||||||
|
];
|
||||||
|
if (options.headless) args.push("--headless=new");
|
||||||
|
if (options.url) args.push(options.url);
|
||||||
|
|
||||||
|
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killChrome(chrome: ChildProcess): void {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGTERM");
|
||||||
|
} catch {}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chrome.killed) {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGKILL");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, 2_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
|
let targetId: string;
|
||||||
|
|
||||||
|
if (options.reusing) {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
} else {
|
||||||
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
|
if (existing) {
|
||||||
|
targetId = existing.targetId;
|
||||||
|
} else {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||||
|
"Target.attachToTarget",
|
||||||
|
{ targetId, flatten: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.activateTarget ?? true) {
|
||||||
|
await options.cdp.send("Target.activateTarget", { targetId });
|
||||||
|
}
|
||||||
|
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||||
|
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||||
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
|
return { sessionId, targetId };
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"name": "baoyu-url-to-markdown-scripts",
|
"name": "baoyu-url-to-markdown-scripts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"defuddle": "^0.10.0",
|
"defuddle": "^0.10.0",
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
|
|
||||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||||
|
|
||||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||||
|
|
||||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||||
"defuddle": "^0.10.0",
|
"defuddle": "^0.10.0",
|
||||||
"jsdom": "^24.1.3",
|
"jsdom": "^24.1.3",
|
||||||
"linkedom": "^0.18.12",
|
"linkedom": "^0.18.12",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "baoyu-chrome-cdp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,408 @@
|
||||||
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
export type PlatformCandidates = {
|
||||||
|
darwin?: string[];
|
||||||
|
win32?: string[];
|
||||||
|
default: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PendingRequest = {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CdpSendOptions = {
|
||||||
|
sessionId?: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FetchJsonOptions = {
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindChromeExecutableOptions = {
|
||||||
|
candidates: PlatformCandidates;
|
||||||
|
envNames?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolveSharedChromeProfileDirOptions = {
|
||||||
|
envNames?: string[];
|
||||||
|
appDataDirName?: string;
|
||||||
|
profileDirName?: string;
|
||||||
|
wslWindowsHome?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FindExistingChromeDebugPortOptions = {
|
||||||
|
profileDir: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LaunchChromeOptions = {
|
||||||
|
chromePath: string;
|
||||||
|
profileDir: string;
|
||||||
|
port: number;
|
||||||
|
url?: string;
|
||||||
|
headless?: boolean;
|
||||||
|
extraArgs?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChromeTargetInfo = {
|
||||||
|
targetId: string;
|
||||||
|
url: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenPageSessionOptions = {
|
||||||
|
cdp: CdpConnection;
|
||||||
|
reusing: boolean;
|
||||||
|
url: string;
|
||||||
|
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||||
|
enablePage?: boolean;
|
||||||
|
enableRuntime?: boolean;
|
||||||
|
enableDom?: boolean;
|
||||||
|
enableNetwork?: boolean;
|
||||||
|
activateTarget?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageSession = {
|
||||||
|
sessionId: string;
|
||||||
|
targetId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||||
|
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||||
|
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.on("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = address.port;
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override && fs.existsSync(override)) return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = process.platform === "darwin"
|
||||||
|
? options.candidates.darwin ?? options.candidates.default
|
||||||
|
: process.platform === "win32"
|
||||||
|
? options.candidates.win32 ?? options.candidates.default
|
||||||
|
: options.candidates.default;
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||||
|
for (const envName of options.envNames ?? []) {
|
||||||
|
const override = process.env[envName]?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||||
|
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||||
|
|
||||||
|
if (options.wslWindowsHome) {
|
||||||
|
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = process.platform === "darwin"
|
||||||
|
? path.join(os.homedir(), "Library", "Application Support")
|
||||||
|
: process.platform === "win32"
|
||||||
|
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||||
|
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||||
|
return path.join(base, appDataDirName, profileDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||||
|
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||||
|
|
||||||
|
const ctl = new AbortController();
|
||||||
|
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||||
|
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return await response.json() as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs }
|
||||||
|
);
|
||||||
|
return !!version.webSocketDebuggerUrl;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||||
|
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||||
|
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(portFile, "utf-8");
|
||||||
|
const [portLine] = content.split(/\r?\n/);
|
||||||
|
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (process.platform === "win32") return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||||
|
if (result.status !== 0 || !result.stdout) return null;
|
||||||
|
|
||||||
|
const lines = result.stdout
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||||
|
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||||
|
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForChromeDebugPort(
|
||||||
|
port: number,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { includeLastError?: boolean }
|
||||||
|
): Promise<string> {
|
||||||
|
const start = Date.now();
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||||
|
`http://127.0.0.1:${port}/json/version`,
|
||||||
|
{ timeoutMs: 5_000 }
|
||||||
|
);
|
||||||
|
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||||
|
lastError = new Error("Missing webSocketDebuggerUrl");
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.includeLastError && lastError) {
|
||||||
|
throw new Error(
|
||||||
|
`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Chrome debug port not ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CdpConnection {
|
||||||
|
private ws: WebSocket;
|
||||||
|
private nextId = 0;
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||||
|
private defaultTimeoutMs: number;
|
||||||
|
|
||||||
|
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||||
|
this.ws = ws;
|
||||||
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||||
|
|
||||||
|
this.ws.addEventListener("message", (event) => {
|
||||||
|
try {
|
||||||
|
const data = typeof event.data === "string"
|
||||||
|
? event.data
|
||||||
|
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||||
|
const msg = JSON.parse(data) as {
|
||||||
|
id?: number;
|
||||||
|
method?: string;
|
||||||
|
params?: unknown;
|
||||||
|
result?: unknown;
|
||||||
|
error?: { message?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (msg.method) {
|
||||||
|
const handlers = this.eventHandlers.get(msg.method);
|
||||||
|
if (handlers) {
|
||||||
|
handlers.forEach((handler) => handler(msg.params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.id) {
|
||||||
|
const pending = this.pending.get(msg.id);
|
||||||
|
if (pending) {
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||||
|
else pending.resolve(msg.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.addEventListener("close", () => {
|
||||||
|
for (const [id, pending] of this.pending.entries()) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
if (pending.timer) clearTimeout(pending.timer);
|
||||||
|
pending.reject(new Error("CDP connection closed."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(
|
||||||
|
url: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
options?: { defaultTimeoutMs?: number }
|
||||||
|
): Promise<CdpConnection> {
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||||
|
ws.addEventListener("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.addEventListener("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error("CDP connection failed."));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(method: string, handler: (params: unknown) => void): void {
|
||||||
|
if (!this.eventHandlers.has(method)) {
|
||||||
|
this.eventHandlers.set(method, new Set());
|
||||||
|
}
|
||||||
|
this.eventHandlers.get(method)?.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(method: string, handler: (params: unknown) => void): void {
|
||||||
|
this.eventHandlers.get(method)?.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<T> {
|
||||||
|
const id = ++this.nextId;
|
||||||
|
const message: Record<string, unknown> = { id, method };
|
||||||
|
if (params) message.params = params;
|
||||||
|
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||||
|
|
||||||
|
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||||
|
const result = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
const timer = timeoutMs > 0
|
||||||
|
? setTimeout(() => {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`CDP timeout: ${method}`));
|
||||||
|
}, timeoutMs)
|
||||||
|
: null;
|
||||||
|
this.pending.set(id, { resolve, reject, timer });
|
||||||
|
this.ws.send(JSON.stringify(message));
|
||||||
|
});
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
this.ws.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||||
|
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
`--remote-debugging-port=${options.port}`,
|
||||||
|
`--user-data-dir=${options.profileDir}`,
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
...(options.extraArgs ?? []),
|
||||||
|
];
|
||||||
|
if (options.headless) args.push("--headless=new");
|
||||||
|
if (options.url) args.push(options.url);
|
||||||
|
|
||||||
|
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killChrome(chrome: ChildProcess): void {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGTERM");
|
||||||
|
} catch {}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chrome.killed) {
|
||||||
|
try {
|
||||||
|
chrome.kill("SIGKILL");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, 2_000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||||
|
let targetId: string;
|
||||||
|
|
||||||
|
if (options.reusing) {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
} else {
|
||||||
|
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||||
|
const existing = targets.targetInfos.find(options.matchTarget);
|
||||||
|
if (existing) {
|
||||||
|
targetId = existing.targetId;
|
||||||
|
} else {
|
||||||
|
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||||
|
targetId = created.targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||||
|
"Target.attachToTarget",
|
||||||
|
{ targetId, flatten: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.activateTarget ?? true) {
|
||||||
|
await options.cdp.send("Target.activateTarget", { targetId });
|
||||||
|
}
|
||||||
|
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||||
|
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||||
|
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||||
|
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||||
|
|
||||||
|
return { sessionId, targetId };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue