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 |
|
||||
|------|---------|-------------------------|
|
||||
| `prepare_artifact` | Build a releasable artifact for one target | Vendor local deps, rewrite package metadata, stage files |
|
||||
| `prepare_artifact` | Build a releasable artifact for one target | Validate the target is self-contained, stage files, apply any project-specific packaging |
|
||||
| `publish_artifact` | Publish one prepared artifact | Upload artifact, attach version/changelog/tags |
|
||||
|
||||
Supported placeholders:
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
.clawdhub/
|
||||
.release-artifacts/
|
||||
.worktrees/
|
||||
|
|
|
|||
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -38,7 +38,9 @@ skills/
|
|||
|
||||
Top-level `scripts/` directory contains repository maintenance utilities:
|
||||
- `scripts/sync-clawhub.sh` - Publish skills to ClawHub/OpenClaw registry
|
||||
- `scripts/prepare-skill-artifact.mjs` - Build one releasable skill artifact with vendored local packages
|
||||
- `scripts/sync-shared-skill-packages.mjs` - Sync committed `vendor/` copies of shared workspace packages into skill script directories
|
||||
- `scripts/install-git-hooks.mjs` - Configure `.githooks/` as the repository-local git hooks path
|
||||
- `scripts/prepare-skill-artifact.mjs` - Build one releasable skill artifact from the already self-contained skill tree
|
||||
- `scripts/publish-skill-artifact.mjs` - Publish one prepared skill artifact
|
||||
- `scripts/sync-md-to-wechat.sh` - Sync markdown content to WeChat
|
||||
|
||||
|
|
@ -217,6 +219,17 @@ Requires `clawhub` CLI or `npx` (auto-downloads via npx if not installed).
|
|||
|
||||
Release-time artifact preparation is configured via `.releaserc.yml`. Keep registry/project-specific packaging in hook scripts instead of hardcoding it into generic release instructions.
|
||||
|
||||
### Shared Workspace Packages
|
||||
|
||||
Shared workspace packages under `packages/` are the **only** source of truth. Do not edit copies under `skills/*/scripts/vendor/` directly.
|
||||
|
||||
When updating a shared package:
|
||||
1. Edit the package under `packages/` first
|
||||
2. Run `node scripts/sync-shared-skill-packages.mjs`
|
||||
3. Review and commit the synced `skills/*/scripts/vendor/`, `package.json`, and `bun.lock` changes together
|
||||
|
||||
Use `node scripts/install-git-hooks.mjs` to enable the repository `pre-push` hook. The hook reruns the sync and blocks push if the committed vendor copies are stale. The release flow assumes `main` already contains the final installable tree and only validates/copies it.
|
||||
|
||||
## Skill Loading Rules
|
||||
|
||||
**IMPORTANT**: When working in this project, follow these rules:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
const baseFiles = await listTextFiles(root);
|
||||
const fileMap = new Map(baseFiles.map((file) => [file.relPath, file.bytes]));
|
||||
const vendoredPackages = new Set();
|
||||
|
||||
for (const file of baseFiles.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) {
|
||||
const packageDirRel = normalizeDirRel(path.posix.dirname(file.relPath));
|
||||
const rewritten = await rewritePackageJsonForRelease({
|
||||
root,
|
||||
packageDirRel,
|
||||
bytes: file.bytes,
|
||||
fileMap,
|
||||
vendoredPackages,
|
||||
});
|
||||
if (rewritten) {
|
||||
fileMap.set(file.relPath, rewritten);
|
||||
}
|
||||
}
|
||||
|
||||
return [...fileMap.entries()]
|
||||
.map(([relPath, bytes]) => ({ relPath, bytes }))
|
||||
.sort((left, right) => left.relPath.localeCompare(right.relPath));
|
||||
await validateSelfContainedRelease(root);
|
||||
return listTextFiles(root);
|
||||
}
|
||||
|
||||
export async function materializeReleaseFiles(files, outDir) {
|
||||
|
|
@ -113,88 +94,37 @@ export async function materializeReleaseFiles(files, outDir) {
|
|||
}
|
||||
}
|
||||
|
||||
async function rewritePackageJsonForRelease({ root, packageDirRel, bytes, fileMap, vendoredPackages }) {
|
||||
const packageJson = JSON.parse(bytes.toString("utf8"));
|
||||
let changed = false;
|
||||
|
||||
export async function validateSelfContainedRelease(root) {
|
||||
const files = await listTextFiles(root);
|
||||
for (const file of files.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) {
|
||||
const packageDir = path.resolve(root, fromPosixRel(path.posix.dirname(file.relPath)));
|
||||
const packageJson = JSON.parse(file.bytes.toString("utf8"));
|
||||
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||
const dependencies = packageJson[section];
|
||||
if (!dependencies || typeof dependencies !== "object") continue;
|
||||
|
||||
for (const [name, spec] of Object.entries(dependencies)) {
|
||||
if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
|
||||
|
||||
const sourceDir = path.resolve(root, fromPosixRel(packageDirRel), spec.slice(5));
|
||||
const vendorDirRel = normalizeDirRel(path.posix.join(packageDirRel, "vendor", name));
|
||||
await vendorPackageTree({
|
||||
sourceDir,
|
||||
targetDirRel: vendorDirRel,
|
||||
fileMap,
|
||||
vendoredPackages,
|
||||
});
|
||||
dependencies[name] = toFileDependencySpec(packageDirRel, vendorDirRel);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return null;
|
||||
return Buffer.from(`${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function vendorPackageTree({ sourceDir, targetDirRel, fileMap, vendoredPackages }) {
|
||||
const dedupeKey = `${path.resolve(sourceDir)}=>${targetDirRel}`;
|
||||
if (vendoredPackages.has(dedupeKey)) return;
|
||||
vendoredPackages.add(dedupeKey);
|
||||
|
||||
const files = await listTextFiles(sourceDir);
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Local package has no text files: ${sourceDir}`);
|
||||
}
|
||||
|
||||
const packageJson = files.find((file) => file.relPath === "package.json");
|
||||
if (!packageJson) {
|
||||
throw new Error(`Local package is missing package.json: ${sourceDir}`);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (file.relPath === "package.json") continue;
|
||||
fileMap.set(joinReleasePath(targetDirRel, file.relPath), file.bytes);
|
||||
}
|
||||
|
||||
const rewrittenPackageJson = await rewritePackageJsonForRelease({
|
||||
root: sourceDir,
|
||||
packageDirRel: ".",
|
||||
bytes: packageJson.bytes,
|
||||
fileMap: {
|
||||
set(relPath, outputBytes) {
|
||||
fileMap.set(joinReleasePath(targetDirRel, relPath), outputBytes);
|
||||
},
|
||||
},
|
||||
vendoredPackages,
|
||||
});
|
||||
|
||||
fileMap.set(
|
||||
joinReleasePath(targetDirRel, "package.json"),
|
||||
rewrittenPackageJson ?? packageJson.bytes,
|
||||
const targetDir = path.resolve(packageDir, spec.slice(5));
|
||||
if (!isWithinRoot(root, targetDir)) {
|
||||
throw new Error(
|
||||
`Release artifact is not self-contained: ${file.relPath} depends on ${name} via ${spec}`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDirRel(relPath) {
|
||||
return relPath === "." ? "." : relPath.split(path.sep).join("/");
|
||||
await fs.access(targetDir).catch(() => {
|
||||
throw new Error(`Missing local dependency for release: ${file.relPath} -> ${spec}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fromPosixRel(relPath) {
|
||||
return relPath === "." ? "." : relPath.split("/").join(path.sep);
|
||||
}
|
||||
|
||||
function joinReleasePath(base, relPath) {
|
||||
const joined = normalizeDirRel(path.posix.join(base === "." ? "" : base, relPath));
|
||||
return joined.replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
function toFileDependencySpec(fromDirRel, targetDirRel) {
|
||||
const fromDir = fromDirRel === "." ? "" : fromDirRel;
|
||||
const relative = path.posix.relative(fromDir || ".", targetDirRel);
|
||||
const normalized = relative === "" ? "." : relative;
|
||||
return `file:${normalized.startsWith(".") ? normalized : `./${normalized}`}`;
|
||||
function isWithinRoot(root, target) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const relative = path.relative(resolvedRoot, path.resolve(target));
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp"
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp"
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp"
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"packages": {
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
|
||||
"esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"front-matter": "^4.0.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"defuddle": "^0.10.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"linkedom": "^0.18.12",
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:../../../packages/baoyu-chrome-cdp", {}],
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"baoyu-chrome-cdp": "file:../../../packages/baoyu-chrome-cdp",
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
"defuddle": "^0.10.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"linkedom": "^0.18.12",
|
||||
|
|
|
|||
|
|
@ -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