Claude Skill

browser-cdp

Use this skill when you need to control a Chrome browser via CDP (Chrome DevTools Protocol) to reuse existing login sessions. Covers: launching Chrome in debug mode, opening URLs, waiting for page load, evaluating JavaScript, taking snapshots, and extracting auth tokens. Trigger

LLM Mart · 0 points · 14 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download zenstory-ai-oh-story-claudecode-skills_browser-cdp-d1f8858.zip · 18 KB
Part of zenstory-ai/oh-story-claudecode — 26 skills

Install

skills CLI npx skills add https://github.com/zenstory-ai/oh-story-claudecode/tree/main/skills/browser-cdp
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zenstory-ai-oh-story-claudecode@llmmart
Git git clone https://github.com/zenstory-ai/oh-story-claudecode.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole zenstory-ai/oh-story-claudecode collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Browser CDP 操作工具

通过 CDP 协议控制 Chrome,复用已有登录态,执行浏览器自动化操作。

前置条件

  • macOS / Linux / Windows(实验性),已安装 Google Chrome
  • Node.js 20+
  • agent-browser 已安装:npm install -g agent-browser

⚠️ 首次启动会 kill 用户的常规 Chrome。 在启动前必须征求用户同意(见下方"启动流程"),否则用户可能丢失未保存的标签页/草稿。


启动流程(skill-mode 强制步骤)

第一步:探测当前状态(无副作用)

node {SKILL_DIR}/scripts/setup-cdp-chrome.js 9222 --detect-only

输出形如:

CDP_STATUS=ready                        # 已就绪,可直接复用
CDP_URL=http://127.0.0.1:9222/json/version
BROWSER=Chrome/148.0.7778.168

或:

CDP_STATUS=needs-setup
CHROME_RUNNING=yes                      # 用户有 Chrome 在跑,启动会杀掉
CHROME_PID_COUNT=3

第二步:根据探测结果分支

  • CDP_STATUS=ready → 直接使用 agent-browser --cdp 9222 ...,不要运行 setup。
  • CDP_STATUS=needs-setup 且 CHROME_RUNNING=no → 安全启动:
    node {SKILL_DIR}/scripts/setup-cdp-chrome.js 9222 --yes
    
  • CDP_STATUS=needs-setup 且 CHROME_RUNNING=yes → 先用 AskUserQuestion 工具向用户确认:告知会杀掉 N 个 Chrome 进程、可能丢失未保存工作;用户同意后再带 --yes 启动;用户拒绝则放弃这次自动化。

为什么不能直接 --yes: 脚本在非 TTY(即 skill 模式 / Bash 工具)下,如果检测到 Chrome 在跑而没有 --yes,会以退出码 3 报 NEEDS_CONSENT: ... 并中止,不会静默杀进程。这是有意的兜底——但 skill 流程仍应先问用户,而不是看到 3 就盲传 --yes。


启动脚本选项

选项 说明
--detect-only 只探测,不修改任何状态(skill 用)
--yes 已征得同意,跳过交互提示
--reset 启动前清空 ~/chrome-debug-profile(登录失效时用)
--profile <name> 使用非 Default 的 Chrome profile(如 "Profile 1")
--dry-run 打印将执行的步骤,不执行

退出码:0 成功 / 1 通用错误 / 2 用户拒绝(TTY)/ 3 需同意但缺 --yes。


常用操作

打开页面并等待加载

agent-browser --cdp 9222 open "<URL>"
agent-browser --cdp 9222 wait 3000

提取页面文本

agent-browser --cdp 9222 eval 'document.body.innerText.substring(0, 8000)'

提取 Auth Token

agent-browser --cdp 9222 eval 'localStorage.getItem("token") || document.cookie'

复杂 JS(含引号 / $ / 反引号)

shell 转义容易出错,用以下两种方式之一:

# 1) base64 包裹
agent-browser --cdp 9222 eval -b "$(echo -n "document.querySelectorAll('a').length" | base64)"

# 2) heredoc + --stdin
cat <<'EOF' | agent-browser --cdp 9222 eval --stdin
const links = document.querySelectorAll('a');
links.length;
EOF

页面交互(snapshot 拿元素引用)

agent-browser --cdp 9222 snapshot -i        # 仅交互元素
agent-browser --cdp 9222 click "<CSS or @e1>"
agent-browser --cdp 9222 type "<sel>" "<text>"

停止 / 清理

  • 关掉 debug Chrome 窗口即可。若窗口无响应,先按 --user-data-dir 核验出 debug 实例的 PID 再只结束它:
    • macOS / Linux:pgrep -af chrome-debug-profile
    • Windows:wmic process where "name='chrome.exe'" get ProcessId,CommandLine | findstr chrome-debug-profile 拿到 PID 后 kill -9 {PID} / taskkill /F /PID {PID}。核验不出归属时停止,手工清理不得按 Chrome 可执行文件名批量结束进程——那会连带杀掉用户的日常 Chrome。 例外:setup-cdp-chrome.js --reset 内部确实会做一次按可执行名的清理,它属于本 skill 自带的、需 --yes 显式同意的启动流程;手工排障不要复制该做法。
  • 登录态失效:node {SKILL_DIR}/scripts/setup-cdp-chrome.js 9222 --reset --yes(注意 --yes 同样需要先问用户)。

OpenCode 环境注意事项

opencode 没有后台执行命令行的工具,长时间的 CDP 操作(如等待页面加载、大批量数据抓取)会阻塞整个会话,导致 CLI 无响应。

超时包装

Windows 上对 CDP 命令使用 PowerShell Job 包装超时:

$job = Start-Job { agent-browser --cdp 9222 eval "window.location.replace('https://www.qidian.com/rank/')" }
Wait-Job $job -Timeout 30 | Out-Null
if ($job.State -eq 'Running') { Stop-Job $job; Write-Output "⏱ CDP 操作超时(30s),请重试或手动打断" }
else { Receive-Job $job }
Remove-Job $job -Force

macOS / Linux 上使用 timeout 命令:

timeout 30 agent-browser --cdp 9222 eval "window.location.replace('https://www.qidian.com/rank/')" || echo "⏱ CDP 操作超时(30s),请重试或手动打断"

已知限制

即使加了超时包装,以下场景仍可能出现问题:

场景 风险 缓解
页面加载超时 eval 命令等待永不返回 设置 30s 超时,超时后重试
大批量数据抓取 多页翻页时累计等待过长 每页独立超时,失败后从断点继续
Chrome 进程僵死 CDP 连接断开但进程未退出 先核验 debug profile 对应 PID,只结束该 debug 实例后重连;不得连带普通 Chrome
网络波动 请求挂起无超时 超时后自动重试一次

如遇到持续卡死的操作,在 opencode 中按 ESC 手动打断。


常见问题

问题 解决方案
NEEDS_CONSENT + 退出码 3 用 AskUserQuestion 询问用户是否允许杀掉 Chrome,同意后加 --yes 重跑
CDP 端口未监听 --detect-only 再确认;端口被占用则换端口
页面跳转到登录页 snapshot -i 找登录按钮并操作
eval 返回 null 检查 localStorage key 名;含引号的 JS 用 eval -b 或 --stdin
登录态过期 setup-cdp-chrome.js 9222 --reset --yes 重新拷贝
有多个 Chrome profile --profile "Profile 1" 指定
Chrome 不会启动(30s 超时) 试 --reset;检查端口冲突;查看 ~/chrome-debug-profile/ 是否损坏
Files (oh-story-claudecode)
  • scripts
    • setup-cdp-chrome.js 39.9 KB
      #!/usr/bin/env node
      // setup-cdp-chrome.js
      // 准备带有 CDP(Chrome DevTools Protocol)调试功能的 Chrome 环境(跨平台)。
      // 通过此脚本,agent-browser 可以复用用户的 Chrome 登录态。
      //
      // 用法:
      //   node setup-cdp-chrome.js [port] [options]
      //
      // Options:
      //   --detect-only            只探测当前状态(结构化输出),不做任何修改
      //   --yes                    确认杀死现有 Chrome,跳过交互提示
      //   --reset                  清空 ~/chrome-debug-profile 后重新复制
      //   --profile <name>         使用指定 Chrome profile(默认: Default)
      //   --dry-run                打印将执行的操作,不实际执行
      //
      // 说明:CDP 端口已在监听时默认直接复用现有 Chrome 并退出 0;但传了 --reset 或显式
      //       --profile 时不复用——这两个参数就是要重建 debug profile(登录态过期即走这条路),
      //       会先关闭现有 Chrome(非 TTY 下需 --yes,否则 exit 3 报 NEEDS_CONSENT)。
      //       重建路径上有两道硬闸门:关完进程后端口必须真的不再应答(否则在动 profile 之前就
      //       exit 1 中止,绝不删一个还在运行的 Chrome 的 profile);启动后必须证明「端口上应答的
      //       就是本次启动的实例」——身份取得到且与重建前不同、spawn 出的进程还活着、端口的 LISTEN
      //       持有者全在这棵进程树里、且树里确有一个持有者带着本次的 --remote-debugging-port。
      //       任何一条证不出来(含查不到)都拒绝报成功,避免把别人的会话当新浏览器交出去。
      //
      // 退出码:
      //   0  成功 / detect-only 完成
      //   1  通用错误(环境缺失、超时等)
      //   2  用户拒绝(TTY 模式下回答 N)
      //   3  需要同意但当前为非 TTY 且未传 --yes
      //
      // detect-only 结构化输出(stdout,每行 KEY=value):
      //   CDP_STATUS=ready|needs-setup
      //   CDP_URL=...                    (仅当 ready)
      //   BROWSER=...                    (仅当 ready)
      //   CHROME_RUNNING=yes|no
      //   CHROME_PID_COUNT=N             (仅当 CHROME_RUNNING=yes)
      
      "use strict";
      
      const { execSync, spawn } = require("child_process");
      const fs = require("fs");
      const http = require("http");
      const net = require("net");
      const os = require("os");
      const path = require("path");
      const readline = require("readline");
      
      // ---------------------------------------------------------------------------
      // 参数解析
      // ---------------------------------------------------------------------------
      
      function parseArgs(argv) {
        const flags = { dryRun: false, yes: false, detectOnly: false, reset: false };
        let profile = "Default";
        // 是否显式传了 --profile:默认值 "Default" 无法区分「没传」和「传了 Default」,
        // 而这两种情况在"CDP 已就绪"分支上的语义不同(复用 vs 按指定 profile 重建)
        let profileExplicit = false;
        let port = null;
      
        for (let i = 0; i < argv.length; i++) {
          const a = argv[i];
          switch (a) {
            case "--dry-run": flags.dryRun = true; break;
            case "--yes": case "-y": flags.yes = true; break;
            case "--detect-only": flags.detectOnly = true; break;
            case "--reset": flags.reset = true; break;
            case "--profile":
              profile = argv[++i];
              if (!profile) {
                console.error("❌ --profile 需要一个参数(例如: --profile \"Profile 1\")");
                process.exit(1);
              }
              profileExplicit = true;
              break;
            default:
              if (/^\d+$/.test(a)) {
                port = parseInt(a, 10);
              } else if (a.startsWith("--")) {
                console.error(`⚠️  未知参数: ${a}`);
              } else {
                console.error(`⚠️  忽略参数: ${a}`);
              }
          }
        }
      
        if (port === null) port = 9222;
        if (!Number.isInteger(port) || port < 1 || port > 65535) {
          console.error(`❌ 端口非法: ${port}。必须是 1-65535 的整数。`);
          process.exit(1);
        }
      
        return { flags, profile, profileExplicit, port };
      }
      
      const ARGS = parseArgs(process.argv.slice(2));
      const CDP_PORT = ARGS.port;
      const PLATFORM = os.platform();
      
      // ---------------------------------------------------------------------------
      // 平台配置映射
      // ---------------------------------------------------------------------------
      
      const PLATFORM_CONFIG = {
        darwin: {
          chromePaths: [
            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
          ],
          profileDir: path.join(
            os.homedir(),
            "Library",
            "Application Support",
            "Google",
            "Chrome"
          ),
          findChrome() {
            for (const p of this.chromePaths) if (fs.existsSync(p)) return p;
            return null;
          },
          listChromePids() {
            try {
              const out = execSync("pgrep -x 'Google Chrome'", { encoding: "utf-8" }).trim();
              return out.split("\n").map(Number).filter((n) => n > 0);
            } catch { return []; }
          },
          killChrome() {
            try { execSync("pkill -9 -x 'Google Chrome'", { stdio: "ignore" }); } catch {}
          },
        },
        win32: {
          chromePaths: [
            path.join(process.env["PROGRAMFILES(X86)"] || "", "Google", "Chrome", "Application", "chrome.exe"),
            path.join(process.env.PROGRAMFILES || "", "Google", "Chrome", "Application", "chrome.exe"),
            path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
          ],
          profileDir: path.join(
            process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"),
            "Google", "Chrome", "User Data"
          ),
          findChrome() {
            for (const p of this.chromePaths) if (p && fs.existsSync(p)) return p;
            return null;
          },
          listChromePids() {
            try {
              const out = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /NH /FO CSV', { encoding: "utf-8" }).trim();
              return out.split("\n").map((line) => {
                const m = line.match(/"chrome.exe","(\d+)"/i);
                return m ? parseInt(m[1], 10) : 0;
              }).filter((n) => n > 0);
            } catch { return []; }
          },
          killChrome() {
            try { execSync("taskkill /F /IM chrome.exe", { stdio: "ignore" }); } catch {}
          },
        },
        linux: {
          chromePaths: [
            "/usr/bin/google-chrome-stable",
            "/usr/bin/google-chrome",
            "/opt/google/chrome/google-chrome",
          ],
          profileDir: path.join(os.homedir(), ".config", "google-chrome"),
          findChrome() {
            for (const p of this.chromePaths) if (fs.existsSync(p)) return p;
            return null;
          },
          listChromePids() {
            // 覆盖常见的 Chrome 进程命名
            const patterns = ["google-chrome-stable", "google-chrome", "chrome"];
            const pids = new Set();
            for (const pat of patterns) {
              try {
                const out = execSync(`pgrep -x ${pat}`, { encoding: "utf-8" }).trim();
                out.split("\n").map(Number).filter((n) => n > 0).forEach((n) => pids.add(n));
              } catch {}
            }
            return [...pids];
          },
          killChrome() {
            for (const pat of ["google-chrome-stable", "google-chrome", "chrome"]) {
              try { execSync(`pkill -9 -x ${pat}`, { stdio: "ignore" }); } catch {}
            }
          },
        },
      };
      
      // ---------------------------------------------------------------------------
      // 工具函数
      // ---------------------------------------------------------------------------
      
      function log(msg) { console.log(msg); }
      function warn(msg) { console.warn("⚠️  " + msg); }
      function ok(msg) { console.log("✅ " + msg); }
      function err(msg) { console.error("❌ " + msg); }
      
      function getConfig() {
        const config = PLATFORM_CONFIG[PLATFORM];
        if (!config) {
          err(`不支持的平台: ${PLATFORM}。支持 darwin/win32/linux。`);
          process.exit(1);
        }
        return config;
      }
      
      /** 同步等待 ms 毫秒(不依赖 setTimeout / 系统 sleep) */
      function sleepSync(ms) {
        Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
      }
      
      /**
       * HTTP GET 检查 CDP 端点。拒绝 4xx/5xx;自动 drain 掉响应体。
       * agent:false 是必须的——Node 19+ 的 http.globalAgent 默认 keepAlive,探测用过的 socket 会留在
       * 连接池里;而本脚本用 sleepSync 死堵事件循环(等进程退出/等启动),期间服务端按 5s 空闲把这条
       * 连接关掉,客户端来不及处理 FIN。下一次探测复用这条死 socket 就是 ECONNRESET,于是"端口还活着"
       * 被误判成"没人应答"。这种假阴性会直接骗过下面的端口闸门,必须一次一条新连接。
       */
      function httpGet(url) {
        return new Promise((resolve, reject) => {
          const req = http.get(url, { timeout: 3000, agent: false }, (res) => {
            let body = "";
            res.on("data", (chunk) => (body += chunk));
            res.on("end", () => {
              if (res.statusCode >= 400) {
                reject(new Error(`HTTP ${res.statusCode}`));
              } else {
                resolve(body);
              }
            });
          });
          req.on("error", reject);
          req.on("timeout", () => {
            req.destroy();
            reject(new Error("timeout"));
          });
        });
      }
      
      async function probeCDP(port) {
        try {
          const version = await httpGet(`http://127.0.0.1:${port}/json/version`);
          return version;
        } catch {
          return null;
        }
      }
      
      /** 原始 TCP 探测:HTTP 500/畸形 JSON 仍表示端口被占用,不能据此解锁 profile 破坏操作。 */
      function probeTcp(port, timeoutMs = 1000) {
        return new Promise((resolve) => {
          const socket = net.createConnection({ host: "127.0.0.1", port });
          let settled = false;
          const done = (listening) => {
            if (settled) return;
            settled = true;
            socket.destroy();
            resolve(listening);
          };
          socket.setTimeout(timeoutMs);
          socket.once("connect", () => done(true));
          socket.once("error", () => done(false));
          socket.once("timeout", () => done(false));
        });
      }
      
      /**
       * 从 /json/version 响应里取一个能区分「实例」的标识。
       * Chrome 每次启动都会换一个新的 browser GUID(webSocketDebuggerUrl 尾段),最适合做这件事。
       * 取不到就返回 null——调用方必须把 null 当作「无法比对」,绝不能当作「相同」或「不同」。
       */
      function cdpIdentity(version) {
        if (!version) return null;
        try {
          const obj = JSON.parse(version);
          if (obj.webSocketDebuggerUrl) return String(obj.webSocketDebuggerUrl);
        } catch {}
        return null;
      }
      
      /**
       * 等 TCP 端口真的不再监听;true = 端口已空出来,false = 超时后仍有人监听。
       * 不能用 probeCDP:HTTP 500/畸形响应只说明“不是健康 CDP”,不说明“端口空闲”。
       */
      async function waitForPortFree(port, maxMs = 8000, stepMs = 500, needQuiet = 2) {
        const start = Date.now();
        let quiet = 0;
        for (;;) {
          if (await probeTcp(port)) {
            quiet = 0;
          } else if (++quiet >= needQuiet) {
            return true;
          }
          if (Date.now() - start >= maxMs) return false;
          sleepSync(stepMs);
        }
      }
      
      /** 尽力查出占用端口的进程,只用于诊断(查不到就返回 null,不影响判定) */
      function describePortHolder(port) {
        const cmd =
          PLATFORM === "win32"
            ? `netstat -ano -p tcp | findstr LISTENING | findstr :${port}`
            : `lsof -nP -iTCP:${port} -sTCP:LISTEN`;
        try {
          const out = execSync(cmd, {
            encoding: "utf-8",
            stdio: ["ignore", "pipe", "ignore"],
          }).trim();
          const line = out
            .split("\n")
            .map((l) => l.trim())
            .filter((l) => l && !/^COMMAND\s/.test(l))[0];
          return line ? line.slice(0, 200) : null;
        } catch {
          return null;
        }
      }
      
      /** 跑一条只读的查询命令,拿 stdout;命令不存在/非零退出/超时一律返回 null */
      function queryStdout(cmd) {
        try {
          const out = execSync(cmd, {
            encoding: "utf-8",
            stdio: ["ignore", "pipe", "ignore"],
            timeout: 5000,
            maxBuffer: 8 * 1024 * 1024,
          });
          return typeof out === "string" ? out : String(out);
        } catch {
          return null;
        }
      }
      
      /**
       * 列出正在 LISTEN 指定端口的进程 pid。
       * 只在「已经探到 CDP 应答」之后调用——那一刻端口必然有人在监听,所以空结果只可能是
       * 工具缺失或看不见,一律返回 null 表示「无从判断」,绝不能被当成「没人占用」而放行。
       */
      function listPortListenerPids(port) {
        const queries =
          PLATFORM === "win32"
            ? [
                {
                  kind: "pid",
                  cmd: `powershell -NoProfile -NonInteractive -Command "Get-NetTCPConnection -State Listen -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess"`,
                },
                {
                  kind: "pid",
                  cmd: `pwsh -NoProfile -NonInteractive -Command "Get-NetTCPConnection -State Listen -LocalPort ${port} -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess"`,
                },
                { kind: "netstat", cmd: "netstat -ano -p tcp" },
              ]
            : [
                { kind: "pid", cmd: `lsof -nP -iTCP:${port} -sTCP:LISTEN -t` },
                // Linux 上 lsof 经常不预装,用 ss / fuser 兜底
                { kind: "ss", cmd: `ss -H -ltnp "sport = :${port}"` },
                { kind: "pid", cmd: `fuser -n tcp ${port}` },
              ];
        for (const { kind, cmd } of queries) {
          const out = queryStdout(cmd);
          if (out === null) continue;
          const pids = new Set();
          if (kind === "netstat") {
            // 不读取本地化的状态文字。监听行的稳定形状是 TCP + 本地目标端口 +
            // foreign port 0 + 最后一列 Owning PID;已建立连接的 foreign port 非 0。
            for (const line of out.split("\n")) {
              const fields = line.trim().split(/\s+/);
              if (fields.length < 5 || fields[0].toUpperCase() !== "TCP") continue;
              const localPort = Number((fields[1].match(/:(\d+)$/) || [])[1]);
              const foreignPort = Number((fields[2].match(/:(\d+)$/) || [])[1]);
              const pid = Number(fields[fields.length - 1]);
              if (localPort === port && foreignPort === 0 && Number.isInteger(pid) && pid > 0) {
                pids.add(pid);
              }
            }
          } else if (kind === "ss") {
            for (const m of out.matchAll(/pid=(\d+)/g)) pids.add(Number(m[1]));
          } else {
            // PowerShell OwningProcess / lsof -t / fuser:一堆纯数字 pid
            for (const tok of out.split(/\s+/)) {
              const n = Number(tok);
              if (Number.isInteger(n) && n > 0) pids.add(n);
            }
          }
          const list = [...pids].filter((n) => n > 0);
          if (list.length > 0) return list;
        }
        return null;
      }
      
      /** 全机 pid -> ppid 表;查不到返回 null(无从判断,不是「没有父进程」) */
      function listProcessParents() {
        const cmds =
          PLATFORM === "win32"
            ? [
                // wmic 在新版 Windows 上已被移除,退回 PowerShell CIM(5.1 / 7 都试)
                "wmic process get ProcessId,ParentProcessId /format:csv",
                'powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId | ConvertTo-Csv -NoTypeInformation"',
                'pwsh -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId | ConvertTo-Csv -NoTypeInformation"',
              ]
            : ["ps -A -o pid=,ppid="]; // macOS(BSD) 与 Linux(procps) 都认这一条
        for (const cmd of cmds) {
          const out = queryStdout(cmd);
          if (out === null) continue;
          const map = new Map();
          if (PLATFORM === "win32") {
            // 两个来源的列序不一样(wmic 按字母序,PowerShell 按 Select 顺序),按表头定位
            const lines = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
            const head = lines.findIndex(
              (l) => /processid/i.test(l) && /parentprocessid/i.test(l)
            );
            if (head < 0) continue;
            const cols = lines[head]
              .split(",")
              .map((c) => c.replace(/"/g, "").trim().toLowerCase());
            const pidCol = cols.indexOf("processid");
            const ppidCol = cols.indexOf("parentprocessid");
            if (pidCol < 0 || ppidCol < 0) continue;
            for (const line of lines.slice(head + 1)) {
              const cells = line.split(",").map((c) => c.replace(/"/g, "").trim());
              const pid = Number(cells[pidCol]);
              const ppid = Number(cells[ppidCol]);
              if (pid > 0 && Number.isInteger(ppid)) map.set(pid, ppid);
            }
          } else {
            for (const line of out.split("\n")) {
              const m = line.trim().match(/^(\d+)\s+(\d+)$/);
              if (m) map.set(Number(m[1]), Number(m[2]));
            }
          }
          if (map.size > 0) return map;
        }
        return null;
      }
      
      /** 取某个 pid 的完整命令行;取不到返回 null */
      function processCommandLine(pid) {
        const cmds =
          PLATFORM === "win32"
            ? [
                `wmic process where "ProcessId=${pid}" get CommandLine /value`,
                `powershell -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine"`,
                `pwsh -NoProfile -NonInteractive -Command "(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine"`,
              ]
            : [`ps -ww -o command= -p ${pid}`]; // -ww:不许按终端宽度截断,Chrome 的命令行很长
        for (const cmd of cmds) {
          const out = queryStdout(cmd);
          if (out === null) continue;
          const text =
            PLATFORM === "win32" ? out.replace(/^\s*CommandLine=/im, "") : out;
          const trimmed = text.trim();
          if (trimmed) return trimmed;
        }
        return null;
      }
      
      /** pid 是否在 rootPid 的进程树里(含 rootPid 本身);沿 ppid 往上走 */
      function isInProcessTree(pid, rootPid, parents) {
        let cur = pid;
        for (let hops = 0; hops < 64; hops++) {
          if (cur === rootPid) return true;
          if (!Number.isInteger(cur) || cur <= 1) return false;
          const next = parents.get(cur);
          if (next === undefined || next === cur) return false;
          cur = next;
        }
        return false;
      }
      
      function commandLineHasArgument(commandLine, argument) {
        const escaped = argument.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
        return new RegExp(`(?:^|[\\s"'])${escaped}(?=$|[\\s"'])`).test(commandLine);
      }
      
      /**
       * 证明端口上应答的那个端点确实归本次 spawn 出来的进程所有。两件事都要成立:
       *   ① 端口上所有 LISTEN 持有者都在 rootPid 这棵进程树里——Chrome 会另起 browser 进程,
       *      macOS 上启动的二进制还可能 re-exec,所以比的是整棵树而不是直系 pid;反过来,
       *      子进程继承了监听 fd 也会被 lsof 列出来,所以要求「全都在树里」而不是「有一个在」。
       *   ② 树里确实有一个持有者带着本次启动的 --remote-debugging-port=<port>——证明应答的是
       *      我们配出来的那个实例,而不是树里某个别的进程顺手占了这个端口。
       * 任何一步查不出来都返回 unverifiable:宁可硬失败,也不能把「证明不了」当成「证明了」。
       */
      function verifyPortOwnedByLaunch(port, rootPid) {
        const fail = (code, lines) => ({ ok: false, code, lines: [`${code}: ${lines[0]}`, ...lines.slice(1)] });
        const unverifiable = (why) =>
          fail("CDP_OWNER_UNVERIFIABLE", [
            `无法确认端口 ${port} 的 LISTEN 持有者归属(${why})。`,
            "拒绝报成功:证明不了这个端点属于本次启动,就不能把它交给后续采集。",
            PLATFORM === "win32"
              ? "本机需要 netstat 加 wmic 或 PowerShell 才能查进程归属。"
              : "本机需要 lsof(或 ss / fuser)加 ps 才能查进程归属。",
            `处理办法:装上上述工具后重跑,或手动确认 ${port} 上跑的确实是刚启动的 Chrome。`,
          ]);
      
        if (!rootPid) return unverifiable("spawn 没拿到 pid");
        const listeners = listPortListenerPids(port);
        if (!listeners) return unverifiable("查不到监听该端口的进程");
      
        // 持有者就是 spawn 出来的那个 pid 时不必读进程表——最常见的形态(Chrome 的 browser
        // 进程就是我们启动的那个)因此不依赖 wmic/ps 之外的任何东西
        let outside = listeners.filter((pid) => pid !== rootPid);
        if (outside.length > 0) {
          const parents = listProcessParents();
          if (!parents) return unverifiable("读不到进程表(pid/ppid)");
          outside = outside.filter((pid) => !isInProcessTree(pid, rootPid, parents));
        }
        if (outside.length > 0) {
          const holder = describePortHolder(port);
          return fail("CDP_PORT_NOT_OURS", [
            `端口 ${port} 的 LISTEN 持有者(pid ${outside.join(", ")})不在本次启动的进程树里(根 pid ${rootPid})。`,
            "拒绝报成功:端口被别的进程握着,再往下用,每一次采集读到的都是别人的会话。",
            ...(holder ? [`占用者:${holder}`] : []),
            `处理办法:结束占用 ${port} 的进程后重跑,或换一个端口。`,
          ]);
        }
      
        const marker = `--remote-debugging-port=${port}`;
        let sawCommandLine = false;
        for (const pid of listeners) {
          const cmdline = processCommandLine(pid);
          if (cmdline === null) continue;
          sawCommandLine = true;
          if (commandLineHasArgument(cmdline, marker)) return { ok: true, pids: listeners, pid };
        }
        if (!sawCommandLine) return unverifiable("读不到持有者的命令行");
        return fail("CDP_OWNER_NOT_LAUNCHED_INSTANCE", [
          `端口 ${port} 的 LISTEN 持有者(pid ${listeners.join(", ")})在本次启动的进程树里,但没有一个带着 ${marker}。`,
          "拒绝报成功:应答的不是本次启动的那个 Chrome,只是同一棵树里另一个占了这个端口的进程。",
          `处理办法:确认 ${port} 没被别的进程占用,或换一个端口重跑。`,
        ]);
      }
      
      /** spawn 出来的 Chrome 是否还活着(exitCode/signalCode 权威,兜底 kill(pid,0)) */
      function isChildAlive(child) {
        if (!child || !child.pid) return false;
        if (child.exitCode !== null || child.signalCode !== null) return false;
        try {
          process.kill(child.pid, 0);
          return true;
        } catch {
          return false;
        }
      }
      
      function isPidAlive(pid) {
        if (!Number.isInteger(pid) || pid <= 0) return false;
        try {
          process.kill(pid, 0);
          return true;
        } catch {
          return false;
        }
      }
      
      /** 只清理由本次 spawn 拉起的进程树;绝不调用全局 killChrome 连坐用户的其他窗口。 */
      function terminateLaunchTree(rootPid) {
        if (!Number.isInteger(rootPid) || rootPid <= 0) return;
        if (PLATFORM === "win32") {
          try {
            execSync(`taskkill /F /T /PID ${rootPid}`, { stdio: "ignore" });
          } catch {}
          return;
        }
      
        const parents = listProcessParents();
        const tree = [];
        if (parents) {
          for (const pid of parents.keys()) {
            if (pid !== process.pid && isInProcessTree(pid, rootPid, parents)) tree.push(pid);
          }
        }
        if (!tree.includes(rootPid)) tree.push(rootPid);
      
        // 子孙先停、launcher 最后停,避免 detached listener 在父进程先死后被 reparent 而丢失归属。
        const depth = (pid) => {
          let current = pid;
          for (let hops = 0; hops < 64; hops++) {
            if (current === rootPid) return hops;
            const next = parents?.get(current);
            if (!next || next === current) return -1;
            current = next;
          }
          return -1;
        };
        tree.sort((left, right) => depth(right) - depth(left));
        for (const pid of tree) {
          try { process.kill(pid, "SIGTERM"); } catch {}
        }
        sleepSync(200);
        for (const pid of tree) {
          if (!isPidAlive(pid)) continue;
          try { process.kill(pid, "SIGKILL"); } catch {}
        }
      }
      
      /** 复制文件(吞掉 ENOENT;其他错误打印一次警告供用户排查) */
      function copyFileSafe(src, dest) {
        try {
          fs.copyFileSync(src, dest);
          return true;
        } catch (e) {
          if (e.code !== "ENOENT") {
            warn(`复制失败: ${src} -> ${dest} (${e.code || e.message})`);
          }
          return false;
        }
      }
      
      /** 递归复制目录 */
      function copyDirRecursive(src, dest) {
        fs.cpSync(src, dest, { recursive: true, force: true });
      }
      
      /** 递归删除目录 */
      function rmDirSafe(dir) {
        fs.rmSync(dir, { recursive: true, force: true });
      }
      
      /**
       * 刷新登录态相关文件(在 debugProfile 已存在的"增量"路径上使用)。
       * 同时尝试 Chrome 当前可能存在的 Default/Cookies 与 Default/Network/Cookies,
       * 包含各类 -journal / -wal / -shm 旁路文件,以及 Google 账号登录数据。
       */
      function refreshAuthFiles(srcDefault, destDefault) {
        const targets = [
          "Cookies", "Cookies-journal",
          "Login Data", "Login Data-journal",
          "Login Data For Account", "Login Data For Account-journal",
          "Web Data", "Web Data-journal",
          path.join("Network", "Cookies"),
          path.join("Network", "Cookies-journal"),
        ];
        let copied = 0;
        for (const rel of targets) {
          const src = path.join(srcDefault, rel);
          if (!fs.existsSync(src)) continue;
          const dest = path.join(destDefault, rel);
          fs.mkdirSync(path.dirname(dest), { recursive: true });
          if (copyFileSafe(src, dest)) copied++;
        }
        return copied;
      }
      
      /** 清理 Chrome singleton 锁,避免上次崩溃后下次启动失败 */
      function clearSingletonLocks(profileDir) {
        const names = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
        for (const n of names) {
          try { fs.unlinkSync(path.join(profileDir, n)); } catch {}
        }
      }
      
      /** 等待 Chrome PID 列表为空 */
      function waitForChromeExit(config, maxMs = 8000, stepMs = 500) {
        const start = Date.now();
        while (Date.now() - start < maxMs) {
          if (config.listChromePids().length === 0) return true;
          sleepSync(stepMs);
        }
        return false;
      }
      
      /** TTY 交互式问询 */
      function promptYesNo(question) {
        return new Promise((resolve) => {
          const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
          rl.question(question, (answer) => {
            rl.close();
            resolve(/^y(es)?$/i.test((answer || "").trim()));
          });
        });
      }
      
      // ---------------------------------------------------------------------------
      // detect-only 模式
      // ---------------------------------------------------------------------------
      
      async function runDetectOnly(config) {
        const version = await probeCDP(CDP_PORT);
        if (version) {
          log("CDP_STATUS=ready");
          log(`CDP_URL=http://127.0.0.1:${CDP_PORT}/json/version`);
          // 尝试从 JSON 提取浏览器版本(容错)
          try {
            const obj = JSON.parse(version);
            if (obj.Browser) log(`BROWSER=${obj.Browser}`);
          } catch {}
          process.exit(0);
        }
        log("CDP_STATUS=needs-setup");
        const pids = config.listChromePids();
        if (pids.length > 0) {
          log("CHROME_RUNNING=yes");
          log(`CHROME_PID_COUNT=${pids.length}`);
        } else {
          log("CHROME_RUNNING=no");
        }
        process.exit(0);
      }
      
      // ---------------------------------------------------------------------------
      // 同意流程:返回 true 继续,false 用户拒绝
      // ---------------------------------------------------------------------------
      
      async function ensureConsentToKill(pids) {
        if (pids.length === 0) return true;
        if (ARGS.flags.yes) return true;
      
        // 非 TTY:拒绝静默杀进程,给调用方(Claude / 上层脚本)一个明确信号
        if (!process.stdin.isTTY) {
          err(`NEEDS_CONSENT: ${pids.length} running Chrome process(es) will be killed.`);
          err(`Pass --yes to confirm (after asking the user), or stop Chrome manually first.`);
          process.exit(3);
        }
      
        // TTY:交互问询
        warn(`检测到 ${pids.length} 个正在运行的 Chrome 进程。`);
        warn("继续将杀死它们,你在常规 Chrome 中未保存的工作可能丢失。");
        return promptYesNo("继续?[y/N] ");
      }
      
      // ---------------------------------------------------------------------------
      // 主流程
      // ---------------------------------------------------------------------------
      
      async function main() {
        const config = getConfig();
        const debugProfile = path.join(os.homedir(), "chrome-debug-profile");
      
        // 1) 检测 Chrome 可执行路径(detect-only 也需要 profileDir)
        const chromePath = config.findChrome();
      
        // detect-only:不修改任何状态
        if (ARGS.flags.detectOnly) {
          if (!chromePath) {
            log("CDP_STATUS=needs-setup");
            log("CHROME_INSTALLED=no");
            process.exit(0);
          }
          return runDetectOnly(config);
        }
      
        log("=== CDP Chrome 环境准备 ===");
        log(`平台: ${PLATFORM} | CDP 端口: ${CDP_PORT} | profile: ${ARGS.profile}`);
      
        if (!chromePath) {
          err("未找到 Google Chrome。请确保已安装。");
          err(`搜索路径: ${JSON.stringify(config.chromePaths, null, 2)}`);
          process.exit(1);
        }
        log(`Chrome 路径: ${chromePath}`);
      
        // 2) dry-run:先于任何副作用(包括"复用现有 CDP")打印计划,让用户能看到真要执行时的步骤
        const defaultProfile = path.join(config.profileDir, ARGS.profile);
        const hasProfile = fs.existsSync(defaultProfile);
      
        if (ARGS.flags.dryRun) {
          const cdpAlive = !!(await probeCDP(CDP_PORT));
          const tcpOccupied = await probeTcp(CDP_PORT);
          // --reset / 显式 --profile 会跳过复用(见下方第 3 步),dry-run 必须照实说
          const willReuse = cdpAlive && !ARGS.flags.reset && !ARGS.profileExplicit;
          const cdpNote = !tcpOccupied
            ? "未监听"
            : !cdpAlive
              ? "有 TCP 监听,但不是健康 CDP(实际运行会在动 profile 前硬失败)"
            : willReuse
              ? "已就绪(实际运行时会直接复用)"
              : "已就绪(但传了 --reset/--profile,实际运行会重建,不复用)";
          log(`Chrome profile: ${defaultProfile} (${hasProfile ? "存在" : "不存在"})`);
          log(`CDP 端口 ${CDP_PORT}: ${cdpNote}`);
          const runningPids = config.listChromePids();
          log(`检测到 ${runningPids.length} 个 Chrome 进程`);
          log("\n--- dry-run 模式:只打印操作,不执行 ---");
          if (willReuse) {
            log("0. CDP 已就绪,实际运行会直接复用并退出 0(以下步骤仅供参考)");
          } else if (cdpAlive) {
            log("0. CDP 已就绪,但传了 --reset/--profile:实际运行不复用,按下列步骤重建");
          }
          // 步骤号按真实执行顺序动态编号:先杀进程、再确认端口空了,之后才碰 profile 目录
          let stepNo = 0;
          const step = (msg) => log(`${++stepNo}. ${msg}`);
          if (runningPids.length > 0) {
            step(`${ARGS.flags.yes ? "(已同意)" : "请求同意后 "}杀死 ${runningPids.length} 个 Chrome 进程`);
          } else {
            step("无 Chrome 进程,无需杀死");
          }
          step(`用 TCP 确认端口 ${CDP_PORT} 已释放(任何监听仍在都中止:不删 profile、不启动)`);
          if (ARGS.flags.reset) step(`删除 ${debugProfile}`);
          if (hasProfile) {
            step(`复制 profile: ${defaultProfile} -> ${debugProfile}/Default`);
          } else {
            step("⚠️ 无用户 profile,将以空 profile 启动");
          }
          step("清理 SingletonLock / SingletonCookie / SingletonSocket");
          step("启动 Chrome(含 --remote-allow-origins=*, --no-first-run 等)");
          step(
            `验证 http://127.0.0.1:${CDP_PORT}/json/version 来自本次启动的实例` +
              "(身份取得到且已变 + 进程存活 + 端口的 LISTEN 持有者就在这棵进程树里)"
          );
          ok("dry-run 完成。");
          process.exit(0);
        }
      
        // 3) 若 CDP 已就绪 → 复用,直接退出。
        //    但 --reset / 显式 --profile 的语义就是"重建 debug profile":登录态过期时文档正是
        //    让用户跑 --reset,而那时 CDP 恰恰是活着的(过期是从这个会话里发现的)。若照旧复用,
        //    这两个参数会被静默丢掉,还以 exit 0 报"成功"。因此这两种情况不复用,继续往下重建。
        const existing = await probeCDP(CDP_PORT);
        const portWasListening = await probeTcp(CDP_PORT);
        if (existing) {
          if (!ARGS.flags.reset && !ARGS.profileExplicit) {
            ok("CDP 已就绪,复用现有 Chrome。");
            log(existing.split("\n").slice(0, 5).join("\n"));
            process.exit(0);
          }
          const requested = ARGS.flags.reset ? "--reset" : `--profile ${ARGS.profile}`;
          warn(`CDP 端口 ${CDP_PORT} 已在监听,但传了 ${requested}:不复用,将关闭现有 Chrome 后重建 debug profile。`);
        }
        // 重建前那个实例的身份:第 10 步要靠它证明「应答的是新起的实例」,而不只是「有人应答」
        const staleIdentity = cdpIdentity(existing);
      
        if (!hasProfile) {
          err(`未找到 Chrome profile: ${defaultProfile}`);
          err("请确保已安装 Google Chrome 并至少使用过一次,或用 --profile <name> 指定其他 profile。");
          process.exit(1);
        }
      
        // 4) 同意流程:如有 Chrome 进程要杀,先征得同意
        const runningPids = config.listChromePids();
        const consented = await ensureConsentToKill(runningPids);
        if (!consented) {
          err("用户拒绝,已中止。");
          process.exit(2);
        }
      
        // 5) 杀死现有 Chrome 进程,等待退出
        if (runningPids.length > 0) {
          log(`正在停止 ${runningPids.length} 个 Chrome 进程...`);
          config.killChrome();
          if (!waitForChromeExit(config, 6000)) {
            warn("首轮 kill 后仍有 Chrome 进程,再试一次...");
            config.killChrome();
            waitForChromeExit(config, 4000);
          }
          const remain = config.listChromePids();
          if (remain.length > 0) {
            err(`仍有 ${remain.length} 个 Chrome 进程未退出,已中止。`);
            err("未删除、未改动 debug profile,也未启动新 Chrome——状态保持原样。");
            process.exit(1);
          } else {
            ok("Chrome 已退出。");
          }
        }
      
        // 5.5) 硬闸门:端口必须真的空出来,才允许动 profile 目录、才允许启动新实例。
        //      顺序是刻意的——闸门在删 profile 之前。旧实例还活着就往下走会撞上最坏的一种结果:
        //      先删掉一个正在运行的 Chrome 的 profile(本身就是破坏性的),新进程又因端口被占起不来,
        //      而第 10 步的 probeCDP 恰好被旧端点答上,于是 exit 0 报「重建成功」——调用方以为拿到了
        //      新浏览器,之后每一次采集读的都是旧会话/别人的会话。这里只能硬失败。
        //      无论 /json/version 是否健康都执行:HTTP 500 也可能正占着端口。
        // 杀过进程才值得给宽限期;没有已识别 Chrome 时,占用者不会自己退出,快速确认后失败。
        const graceMs = runningPids.length > 0 ? 8000 : 1000;
        if (!(await waitForPortFree(CDP_PORT, graceMs))) {
          const remain = config.listChromePids();
          err(
            existing
              ? `CDP 端口 ${CDP_PORT} 上的旧实例仍在应答,已中止。`
              : `CDP 端口 ${CDP_PORT} 仍被占用、未释放,已中止。`
          );
          if (remain.length > 0) {
            err(`原因:${remain.length} 个 Chrome 进程没能退出(kill 无效,可能权限不足或进程卡死)。`);
          } else if (runningPids.length === 0) {
            err("原因:端口被无法识别的进程占用——没找到任何 Chrome 进程,脚本无从关闭它。");
          } else {
            err("原因:Chrome 进程已退出,但另有进程仍守着这个端口。");
          }
          const holder = describePortHolder(CDP_PORT);
          if (holder) err(`占用者:${holder}`);
          err("未删除、未改动 debug profile,也未启动新 Chrome——状态保持原样。");
          err(`处理办法:手动结束占用 ${CDP_PORT} 的进程后重跑,或换一个端口(node setup-cdp-chrome.js <其他端口> ...)。`);
          process.exit(1);
        }
        if (portWasListening) {
          ok(`CDP 端口 ${CDP_PORT} 已释放。`);
        }
      
        // 6) --reset:清空 debug profile
        if (ARGS.flags.reset) {
          log(`正在删除 debug profile: ${debugProfile}`);
          rmDirSafe(debugProfile);
        }
      
        // 7) 复制 / 刷新 profile(此时 Chrome 已关闭,SQLite 一致)
        const debugDefault = path.join(debugProfile, "Default");
        if (!fs.existsSync(debugDefault)) {
          log("正在复制 Chrome profile 到 debug 目录...");
          fs.mkdirSync(debugProfile, { recursive: true });
          try { fs.chmodSync(debugProfile, 0o700); } catch {}
          copyDirRecursive(defaultProfile, debugDefault);
          ok(`Profile 已复制到: ${debugProfile}`);
        } else {
          log("debug profile 已存在,刷新登录态相关文件...");
          try { fs.chmodSync(debugProfile, 0o700); } catch {}
          const n = refreshAuthFiles(defaultProfile, debugDefault);
          ok(`已刷新 ${n} 个登录态文件`);
        }
      
        // 8) 清理 singleton 锁
        clearSingletonLocks(debugProfile);
      
        // 9) 以 CDP 模式启动 Chrome
        log(`正在以 CDP 模式启动 Chrome(端口 ${CDP_PORT})...`);
        const chromeArgs = [
          `--remote-debugging-port=${CDP_PORT}`,
          `--user-data-dir=${debugProfile}`,
          "--remote-allow-origins=*",
          "--no-first-run",
          "--no-default-browser-check",
          "--disable-features=ChromeWhatsNewUI",
        ];
        const child = spawn(chromePath, chromeArgs, { detached: true, stdio: "ignore" });
        const childPid = child.pid;
        let spawnError = null;
        child.on("error", (e) => { spawnError = e; });
        child.unref();
      
        /** 启动后验证没过:只清掉自己刚起的进程(端口上那个不是我们的,不该连坐杀别人的 Chrome) */
        function abortAfterLaunch(reasons) {
          for (const line of reasons) err(line);
          err("正在清理刚启动的 Chrome 进程...");
          terminateLaunchTree(childPid);
          process.exit(1);
        }
      
        // 10) 等待启动并验证。光有人应答不算成功——那可能是没被关掉的旧实例,也可能是别的进程
        //     顺手占了这个端口。四条全过才算:
        //     ① 新端点的 browser GUID 取得到(取不到=无法比对,按合约不能当作相同或不同);
        //     ② 这个 GUID 与重建前不同(配合第 5.5 步已确认旧端点消失过);
        //     ③ 刚 spawn 的进程还活着(它死了,端口上应答的就一定不是本次启动的实例);
        //     ④ 端口的 LISTEN 持有者确实在这棵 spawn 出来的进程树里,且带着本次的
        //        --remote-debugging-port。前三条都是间接证据——「旧端点消失过 + 身份变了 +
        //        launcher 还活着」推不出「端口归它」,只有第 ④ 条才真的把端口和进程绑上。
        log("等待 Chrome 启动...");
        let identityMisses = 0;
        for (let i = 1; i <= 15; i++) {
          sleepSync(2000);
          if (spawnError) {
            abortAfterLaunch([`启动 Chrome 失败: ${spawnError.message}`]);
          }
          const version = await probeCDP(CDP_PORT);
          if (version) {
            const identity = cdpIdentity(version);
            if (identity === null) {
              // 端点刚起来时理论上可能先答上 HTTP,给两轮宽限;之后仍取不到就硬失败。
              if (++identityMisses < 3) {
                log(`   端口有应答但取不到实例身份,重试 ${identityMisses}/3...`);
                continue;
              }
              abortAfterLaunch([
                `CDP_IDENTITY_UNVERIFIABLE: 端口 ${CDP_PORT} 有 HTTP 应答,但 /json/version 里取不到实例身份(webSocketDebuggerUrl)。`,
                "拒绝报成功:身份取不到就无法证明这是新起的实例——按合约它既不算相同也不算不同,只能当作没证出来。",
                `处理办法:确认 ${CDP_PORT} 上跑的是 Chrome 的 CDP 端点(而不是别的 HTTP 服务),或换一个端口重跑。`,
              ]);
            }
            if (staleIdentity && identity === staleIdentity) {
              abortAfterLaunch([
                `端口 ${CDP_PORT} 应答的仍是重建前那个实例(${identity}),不是新启动的 Chrome。`,
                "拒绝报成功:再往下用,每一次采集读到的都会是旧会话。",
              ]);
            }
            if (!isChildAlive(child)) {
              const holder = describePortHolder(CDP_PORT);
              abortAfterLaunch([
                `端口 ${CDP_PORT} 上有 CDP 应答,但刚启动的 Chrome(pid ${childPid})已经退出。`,
                "拒绝报成功:这个端点不属于本次启动的实例。",
                ...(holder ? [`占用者:${holder}`] : []),
                `处理办法:确认 ${CDP_PORT} 没被别的进程占用,或换一个端口重跑。`,
              ]);
            }
            const owner = verifyPortOwnedByLaunch(CDP_PORT, childPid);
            if (!owner.ok) abortAfterLaunch(owner.lines);
            ok(`Chrome 已成功以 CDP 模式启动(端口 ${CDP_PORT})`);
            log(version.split("\n").slice(0, 5).join("\n"));
            process.exit(0);
          }
          log(`   尝试 ${i}/15...`);
        }
      
        // 11) 失败清理:杀死刚才启动的孤儿 Chrome
        err("30 秒内未能启动 Chrome CDP 环境。");
        err("正在清理刚启动的 Chrome 进程...");
        terminateLaunchTree(childPid);
        err("可能原因:");
        err("  - Chrome 不支持 --remote-debugging-port");
        err(`  - 端口 ${CDP_PORT} 已被其他进程占用`);
        err("  - debug profile 目录已损坏(试试 --reset)");
        process.exit(1);
      }
      
      main().catch((e) => {
        err(`启动失败: ${e.message}`);
        process.exit(1);
      });
      
  • SKILL.md 6.8 KB
    ---
    name: browser-cdp
    description: "Use this skill when you need to control a Chrome browser via CDP (Chrome DevTools Protocol) to reuse existing login sessions. Covers: launching Chrome in debug mode, opening URLs, waiting for page load, evaluating JavaScript, taking snapshots, and extracting auth tokens. Trigger phrases: browser automation, CDP, agent-browser, 浏览器操作, 操作浏览器, Chrome CDP, 复用登录态, extract token from browser."
    metadata: {"openclaw":{"requires":{"bins":["agent-browser"]},"source":"https://github.com/zenstory-ai/oh-story-claudecode"}}
    ---
    # Browser CDP 操作工具
    
    通过 CDP 协议控制 Chrome,复用已有登录态,执行浏览器自动化操作。
    
    ## 前置条件
    
    - macOS / Linux / Windows(实验性),已安装 Google Chrome
    - Node.js 20+
    - `agent-browser` 已安装:`npm install -g agent-browser`
    
    > ⚠️ **首次启动会 kill 用户的常规 Chrome。** 在启动前必须征求用户同意(见下方"启动流程"),否则用户可能丢失未保存的标签页/草稿。
    
    ---
    
    ## 启动流程(skill-mode 强制步骤)
    
    **第一步:探测当前状态(无副作用)**
    
    ```bash
    node {SKILL_DIR}/scripts/setup-cdp-chrome.js 9222 --detect-only
    ```
    
    输出形如:
    
    ```
    CDP_STATUS=ready                        # 已就绪,可直接复用
    CDP_URL=http://127.0.0.1:9222/json/version
    BROWSER=Chrome/148.0.7778.168
    ```
    
    或:
    
    ```
    CDP_STATUS=needs-setup
    CHROME_RUNNING=yes                      # 用户有 Chrome 在跑,启动会杀掉
    CHROME_PID_COUNT=3
    ```
    
    **第二步:根据探测结果分支**
    
    - `CDP_STATUS=ready` → 直接使用 `agent-browser --cdp 9222 ...`,**不要运行 setup**。
    - `CDP_STATUS=needs-setup` 且 `CHROME_RUNNING=no` → 安全启动:
      ```bash
      node {SKILL_DIR}/scripts/setup-cdp-chrome.js 9222 --yes
      ```
    - `CDP_STATUS=needs-setup` 且 `CHROME_RUNNING=yes` → **先用 AskUserQuestion 工具向用户确认**:告知会杀掉 N 个 Chrome 进程、可能丢失未保存工作;用户同意后再带 `--yes` 启动;用户拒绝则放弃这次自动化。
    
    **为什么不能直接 `--yes`:** 脚本在非 TTY(即 skill 模式 / Bash 工具)下,如果检测到 Chrome 在跑而没有 `--yes`,会以退出码 3 报 `NEEDS_CONSENT: ...` 并中止,**不会**静默杀进程。这是有意的兜底——但 skill 流程仍应先问用户,而不是看到 3 就盲传 `--yes`。
    
    ---
    
    ## 启动脚本选项
    
    | 选项 | 说明 |
    |------|------|
    | `--detect-only` | 只探测,不修改任何状态(skill 用) |
    | `--yes` | 已征得同意,跳过交互提示 |
    | `--reset` | 启动前清空 `~/chrome-debug-profile`(登录失效时用) |
    | `--profile <name>` | 使用非 Default 的 Chrome profile(如 `"Profile 1"`) |
    | `--dry-run` | 打印将执行的步骤,不执行 |
    
    退出码:`0` 成功 / `1` 通用错误 / `2` 用户拒绝(TTY)/ `3` 需同意但缺 `--yes`。
    
    ---
    
    ## 常用操作
    
    ### 打开页面并等待加载
    
    ```bash
    agent-browser --cdp 9222 open "<URL>"
    agent-browser --cdp 9222 wait 3000
    ```
    
    ### 提取页面文本
    
    ```bash
    agent-browser --cdp 9222 eval 'document.body.innerText.substring(0, 8000)'
    ```
    
    ### 提取 Auth Token
    
    ```bash
    agent-browser --cdp 9222 eval 'localStorage.getItem("token") || document.cookie'
    ```
    
    ### 复杂 JS(含引号 / `$` / 反引号)
    
    shell 转义容易出错,用以下两种方式之一:
    
    ```bash
    # 1) base64 包裹
    agent-browser --cdp 9222 eval -b "$(echo -n "document.querySelectorAll('a').length" | base64)"
    
    # 2) heredoc + --stdin
    cat <<'EOF' | agent-browser --cdp 9222 eval --stdin
    const links = document.querySelectorAll('a');
    links.length;
    EOF
    ```
    
    ### 页面交互(snapshot 拿元素引用)
    
    ```bash
    agent-browser --cdp 9222 snapshot -i        # 仅交互元素
    agent-browser --cdp 9222 click "<CSS or @e1>"
    agent-browser --cdp 9222 type "<sel>" "<text>"
    ```
    
    ---
    
    ## 停止 / 清理
    
    - 关掉 debug Chrome 窗口即可。若窗口无响应,先按 `--user-data-dir` 核验出 debug 实例的 PID 再只结束它:
      - macOS / Linux:`pgrep -af chrome-debug-profile`
      - Windows:`wmic process where "name='chrome.exe'" get ProcessId,CommandLine | findstr chrome-debug-profile`
      拿到 PID 后 `kill -9 {PID}` / `taskkill /F /PID {PID}`。核验不出归属时停止,**手工清理不得按 Chrome 可执行文件名批量结束进程**——那会连带杀掉用户的日常 Chrome。
      例外:`setup-cdp-chrome.js --reset` 内部确实会做一次按可执行名的清理,它属于本 skill 自带的、需 `--yes` 显式同意的启动流程;手工排障不要复制该做法。
    - 登录态失效:`node {SKILL_DIR}/scripts/setup-cdp-chrome.js 9222 --reset --yes`(注意 `--yes` 同样需要先问用户)。
    
    ---
    
    ## OpenCode 环境注意事项
    
    opencode 没有后台执行命令行的工具,长时间的 CDP 操作(如等待页面加载、大批量数据抓取)会阻塞整个会话,导致 CLI 无响应。
    
    ### 超时包装
    
    Windows 上对 CDP 命令使用 PowerShell Job 包装超时:
    
    ```powershell
    $job = Start-Job { agent-browser --cdp 9222 eval "window.location.replace('https://www.qidian.com/rank/')" }
    Wait-Job $job -Timeout 30 | Out-Null
    if ($job.State -eq 'Running') { Stop-Job $job; Write-Output "⏱ CDP 操作超时(30s),请重试或手动打断" }
    else { Receive-Job $job }
    Remove-Job $job -Force
    ```
    
    macOS / Linux 上使用 `timeout` 命令:
    
    ```bash
    timeout 30 agent-browser --cdp 9222 eval "window.location.replace('https://www.qidian.com/rank/')" || echo "⏱ CDP 操作超时(30s),请重试或手动打断"
    ```
    
    ### 已知限制
    
    即使加了超时包装,以下场景仍可能出现问题:
    
    | 场景 | 风险 | 缓解 |
    |------|------|------|
    | 页面加载超时 | eval 命令等待永不返回 | 设置 30s 超时,超时后重试 |
    | 大批量数据抓取 | 多页翻页时累计等待过长 | 每页独立超时,失败后从断点继续 |
    | Chrome 进程僵死 | CDP 连接断开但进程未退出 | 先核验 debug profile 对应 PID,只结束该 debug 实例后重连;不得连带普通 Chrome |
    | 网络波动 | 请求挂起无超时 | 超时后自动重试一次 |
    
    如遇到持续卡死的操作,在 opencode 中按 `ESC` 手动打断。
    
    ---
    
    ## 常见问题
    
    | 问题 | 解决方案 |
    |------|----------|
    | `NEEDS_CONSENT` + 退出码 3 | 用 AskUserQuestion 询问用户是否允许杀掉 Chrome,同意后加 `--yes` 重跑 |
    | CDP 端口未监听 | `--detect-only` 再确认;端口被占用则换端口 |
    | 页面跳转到登录页 | `snapshot -i` 找登录按钮并操作 |
    | `eval` 返回 `null` | 检查 localStorage key 名;含引号的 JS 用 `eval -b` 或 `--stdin` |
    | 登录态过期 | `setup-cdp-chrome.js 9222 --reset --yes` 重新拷贝 |
    | 有多个 Chrome profile | `--profile "Profile 1"` 指定 |
    | Chrome 不会启动(30s 超时) | 试 `--reset`;检查端口冲突;查看 `~/chrome-debug-profile/` 是否损坏 |
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related