
Web Access
- 14.9k installs
- 8.5k repo stars
- Updated May 16, 2026
- eze-is/web-access
web-access is an agent skill that 所有联网操作必须通过此 skill 处理,包括:搜索、网页抓取、登录后操作、网络交互等。.
About
所有联网操作必须通过此 skill 处理 包括 搜索 网页抓取 登录后操作 网络交互等 name web-access license MIT github https github com eze-is web-access description 所有联网操作必须通过此 skill 处理 包括 搜索 网页抓取 登录后操作 网络交互等 触发场景 用户要求搜索信息 查看网页内容 访问需要登录的网站 操作网页界面 抓取社交媒体内容 小红书 微博 推特等 读取动态渲染页面 以及任何需要真实浏览器环境的网络任务 metadata author 一泽Eze version 2 5 3 web-access Skill 前置检查 在开始联网操作前 先检查 CDP 模式可用性 bash node CLAUDE_SKILL_DIR scripts check-deps mjs Node js 22 必需 使用原生 WebSocket 按脚本输出处理 exit 0 继续 exit 2 需询问用户偏好 写入 CLAUDE_SKILL_DIR config env 的 WEB_ACCESS_BROWSER exit 1 按 stdout 错误信息处理 若提示包含 Agent 处理顺序 按其步骤执行 如先用系统命令打开浏览器后重跑 自动可解则不打扰用户 仍失败再向用户求助 支持参数 browser chrome edge 表达本次临时覆盖 不写 config env 切换浏览器时 proxy 是长驻进程 需先 pkill f cdp-proxy mjs 再重跑 check-deps 检查通过后并必须在回复中向用户直接展示以下须知 再启动 CDP Proxy 执行操作 温馨提示 部分站点对浏览器自动化操作检测严格 存在账号封禁风险 已内置防护措施但无法完全避免 Agent 继续操作即视为接受 浏览哲学 像人一样思考 兼顾高效与适应性的完成任务 执行任务时不会过度依赖固有印象所规划的步骤 而是带着目标进入 边看边判断 遇到阻碍就解决 发现内容不够就深入 全程围绕 我要达成什么 做决策 这个 skill 的所有行为都应遵循这个逻辑 ① 拿到请求 先明确用户要做什么 定义成功标准 什么算完成了 需要获取什么信息 执行什么操作 达到什么结果 这是后续所有判断的锚点 ② 选择起点 根据任务性质 平台特征 达成条件 选一个最可能直达的方式作为第一步去验证 一次成功当然最好 不成功则在③中调整 比如 需要操作页面 需要登录态 已知静态方式不可达的平台 小红书 微信公众号等 直接 CDP ③ 过程校验 每一步的结果都是证据 不只是成功或失败的二元信号 用结果对照①的成功标准 更新你对目标的判断 路径在推进吗 结果的整体面貌 质量 相关度 量级 是否指向目标可达 发现方向错了立即调整
- `exit 2` → 需询问用户偏好,写入 `${CLAUDE_SKILL_DIR}/config.env` 的 `WEB_ACCESS_BROWSER`
- `exit 1` → 按 stdout 错误信息处理。若提示包含「Agent 处理顺序」,按其步骤执行(如先用系统命令打开浏览器后重跑),自动可解则不打扰用户;仍失败再向用户求助
- **确保信息的真实性,一手信息优于二手信息**:搜索引擎和聚合平台是信息发现入口。当多次搜索尝试后没有质的改进时,升级到更根本的获取方式:定位一手来源(官网、官方平台、原始页面)。
- **看**:用 `/eval` 查询 DOM,发现页面上的链接、按钮、表单、文本内容 - - 相当于「看看这个页面有什么」
- **做**:用 `/click` 点击元素、`/scroll` 滚动加载、`/eval` 填表提交 - - 像人一样在页面内自然导航
Web Access by the numbers
- 14,920 all-time installs (skills.sh)
- +412 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #32 of 2,209 Security skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
web-access capabilities & compatibility
- Capabilities
- `exit 2` → 需询问用户偏好,写入 `${claude_skill_dir}/confi · `exit 1` → 按 stdout 错误信息处理。若提示包含「agent 处理顺序」,按其步 · **确保信息的真实性,一手信息优于二手信息**:搜索引擎和聚合平台是信息发现入口。当多次搜索尝试 · **看**:用 `/eval` 查询 dom,发现页面上的链接、按钮、表单、文本内容——相当于「 · **做**:用 `/click` 点击元素、`/scroll` 滚动加载、`/eval` 填表提
- Use cases
- documentation
What web-access says it does
--- name: web-access license: MIT github: https://github.com/eze-is/web-access description: 所有联网操作必须通过此 skill 处理,包括:搜索、网页抓取、登录后操作、网络交互等。 触发场景:用户要求搜索信息、查看网页内
npx skills add https://github.com/eze-is/web-access --skill web-accessAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 14.9k |
|---|---|
| repo stars | ★ 8.5k |
| Security audit | 1 / 3 scanners passed |
| Last updated | May 16, 2026 |
| Repository | eze-is/web-access ↗ |
What problem does web-access solve for developers using this skill?
所有联网操作必须通过此 skill 处理,包括:搜索、网页抓取、登录后操作、网络交互等。
Who is it for?
Developers who need web-access patterns described in the cached skill documentation.
Skip if: Skip when docs are empty or the task is outside the skill's documented scope.
When should I use this skill?
所有联网操作必须通过此 skill 处理,包括:搜索、网页抓取、登录后操作、网络交互等。
What you get
Actionable workflows and conventions from SKILL.md for web-access.
- config.env browser preference
- completed browser automation session
By the numbers
- Skill version 2.5.3 in skill metadata
- Requires Node.js 22+ for native WebSocket CDP connectivity
- Preflight script uses exit codes 0, 1, and 2 for proceed, error, and browser-preference flows
Files
web-access Skill
前置检查
在开始联网操作前,先检查 CDP 模式可用性:
node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs"Node.js 22+ 必需(使用原生 WebSocket)。
按脚本输出处理:
exit 0→ 继续exit 2→ 需询问用户偏好,写入${CLAUDE_SKILL_DIR}/config.env的WEB_ACCESS_BROWSERexit 1→ 按 stdout 错误信息处理。若提示包含「Agent 处理顺序」,按其步骤执行(如先用系统命令打开浏览器后重跑),自动可解则不打扰用户;仍失败再向用户求助
支持参数 --browser <chrome|edge> 表达本次临时覆盖(不写 config.env)。
切换浏览器时,proxy 是长驻进程,需先 pkill -f cdp-proxy.mjs 再重跑 check-deps。
检查通过后并必须在回复中向用户直接展示以下须知,再启动 CDP Proxy 执行操作:
温馨提示:部分站点对浏览器自动化操作检测严格,存在账号封禁风险。已内置防护措施但无法完全避免,Agent 继续操作即视为接受。浏览哲学
像人一样思考,兼顾高效与适应性的完成任务。
执行任务时不会过度依赖固有印象所规划的步骤,而是带着目标进入,边看边判断,遇到阻碍就解决,发现内容不够就深入——全程围绕「我要达成什么」做决策。这个 skill 的所有行为都应遵循这个逻辑。
① 拿到请求 — 先明确用户要做什么,定义成功标准:什么算完成了?需要获取什么信息、执行什么操作、达到什么结果?这是后续所有判断的锚点。
② 选择起点 — 根据任务性质、平台特征、达成条件,选一个最可能直达的方式作为第一步去验证。一次成功当然最好;不成功则在③中调整。比如,需要操作页面、需要登录态、已知静态方式不可达的平台(小红书、微信公众号等)→ 直接 CDP
③ 过程校验 — 每一步的结果都是证据,不只是成功或失败的二元信号。用结果对照①的成功标准,更新你对目标的判断:路径在推进吗?结果的整体面貌(质量、相关度、量级)是否指向目标可达?发现方向错了立即调整,不在同一个方式上反复重试——搜索没命中不等于"还没找对方法",也可能是"目标不存在";API 报错、页面缺少预期元素、重试无改善,都是在告诉你该重新评估方向。遇到弹窗、登录墙等障碍,判断它是否真的挡住了目标:挡住了就处理,没挡住就绕过——内容可能已在页面 DOM 中,交互只是展示手段。
④ 完成判断 — 对照定义的任务成功标准,确认任务完成后才停止,但也不要过度操作,不为了"完整"而浪费代价。
联网工具选择
- 确保信息的真实性,一手信息优于二手信息:搜索引擎和聚合平台是信息发现入口。当多次搜索尝试后没有质的改进时,升级到更根本的获取方式:定位一手来源(官网、官方平台、原始页面)。
| 场景 | 工具 |
|---|---|
| 搜索摘要或关键词结果,发现信息来源 | WebSearch |
| URL 已知,需要从页面定向提取特定信息 | WebFetch(拉取网页内容,由小模型根据 prompt 提取,返回处理后结果) |
| URL 已知,需要原始 HTML 源码(meta、JSON-LD 等结构化字段) | curl |
| 非公开内容,或已知静态层无效的平台(小红书、微信公众号等公开内容也被反爬限制) | 浏览器 CDP(直接,跳过静态层) |
| 需要登录态、交互操作,或需要像人一样在浏览器内自由导航探索 | 浏览器 CDP |
浏览器 CDP 不要求 URL 已知——可从任意入口出发,通过页面内搜索、点击、跳转等方式找到目标内容。WebSearch、WebFetch、curl 均不处理登录态。
Jina(可选预处理层,可与 WebFetch/curl 组合使用,由于其特性可节省 tokens 消耗,请积极在任务合适时组合使用):第三方网络服务,可将网页转为 Markdown,大幅节省 token 但可能有信息损耗。调用方式为 r.jina.ai/example.com(URL 前加前缀,不保留原网址 http 前缀),限 20 RPM。适合文章、博客、文档、PDF 等以正文为核心的页面;对数据面板、商品页等非文章结构页面可能提取到错误区块。
进入浏览器层后,/eval 就是你的眼睛和手:
- 看:用
/eval查询 DOM,发现页面上的链接、按钮、表单、文本内容——相当于「看看这个页面有什么」 - 做:用
/click点击元素、/scroll滚动加载、/eval填表提交——像人一样在页面内自然导航 - 读:用
/eval提取文字内容,判断图片/视频是否承载核心信息——是则提取媒体 URL 定向读取或/screenshot视觉识别
浏览网页时,先了解页面结构,再决定下一步动作。不需要提前规划所有步骤。
补充:本地浏览器资源
用户指向本人访问过的页面("我之前看的那个讲 X 的文章"、"上次打开过的 XX 面板")或组织内部系统("我们的 XX 平台"、"公司那个 YY 系统"等公网搜不到的目标)时,检索本地浏览器(Chrome / Edge)书签/历史:
node "${CLAUDE_SKILL_DIR}/scripts/find-url.mjs" [关键词...] [--only bookmarks|history] [--browser chrome|edge] [--limit N] [--since 1d|7h|YYYY-MM-DD] [--sort recent|visits]关键词空格分词、多词 AND,匹配 title + url(可省略);默认遍历所有已安装的 Chromium 系浏览器(Chrome、Edge),--browser 限定单一来源;--since / --sort 仅作用于历史;默认按最近访问倒序,--sort visits 按访问次数排序(适合"高频访问的网站"这类场景)。
程序化操作与 GUI 交互
浏览器内操作页面有两种方式:
- 程序化方式(构造 URL 直接导航、eval 操作 DOM):成功时速度快、精确,但对网站来说不是正常用户行为,可能触发反爬机制。
- GUI 交互(点击按钮、填写输入框、滚动浏览):GUI 是为人设计的,网站不会限制正常的 UI 操作,确定性最高,但步骤多、速度慢。
根据对目标平台的了解来灵活选择方式。GUI 交互也是程序化方式的有效探测——通过一次真实交互观察站点的实际行为(URL 模式、必需参数、页面跳转逻辑),为后续程序化操作提供依据;同时当程序化方式受阻时,GUI 交互是可靠的兜底。
站点内交互产生的链接是可靠的:通过用户视角中的可交互单元(卡片、条目、按钮)进行的站点内交互,自然到达的 URL 天然携带平台所需的完整上下文。而手动构造的 URL 可能缺失隐式必要参数,导致被拦截、返回错误页面、甚至触发反爬。
浏览器 CDP 模式
通过 CDP Proxy 直连用户日常浏览器(Chrome / Edge / Chromium 等 Chromium 系),天然携带登录态,无需启动独立浏览器。 若无用户明确要求,不主动操作用户已有 tab,所有操作都在自己创建的后台 tab 中进行,保持对用户环境的最小侵入。不关闭用户 tab 的前提下,完成任务后关闭自己创建的 tab,保持环境整洁。
启动
node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs"脚本会依次检查 Node.js、浏览器调试端口,并确保 Proxy 已连接(未运行则自动启动并等待)。Proxy 启动后持续运行。
Proxy API
所有操作通过 curl 调用 HTTP API:
# 列出用户已打开的 tab
curl -s http://localhost:3456/targets
# 创建新后台 tab(自动等待加载)— URL 走 POST body,避免目标 URL 含 query 时被切分
curl -s -X POST --data-raw 'https://example.com' http://localhost:3456/new
# 页面信息
curl -s "http://localhost:3456/info?target=ID"
# 执行任意 JS:可读写 DOM、提取数据、操控元素、触发状态变更、提交表单、调用内部方法
curl -s -X POST "http://localhost:3456/eval?target=ID" -d 'document.title'
# 捕获页面渲染状态(含视频当前帧)
curl -s "http://localhost:3456/screenshot?target=ID&file=/tmp/shot.png"
# 导航(URL 走 POST body,target 走 query)、后退
curl -s -X POST --data-raw 'https://example.com' "http://localhost:3456/navigate?target=ID"
curl -s "http://localhost:3456/back?target=ID"
# 点击(POST body 为 CSS 选择器)— JS el.click(),简单快速,覆盖大多数场景
curl -s -X POST "http://localhost:3456/click?target=ID" -d 'button.submit'
# 真实鼠标点击 — CDP Input.dispatchMouseEvent,算用户手势,能触发文件对话框
curl -s -X POST "http://localhost:3456/clickAt?target=ID" -d 'button.upload'
# 文件上传 — 直接设置 file input 的本地文件路径,绕过文件对话框
curl -s -X POST "http://localhost:3456/setFiles?target=ID" -d '{"selector":"input[type=file]","files":["/path/to/file.png"]}'
# 滚动(触发懒加载)
curl -s "http://localhost:3456/scroll?target=ID&y=3000"
curl -s "http://localhost:3456/scroll?target=ID&direction=bottom"
# 关闭 tab
curl -s "http://localhost:3456/close?target=ID"页面内导航
两种方式打开页面内的链接:
- `/click`:在当前 tab 内直接点击用户视角中的可交互单元,简单直接,串行处理。适合需要在同一页面内连续操作的场景,如点击展开、翻页、进入详情等。
- `/new` + 完整 URL:使用目标链接的完整地址(包含所有URL参数),在新 tab 中打开。适合需要同时访问多个页面的场景。
很多网站的链接包含会话相关的参数(如 token),这些参数是正常访问所必需的。提取 URL 时应保留完整地址,不要裁剪或省略参数。URL 通过 POST body 原样传入 /new 或 /navigate。
v2.5.3 迁移提示:若引用的站点经验文件(references/site-patterns/*.md)或其它脚本中仍含GET /new?url=...或/navigate?target=...&url=...的旧写法,调用会收到迁移指引。按 `references/migration-2.5.3.md` 就地改写为 POST body 后再使用,并顺手把该站点经验文件更新掉。
媒体资源提取
判断内容在图片里时,用 /eval 从 DOM 直接拿图片 URL,再定向读取——比全页截图精准得多。
技术事实
- 页面中存在大量已加载但未展示的内容——轮播中非当前帧的图片、折叠区块的文字、懒加载占位元素等,它们存在于 DOM 中但对用户不可见。以数据结构(容器、属性、节点关系)为单位思考,可以直接触达这些内容。
- DOM 中存在选择器不可跨越的边界(Shadow DOM 的
shadowRoot、iframe 的contentDocument等)。eval 递归遍历可一次穿透所有层级,返回带标签的结构化内容,适合快速了解未知页面的完整结构。 /scroll到底部会触发懒加载,使未进入视口的图片完成加载。提取图片 URL 前若未滚动,部分图片可能尚未加载。- 拿到媒体资源 URL 后,公开资源可直接下载到本地后用读取;需要登录态才可获取的资源才需要在浏览器内 navigate + screenshot。
- 短时间内密集打开大量页面(如批量
/new)可能触发网站的反爬风控。 - 平台返回的"内容不存在""页面不见了"等提示不一定反映真实状态,也可能是访问方式的问题(如 URL 缺失必要参数、触发反爬)而非内容本身的问题。
视频内容获取
用户浏览器真实渲染,截图可捕获当前视频帧。核心能力:通过 /eval 操控 <video> 元素(获取时长、seek 到任意时间点、播放/暂停/全屏),配合 /screenshot 采帧,可对视频内容进行离散采样分析。
登录判断
用户日常浏览器天然携带登录态,大多数常用网站已登录。
登录判断的核心问题只有一个:目标内容拿到了吗?
打开页面后先尝试获取目标内容。只有当确认目标内容无法获取且判断登录能解决时,才告知用户:
"当前页面在未登录状态下无法获取[具体内容],请在你的浏览器中登录 [网站名],完成后告诉我继续。"
登录完成后无需重启任何东西,直接刷新页面继续。
任务结束
用 /close 关闭自己创建的 tab,必须保留用户原有的 tab 不受影响。
Proxy 持续运行,不建议主动停止——重启后需要在浏览器中重新授权 CDP 连接。
并行调研:子 Agent 分治策略
任务包含多个独立调研目标时(如同时调研 N 个项目、N 个来源),鼓励合理分治给子 Agent 并行执行,而非主 Agent 串行处理。
好处:
- 速度:多子 Agent 并行,总耗时约等于单个子任务时长
- 上下文保护:抓取内容不进入主 Agent 上下文,主 Agent 只接收摘要,节省 token
并行 CDP 操作:每个子 Agent 在当前用户浏览器实例中,自行创建所需的后台 tab(/new),自行操作,任务结束自行关闭(/close)。所有子 Agent 共享一个浏览器、一个 Proxy,通过不同 targetId 操作不同 tab,无竞态风险。
子 Agent Prompt 写法:目标导向,而非步骤指令
- 必须在子 Agent prompt 中写
必须加载 web-access skill 并遵循指引,子 Agent 会自动加载 skill,无需在 prompt 中复制 skill 内容或指定路径。 - 子 Agent 有自主判断能力。主 Agent 的职责是说清楚要什么,仅在必要与确信时限定怎么做。过度指定步骤会剥夺子 Agent 的判断空间,反而引入主 Agent 的假设错误。避免 prompt 用词对子 Agent 行为的暗示:「搜索xx」会把子 Agent 锚定到 WebSearch,而实际上有些反爬站点需要 CDP 直接访问主站才能有效获取内容。主 Agent 写 prompt 时应描述目标(「获取」「调研」「了解」),避免用暗示具体手段的动词(「搜索」「抓取」「爬取」)。
分治判断标准:
| 适合分治 | 不适合分治 |
|---|---|
| 目标相互独立,结果互不依赖 | 目标有依赖关系,下一个需要上一个的结果 |
| 每个子任务量足够大(多页抓取、多轮搜索) | 简单单页查询,分治开销大于收益 |
| 需要 CDP 浏览器或长时间运行的任务 | 几次 WebSearch / Jina 就能完成的轻量查询 |
信息核实类任务
核实的目标是一手来源,而非更多的二手报道。多个媒体引用同一个错误会造成循环印证假象。
搜索引擎和聚合平台是信息发现入口,是定位信息的工具,不可用于直接证明真伪。找到来源后,直接访问读取原文。同一原则适用于工具能力/用法的调研——官方文档是一手来源,不确定时先查文档或源码,不猜测。
| 信息类型 | 一手来源 |
|---|---|
| 政策/法规 | 发布机构官网 |
| 企业公告 | 公司官方新闻页 |
| 学术声明 | 原始论文/机构官网 |
| 工具能力/用法 | 官方文档、源码 |
找不到官网时:权威媒体的原创报道(非转载)可作为次级依据,但需向用户说明:"未找到官方原文,以下核实来自[媒体名]报道,存在转述误差可能。"单一来源时同样向用户声明。
站点经验
操作中积累的特定网站经验,按域名存储在 references/site-patterns/ 下。
确定目标网站后,如果前置检查输出的 site-patterns 列表中有匹配的站点,必须读取对应文件获取先验知识(平台特征、有效模式、已知陷阱)。经验内容标注了发现日期,当作可能有效的提示而非保证——如果按经验操作失败,回退通用模式并更新经验文件。
CDP 操作成功完成后,如果发现了有必要记录经验的新站点或新模式(URL 结构、平台特征、操作策略),主动写入对应的站点经验文件。只写经过验证的事实,不写未确认的猜测。
文件格式:
---
domain: example.com
aliases: [示例, Example]
updated: 2026-03-19
---
## 平台特征
架构、反爬行为、登录需求、内容加载方式等事实
## 有效模式
已验证的 URL 模式、操作策略、选择器
## 已知陷阱
什么会失败以及为什么经验/陷阱内容标注发现日期,当作"可能有效的提示"而非"保证正确的事实"。
References 索引
| 文件 | 何时加载 |
|---|---|
references/cdp-api.md | 需要 CDP API 详细参考、JS 提取模式、错误处理时 |
references/site-patterns/{domain}.md | 确定目标网站后,读取对应站点经验 |
{
"name": "web-access",
"owner": {
"name": "一泽 Eze"
},
"metadata": {
"description": "Complete web browsing and automation skill for Claude Code"
},
"plugins": [
{
"name": "web-access",
"description": "All web operations: search, fetch, CDP browser automation, site experience accumulation",
"source": "./",
"category": "productivity",
"tags": ["web", "browser", "cdp", "automation", "search"]
}
]
}
{
"name": "web-access",
"description": "Complete web browsing and automation skill for Claude Code - intelligent tool selection, CDP browser automation, and site experience accumulation",
"version": "2.5.2",
"author": {
"name": "一泽 Eze"
},
"homepage": "https://github.com/eze-is/web-access",
"repository": "https://github.com/eze-is/web-access",
"license": "MIT",
"keywords": ["web", "browser", "cdp", "automation", "search"],
"skills": ["./"]
}
.DS_Store
*.log
references/site-patterns/*.md
config.env
.claude/
<div align="right"> <details> <summary>🌐 Language</summary> <div> <div align="center"> <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=en">English</a> | <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=zh-CN">简体中文</a> | <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=zh-TW">繁體中文</a> | <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=ja">日本語</a> | <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=ko">한국어</a> | <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=fr">Français</a> | <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=de">Deutsch</a> | <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=es">Español</a> | <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=pt">Português</a> | <a href="https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=ru">Русский</a> </div> </div> </details> </div>
<img width="879" height="376" alt="image" src="https://github.com/user-attachments/assets/a87fd816-a0b5-4264-b01c-9466eae90723" />
<p align="center"> <b>给 AI Agent 装上完整联网能力的 Skill。</b><br/> <a href="https://web-access.eze.is">🌐 官网</a> · <a href="https://mp.weixin.qq.com/s/rps5YVB6TchT9npAaIWKCw">📖 设计详解</a> · <a href="#安装">⚡ 快速安装</a> </p>
AI Agent 原本的联网能力(WebSearch、WebFetch)缺少调度策略和浏览器自动化能力。这个 Agent Skill 补上的是:联网策略 + CDP 浏览器操作 + 站点经验积累。兼容所有支持 SKILL.md 的 Agent(Claude Code、Cursor、Gemini CLI、Codex CLI 等)。
推荐必读:Web Access:一个 Skill,拉满 Agent 联网和浏览器能力 ,完整介绍了 Web-Access Skill 的开发细节与 Agent Skill 设计哲学,帮助你也能写出类似通用、高上限的 Skill
---
v2.5.2 能力
| 能力 | 说明 |
|---|---|
| 联网工具自动选择 | WebSearch / WebFetch / curl / Jina / CDP,按场景自主判断,可任意组合 |
| CDP Proxy 浏览器操作 | 直连用户日常浏览器(Chrome / Edge / Chromium 系),天然携带登录态,支持动态页面、交互操作、视频截帧 |
| 三种点击方式 | /click(JS click)、/clickAt(CDP 真实鼠标事件)、/setFiles(文件上传) |
| 本地浏览器书签/历史检索 | find-url.mjs 跨 Chrome / Edge 查询公网搜不到的目标(内部系统)或用户访问过的页面,支持关键词/时间窗/访问频度排序 |
| 并行分治 | 多目标时分发子 Agent 并行执行,共享一个 Proxy,tab 级隔离 |
| 站点经验积累 | 按域名存储操作经验(URL 模式、平台特征、已知陷阱),跨 session 复用 |
| 媒体提取 | 从 DOM 直取图片/视频 URL,或对视频任意时间点截帧分析 |
v2.5.2 更新:
- Microsoft Edge 支持 — CDP Proxy 不再绑定 Chrome,新增 Edge 适配(及 Chromium、Chrome Canary 等 Chromium 系,通过同一套自动发现机制接入)。在
edge://inspect/#remote-debugging勾选 "Allow remote debugging for this browser instance" 即可 - 浏览器偏好持久化 — 新增
config.env(gitignored,首次运行从模板创建),通过WEB_ACCESS_BROWSER固定默认浏览器;多浏览器同时开启 toggle 时 Agent 会询问偏好。也支持单次覆盖--browser <chrome|edge> - 不擅自降级 — 偏好/指定的浏览器没启动或没开 toggle 时硬错并给出明确处理步骤,不会悄悄连到别的浏览器;proxy 首次成功连接后 pin 住浏览器 id,避免运行中漂移
- find-url 也支持 Edge — 本地书签/历史检索默认遍历 Chrome 与 Edge,可用
--browser <chrome|edge>限定单一浏览器
<details><summary>v2.5.0 更新</summary>
- 本地 Chrome 资源检索 — 新增
scripts/find-url.mjs,从本地 Chrome 书签/历史按关键词/时间窗/访问频度定位 URL。典型场景:用户提到组织内部系统("我们的 XX 平台"等公网搜不到的目标)、回查之前访问过但不记得地址的页面、查看最近高频访问网站等(场景感谢 @MVPGFC 在 #60 提出)
</details>
<details><summary>v2.4.3 更新</summary>
- 修复 CLAUDE_SKILL_DIR 路径问题 — bash 代码块改用
${CLAUDE_SKILL_DIR}字符串替换语法,修复 Windows Git Bash 路径转换错误和变量未设置问题(#47 #46) - 站点经验列表合并到前置检查 — 启动检查通过后自动输出已有站点经验列表,移除不可靠的
!内联注入
</details>
<details><summary>v2.4.1 更新</summary>
- 跨平台支持 — 脚本从 bash 迁移到 Node.js,Windows / Linux / macOS 均可使用
- DOM 边界穿透 — 新增技术事实:eval 递归遍历可穿透 Shadow DOM、iframe 等选择器不可跨越的边界
</details>
<details><summary>v2.4 更新</summary>
- 站点内 URL 可靠性 — 新增事实说明:站点生成的链接自带完整上下文,手动构造的 URL 可能缺失隐式必要参数
- 平台错误提示不可信 — 新增技术事实:平台返回的"内容不存在"等提示可能是访问方式问题而非内容本身问题
- 小红书站点经验增强 — xsec_token 机制、创作者平台状态校验、暂存草稿流程
</details>
<details><summary>v2.3 更新</summary>
- 浏览哲学重构 — 更清晰的「像人一样思考」框架,强调目标驱动而非步骤驱动
- Jina 积极推荐 — 明确鼓励在合适场景主动使用 Jina 节省 token
- 子 Agent prompt 指引优化 — 明确加载写法,增加避免动词暗示执行方式的说明
</details>
安装
方式一:npx skills 一键安装(推荐)
npx skills add eze-is/web-accessskills CLI 是开源的 Agent Skill 包管理器,自动检测你的 Agent 环境并安装到正确位置。
方式二:让 Agent 自动安装
帮我安装这个 skill:https://github.com/eze-is/web-access方式三:Plugin 安装(Claude Code)
claude plugin marketplace add https://github.com/eze-is/web-access
claude plugin install web-access@web-access --scope user方式四:手动
git clone https://github.com/eze-is/web-access ~/.claude/skills/web-access前置配置(CDP 模式)
CDP 模式需要 Node.js 22+ 和浏览器(Chrome / Edge)开启远程调试:
1. 在你想用的浏览器地址栏打开对应 inspect 页面:
- Chrome:
chrome://inspect/#remote-debugging - Edge:
edge://inspect/#remote-debugging
2. 勾选 Allow remote debugging for this browser instance(可能需要重启浏览器)
浏览器偏好(config.env)
skill 长期偏好保存在 ${CLAUDE_SKILL_DIR}/config.env(首次运行自动从 config.env.template 创建,gitignored):
# 留空 = 每次启动都询问偏好;设值 = 固定使用该浏览器
WEB_ACCESS_BROWSER=edge合法值:chrome / edge
临时用别的浏览器(不修改 config.env):
node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs" --browser chrome切换浏览器(proxy 已连接旧的):
pkill -f cdp-proxy.mjs && node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs"环境检查(Agent 运行时会自动完成前置检查,无需手动执行):
node "${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs"
# $CLAUDE_SKILL_DIR 是 skill 加载时自动设置的环境变量
# 手动运行请替换为实际路径,如 ~/.claude/skills/web-accessCDP Proxy API
Proxy 通过 WebSocket 直连浏览器(兼容 chrome://inspect / edge://inspect 方式,无需命令行参数启动),提供 HTTP API:
# 启动(Agent 会自动管理 Proxy 生命周期,无需手动启动)
node "${CLAUDE_SKILL_DIR}/scripts/cdp-proxy.mjs" &
# 页面操作
curl -s -X POST --data-raw 'https://example.com' http://localhost:3456/new # 新建 tab(v2.5.3 起 URL 走 POST body)
curl -s -X POST "http://localhost:3456/eval?target=ID" -d 'document.title' # 执行 JS
curl -s -X POST "http://localhost:3456/click?target=ID" -d 'button.submit' # JS 点击
curl -s -X POST "http://localhost:3456/clickAt?target=ID" -d '.upload-btn' # 真实鼠标点击
curl -s -X POST "http://localhost:3456/setFiles?target=ID" \
-d '{"selector":"input[type=file]","files":["/path/to/file.png"]}' # 文件上传
curl -s "http://localhost:3456/screenshot?target=ID&file=/tmp/shot.png" # 截图
curl -s "http://localhost:3456/scroll?target=ID&direction=bottom" # 滚动
curl -s "http://localhost:3456/close?target=ID" # 关闭 tab
curl -s "http://localhost:3456/health" # 查看状态(含 managedTabs 数量)Proxy 会自动追踪通过 /new 创建的 tab,闲置 15 分钟后自动关闭,防止 Agent 异常退出时留下孤儿 tab。可通过环境变量 CDP_TAB_IDLE_TIMEOUT(单位毫秒)调整超时时间。
⚠️ 使用前提醒
通过浏览器自动化操作社交平台(如小红书)存在账号被平台限流或封禁的风险。强烈建议使用小号进行操作。
使用
安装后直接让 Agent 执行联网任务,skill 自动接管:
- "帮我搜索 xxx 最新进展"
- "读一下这个页面:[URL]"
- "去小红书搜索 xxx 的账号"
- "帮我在创作者平台发一篇图文"
- "同时调研这 5 个产品的官网,给我对比摘要"
设计哲学
Skill = 哲学 + 技术事实,不是操作手册。讲清 tradeoff 让 AI 自己选,不替它推理。
详见 SKILL.md 中的浏览哲学部分。
License
Star History

Clawhub Download History

<img width="1280" height="306" alt="image" src="https://github.com/user-attachments/assets/2afa25c2-3730-413e-b40f-94e52567249d" />
CDP Proxy API 参考
基础信息
- 地址:
http://localhost:3456 - 启动:
node ~/.claude/skills/web-access/scripts/cdp-proxy.mjs & - 启动后持续运行,不建议主动停止(重启需 Chrome 重新授权)
- 强制停止:
pkill -f cdp-proxy.mjs
API 端点
GET /health
健康检查,返回连接状态。
curl -s http://localhost:3456/healthGET /targets
列出所有已打开的页面 tab。返回数组,每项含 targetId、title、url。
curl -s http://localhost:3456/targetsPOST /new
创建新后台 tab,自动等待页面加载完成。URL 通过 POST body 原样传入,无需 URL-encode、不会因 query 中含 & 被切分。返回 { targetId }。
curl -s -X POST --data-raw 'https://example.com' http://localhost:3456/new
# 含 query 的目标 URL(如带 token 的小红书笔记)也直接原样传:
curl -s -X POST --data-raw 'https://www.xiaohongshu.com/explore/xxx?xsec_source=app_share&xsec_token=ABC&type=normal' http://localhost:3456/newv2.5.3 起改为 POST。旧的GET /new?url=...返回 400 + 迁移指引,详见migration-2.5.3.md。
GET /close?target=ID
关闭指定 tab。
curl -s "http://localhost:3456/close?target=TARGET_ID"POST /navigate?target=ID
在已有 tab 中导航到新 URL,自动等待加载。target 走 query(不带特殊字符的不透明 ID),URL 走 POST body。
curl -s -X POST --data-raw 'https://example.com' "http://localhost:3456/navigate?target=ID"v2.5.3 起改为 POST。旧的GET /navigate?target=...&url=...返回 400 + 迁移指引,详见migration-2.5.3.md。
GET /back?target=ID
后退一页。
curl -s "http://localhost:3456/back?target=ID"GET /info?target=ID
获取页面基础信息(title、url、readyState)。
curl -s "http://localhost:3456/info?target=ID"POST /eval?target=ID
执行 JavaScript 表达式,POST body 为 JS 代码。
curl -s -X POST "http://localhost:3456/eval?target=ID" -d 'document.title'POST /click?target=ID
JS 层面点击(el.click()),POST body 为 CSS 选择器。自动 scrollIntoView 后点击。简单快速,覆盖大多数场景。
curl -s -X POST "http://localhost:3456/click?target=ID" -d 'button.submit'POST /clickAt?target=ID
CDP 浏览器级真实鼠标点击(Input.dispatchMouseEvent),POST body 为 CSS 选择器。先获取元素坐标,再模拟鼠标按下/释放。算真实用户手势,能触发文件对话框、绕过部分反自动化检测。
curl -s -X POST "http://localhost:3456/clickAt?target=ID" -d 'button.upload'POST /setFiles?target=ID
给 file input 设置本地文件路径(DOM.setFileInputFiles),完全绕过文件对话框。POST body 为 JSON。
curl -s -X POST "http://localhost:3456/setFiles?target=ID" -d '{"selector":"input[type=file]","files":["/path/to/file1.png","/path/to/file2.png"]}'GET /scroll?target=ID&y=3000&direction=down
滚动页面。direction 可选 down(默认)、up、top、bottom。滚动后自动等待 800ms 供懒加载触发。
curl -s "http://localhost:3456/scroll?target=ID&y=3000"
curl -s "http://localhost:3456/scroll?target=ID&direction=bottom"GET /screenshot?target=ID&file=/tmp/shot.png
截图。指定 file 参数保存到本地文件;不指定则返回图片二进制。可选 format=jpeg。
curl -s "http://localhost:3456/screenshot?target=ID&file=/tmp/shot.png"/eval 使用提示
- POST body 为任意 JS 表达式,返回
{ value }或{ error } - 支持
awaitPromise:可以写 async 表达式 - 返回值必须是可序列化的(字符串、数字、对象),DOM 节点不能直接返回,需要提取属性
- 提取大量数据时用
JSON.stringify()包裹,确保返回字符串 - 根据页面实际 DOM 结构编写选择器,不要套用固定模板
错误处理
| 错误 | 原因 | 解决 |
|---|---|---|
Chrome 未开启远程调试端口 | Chrome 未开启远程调试 | 提示用户打开 chrome://inspect/#remote-debugging 并勾选 Allow |
attach 失败 | targetId 无效或 tab 已关闭 | 用 /targets 获取最新列表 |
CDP 命令超时 | 页面长时间未响应 | 重试或检查 tab 状态 |
端口已被占用 | 另一个 proxy 已在运行 | 已有实例可直接复用 |
v2.5.3 迁移指南:/new 和 /navigate 改 POST body
TL;DR
- curl -s "http://localhost:3456/new?url=https://example.com"
+ curl -s -X POST --data-raw 'https://example.com' http://localhost:3456/new
- curl -s "http://localhost:3456/navigate?target=ID&url=https://example.com"
+ curl -s -X POST --data-raw 'https://example.com' "http://localhost:3456/navigate?target=ID"旧写法收到 400 + 包含本文档路径的错误信息后,按下方对照表改写调用。同时把发现的旧写法在源站点经验文件中就地修正。
为什么改
旧版 /new?url=... 把目标 URL 塞进 query 参数。当目标 URL 自带 query(如小红书笔记必须携带 xsec_token、B 站、微博的分享链接),proxy 用 URL.searchParams 解析时,目标 URL 里未编码的 & 会被当成 proxy 自身的 query 分隔符,导致目标 URL 被错误切分、token 等关键参数丢失,页面返回"内容不存在"或被反爬拦截。
例:
GET /new?url=https://xhs.com/explore/x?a=1&b=2proxy 解析结果:
q.url = "https://xhs.com/explore/x?a=1"← 被截断q.b = "2"← 被当成 proxy 自己的参数吃掉
根因是用一种"带语法的格式(query string)"承载另一种"也带相同语法的数据(URL)",存在结构性歧义。靠调用方守纪律做 URL-encode 治标不治本:Agent 偶尔忘记就翻车,且增加每次调用的 token 成本。
v2.5.3 把 URL 改为通过 POST body 传入。HTTP body 是不透明字节流(边界由 Content-Length header 显式声明,与数据本身解耦),不存在任何分隔符歧义,URL 原样传输,零编码负担。
转换对照表
/new
| 场景 | 旧(v2.5.2) | 新(v2.5.3) |
|---|---|---|
| 简单 URL | curl ".../new?url=https://example.com" | curl -X POST --data-raw 'https://example.com' .../new |
| URL 含 query | curl ".../new?url=https://xhs.com/explore/x?xsec_token=ABC" ⚠️ token 丢失 | curl -X POST --data-raw 'https://xhs.com/explore/x?xsec_token=ABC' .../new ✓ |
URL 含 # fragment | curl ".../new?url=https://app/page#sec" ⚠️ fragment 丢失 | curl -X POST --data-raw 'https://app/page#sec' .../new ✓ |
/navigate
| 旧 | 新 |
|---|---|
curl ".../navigate?target=ID&url=URL" | curl -X POST --data-raw 'URL' ".../navigate?target=ID" |
target 仍走 query(它是无特殊字符的不透明 ID,没有歧义问题)。
Agent 自检 checklist
在迁移后的环境中工作时,遇到任何来源的 web-access 调用,按以下顺序处理:
1. 判断调用是否过时:搜索字符串 localhost:3456/new?url= 或 localhost:3456/navigate? 后跟 &url=。这两类模式属于 v2.5.2 旧写法。 2. 就地改写:按上表对照修改成 POST 形式。 3. 更新源文件:如果旧写法来自 references/site-patterns/*.md 中某个站点经验文件,把源文件也一并修正掉(不要只在当前调用上改)—— 这样下次复用同一站点经验时不会再踩坑。 4. 保留 URL 原样:POST body 是不透明字节流,目标 URL 中的 ?、&、=、#、% 等字符无需任何转义,从 DOM/分享链接抓到什么样就传什么样。
FAQ
Q: 我用 GET `?url=` 调用会发生什么? A: 收到 HTTP 400 响应,body 中包含 error、migration 文档路径、example 示例。按提示改 POST 调用即可。
Q: 为什么不在 proxy 里做兼容(同时支持 GET 和 POST)? A: 兼容路径会留下永久的"启发式截取 query"代码 + SKILL.md 里"也支持旧写法"的脚注。两条路径长期共存 → Agent 学得不彻底、维护者两套都要测、读代码的人要分辨主路径 vs 兼容路径。把架构债转成了认知债。一次性 breaking change 配合迁移指南,更彻底也更便宜。
Q: 我自己的脚本/别名/笔记里有大量旧写法,有迁移脚本吗? A: 没有也不打算提供。站点经验和脚本是人写的文档/代码,掺着说明、注释、上下文判断,正则替换容易误伤。本指南的 Agent 自检 checklist 就是给"Agent 看着内容自己判断怎么改"的,比脚本可靠。
Q: 还有哪些 endpoint 用 POST body? A: 一直都有:/eval、/click、/clickAt、/setFiles 全是 POST + body。这次 /new /navigate 加入后,所有传输"任意字符串载荷"的写操作都统一走 POST body —— 内部一致性提升。
// 浏览器 CDP 端口发现 + 选择 - 单一职责模块
// 被 check-deps.mjs 和 cdp-proxy.mjs 共享。
//
// 选择规则(resolution):
// 1. 调用方传入 override 参数(来自命令行 --browser) → 严格模式,找不到则硬错
// 2. config.env 里 WEB_ACCESS_BROWSER 设了 → 严格模式,找不到则硬错
// 3. 都没设 → "ask" 模式,提示调用方询问用户
//
// 不擅自降级:偏好不可用一律硬错,让用户介入。
// 持久态只有 config.env 一处;override 是单次 spawn 通过命令行参数表达,不读 process.env。
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SKILL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const CONFIG_PATH = path.join(SKILL_ROOT, 'config.env');
// 已知支持 chrome://inspect#remote-debugging toggle 的浏览器
// 加新浏览器:只改这里
export function knownBrowsers() {
const home = os.homedir();
const localAppData = process.env.LOCALAPPDATA || '';
switch (os.platform()) {
case 'darwin':
return [
{ id: 'chrome', label: 'Chrome', devToolsPath: path.join(home, 'Library/Application Support/Google/Chrome/DevToolsActivePort') },
{ id: 'chrome-canary', label: 'Chrome Canary', devToolsPath: path.join(home, 'Library/Application Support/Google/Chrome Canary/DevToolsActivePort') },
{ id: 'chromium', label: 'Chromium', devToolsPath: path.join(home, 'Library/Application Support/Chromium/DevToolsActivePort') },
{ id: 'edge', label: 'Microsoft Edge', devToolsPath: path.join(home, 'Library/Application Support/Microsoft Edge/DevToolsActivePort') },
];
case 'linux':
return [
{ id: 'chrome', label: 'Chrome', devToolsPath: path.join(home, '.config/google-chrome/DevToolsActivePort') },
{ id: 'chromium', label: 'Chromium', devToolsPath: path.join(home, '.config/chromium/DevToolsActivePort') },
{ id: 'edge', label: 'Microsoft Edge', devToolsPath: path.join(home, '.config/microsoft-edge/DevToolsActivePort') },
];
case 'win32':
return [
{ id: 'chrome', label: 'Chrome', devToolsPath: path.join(localAppData, 'Google/Chrome/User Data/DevToolsActivePort') },
{ id: 'chromium', label: 'Chromium', devToolsPath: path.join(localAppData, 'Chromium/User Data/DevToolsActivePort') },
{ id: 'edge', label: 'Microsoft Edge', devToolsPath: path.join(localAppData, 'Microsoft/Edge/User Data/DevToolsActivePort') },
];
default:
return [];
}
}
// TCP 端口监听检测
// 用 TCP connect 而非 WebSocket,避免触发浏览器的远程调试授权弹窗。
export function checkPort(port, host = '127.0.0.1', timeoutMs = 2000) {
return new Promise((resolve) => {
const socket = net.createConnection(port, host);
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);
socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once('error', () => { clearTimeout(timer); resolve(false); });
});
}
// 读 config.env 文件(不写入 process.env,分清来源)
// 格式:KEY=VALUE,# 开头是注释
function readConfig() {
const cfg = {};
let content;
try { content = fs.readFileSync(CONFIG_PATH, 'utf8'); }
catch { return cfg; }
for (const line of content.split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const i = t.indexOf('=');
if (i === -1) continue;
const k = t.slice(0, i).trim();
const v = t.slice(i + 1).trim();
if (k && v) cfg[k] = v;
}
return cfg;
}
// 返回所有开了 toggle 且端口活的浏览器
async function detectAll() {
const result = [];
for (const browser of knownBrowsers()) {
let content;
try { content = fs.readFileSync(browser.devToolsPath, 'utf8'); }
catch { continue; }
const lines = content.trim().split(/\r?\n/).filter(Boolean);
const port = parseInt(lines[0], 10);
if (!(port > 0 && port < 65536)) continue;
if (!(await checkPort(port))) continue;
result.push({ ...browser, port, wsPath: lines[1] || null });
}
return result;
}
// 决策入口
// 参数:override — 调用方解析自命令行 --browser 的值(null 表示未传)
// 返回 { kind, browser?, source?, detected, configured, override? }
// kind ∈ 'ok' | 'ambiguous' | 'mismatch' | 'empty'
// source ∈ 'override' | 'preference' | undefined
// ambiguous = 没设偏好 + 至少一个浏览器开了 toggle,需问用户
// mismatch = override/配偏好设了但未检测到对应 toggle,硬错
// empty = 0 浏览器开 toggle 且未设偏好/override
export async function selectBrowser(override = null) {
const detected = await detectAll();
const configured = readConfig().WEB_ACCESS_BROWSER || null;
// 1. 命令行 override(最高优先,单次有效)
if (override) {
const match = detected.find(b => b.id === override);
if (match) return { kind: 'ok', browser: match, source: 'override', detected, configured, override };
return { kind: 'mismatch', source: 'override', detected, configured, override };
}
// 2. config.env preference(持久)
if (configured) {
const match = detected.find(b => b.id === configured);
if (match) return { kind: 'ok', browser: match, source: 'preference', detected, configured };
return { kind: 'mismatch', source: 'preference', detected, configured };
}
// 3. 无偏好 —— 一律询问用户(哪怕 detected 只有一个)
if (detected.length === 0) {
return { kind: 'empty', detected, configured };
}
return { kind: 'ambiguous', detected, configured };
}
// 兜底:扫描常用固定端口
// 适用场景:用户手动 --remote-debugging-port=9222 启动浏览器,
// 此时 DevToolsActivePort 可能不在默认 user-data-dir。
export async function findFallbackPort() {
for (const port of [9222, 9229, 9333]) {
if (await checkPort(port)) return port;
}
return null;
}
#!/usr/bin/env node
// CDP Proxy - 通过 HTTP API 操控用户日常浏览器(Chrome / Edge / Chromium 等)
// 要求:浏览器已开启 remote debugging(chrome://inspect#remote-debugging toggle)
// Node.js 22+(使用原生 WebSocket)
import http from 'node:http';
import { URL } from 'node:url';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { selectBrowser, findFallbackPort } from './browser-discovery.mjs';
// --- 解析命令行 --browser 参数(本次启动用哪个浏览器)---
function parseBrowserArg() {
const argv = process.argv.slice(2);
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--browser' && argv[i + 1]) return argv[i + 1];
if (argv[i].startsWith('--browser=')) return argv[i].slice('--browser='.length);
}
return null;
}
const BROWSER_OVERRIDE = parseBrowserArg();
const PORT = parseInt(process.env.CDP_PROXY_PORT || '3456');
let ws = null;
let cmdId = 0;
const pending = new Map(); // id -> {resolve, timer}
const sessions = new Map(); // targetId -> sessionId
const managedTabs = new Map(); // targetId -> { lastAccessed: number }
const TAB_IDLE_TIMEOUT = parseInt(process.env.CDP_TAB_IDLE_TIMEOUT || '900000'); // 15 min default
const CLEANUP_INTERVAL = 60000; // sweep every 60s
// --- WebSocket 兼容层 ---
let WS;
if (typeof globalThis.WebSocket !== 'undefined') {
// Node 22+ 原生 WebSocket(浏览器兼容 API)
WS = globalThis.WebSocket;
} else {
// 回退到 ws 模块
try {
WS = (await import('ws')).default;
} catch {
console.error('[CDP Proxy] 错误:Node.js 版本 < 22 且未安装 ws 模块');
console.error(' 解决方案:升级到 Node.js 22+ 或执行 npm install -g ws');
process.exit(1);
}
}
// proxy 启动时连接到的浏览器(用于 /health 暴露给 check-deps 比较)
let connectedBrowser = null; // { id, label, source }
// pin 首次成功连接的浏览器 id。重连时只接受同一 id,避免悄悄降级到别的浏览器。
let pinnedBrowserId = null;
// --- 自动发现浏览器调试端口 ---
// 决策完全委派给 browser-discovery.selectBrowser;此处只做日志和返回结构包装。
async function discoverChromePort() {
const result = await selectBrowser(BROWSER_OVERRIDE);
if (result.kind === 'ok') {
if (pinnedBrowserId && pinnedBrowserId !== result.browser.id) {
throw new Error(
`本次连接的浏览器已经是 ${pinnedBrowserId},不会自动切到 ${result.browser.id}。` +
`如果想换成 ${result.browser.id},请先在终端运行 pkill -f cdp-proxy.mjs 重置。`
);
}
pinnedBrowserId = result.browser.id;
connectedBrowser = { id: result.browser.id, label: result.browser.label, source: result.source };
const tag = result.source === 'override' ? '[--browser 指定]' : '[config.env 偏好]';
console.log(`[CDP Proxy] 选用 ${result.browser.label} (端口 ${result.browser.port}${result.browser.wsPath ? ',带 wsPath' : ''}) ${tag}`);
return { port: result.browser.port, wsPath: result.browser.wsPath };
}
// mismatch:有显式偏好但未检测到 —— 硬错,绝不降级
if (result.kind === 'mismatch') {
const expected = result.override || result.configured;
const src = result.source === 'override' ? '本次指定' : '默认偏好';
throw new Error(
`${src}的浏览器是 "${expected}",但没连上。Agent 处理顺序:` +
`(1) 先用系统命令打开 ${expected}(按平台选择,如 macOS 的 open -a),稍等后重试请求;` +
`(2) 若仍失败,说明远程调试开关没启用 —— 告知用户在地址栏访问 ${expected}://inspect/#remote-debugging 勾选 "Allow remote debugging for this browser instance"。`
);
}
// 已 pin 过浏览器(如首次连上 edge 后 edge 退出):拒绝任何 fallback
if (pinnedBrowserId) {
throw new Error(
`本次连接的浏览器是 ${pinnedBrowserId},但现在没连上。Agent 处理顺序:` +
`(1) 先用系统命令打开 ${pinnedBrowserId}(按平台选择),稍等后重试请求;` +
`(2) 若仍失败,告知用户在地址栏访问 ${pinnedBrowserId}://inspect/#remote-debugging 重新勾选允许。` +
`若想换成其他浏览器,请先在终端运行 pkill -f cdp-proxy.mjs 重置。`
);
}
// 仅在「从未成功连接 + 无偏好/override」时允许固定端口兜底(手动 --remote-debugging-port 启动场景)
const fallbackPort = await findFallbackPort();
if (fallbackPort !== null) {
connectedBrowser = { id: 'unknown', label: '未知(通过手动调试端口连接)', source: 'fallback' };
console.log(`[CDP Proxy] 通过手动调试端口连接: ${fallbackPort}`);
return { port: fallbackPort, wsPath: null };
}
return null;
}
function getWebSocketUrl(port, wsPath) {
if (wsPath) return `ws://127.0.0.1:${port}${wsPath}`;
return `ws://127.0.0.1:${port}/devtools/browser`;
}
// --- WebSocket 连接管理 ---
let chromePort = null;
let chromeWsPath = null;
let connectingPromise = null;
async function connect() {
if (ws && (ws.readyState === WS.OPEN || ws.readyState === 1)) return;
if (connectingPromise) return connectingPromise; // 复用进行中的连接
if (!chromePort) {
const discovered = await discoverChromePort();
if (!discovered) {
throw new Error(
'Chrome 未开启远程调试端口。请用以下方式启动 Chrome:\n' +
' macOS: /Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome --remote-debugging-port=9222\n' +
' Linux: google-chrome --remote-debugging-port=9222\n' +
' 或在 chrome://flags 中搜索 "remote debugging" 并启用'
);
}
chromePort = discovered.port;
chromeWsPath = discovered.wsPath;
}
const wsUrl = getWebSocketUrl(chromePort, chromeWsPath);
if (!wsUrl) throw new Error('无法获取 Chrome WebSocket URL');
return connectingPromise = new Promise((resolve, reject) => {
ws = new WS(wsUrl);
const onOpen = () => {
cleanup();
connectingPromise = null;
console.log(`[CDP Proxy] 已连接浏览器 (端口 ${chromePort})`);
resolve();
};
const onError = (e) => {
cleanup();
connectingPromise = null;
ws = null;
chromePort = null;
chromeWsPath = null;
const msg = e.message || e.error?.message || '连接失败';
console.error('[CDP Proxy] 连接错误:', msg, '(端口缓存已清除,下次将重新发现)');
reject(new Error(msg));
};
const onClose = () => {
console.log('[CDP Proxy] 连接断开');
ws = null;
chromePort = null; // 重置端口缓存,下次连接重新发现
chromeWsPath = null;
sessions.clear();
managedTabs.clear();
};
const onMessage = (evt) => {
const data = typeof evt === 'string' ? evt : (evt.data || evt);
const msg = JSON.parse(typeof data === 'string' ? data : data.toString());
if (msg.method === 'Target.attachedToTarget') {
const { sessionId, targetInfo } = msg.params;
sessions.set(targetInfo.targetId, sessionId);
}
// 拦截页面对 Chrome 调试端口的探测请求(反风控)
if (msg.method === 'Fetch.requestPaused') {
const { requestId, sessionId: sid } = msg.params;
sendCDP('Fetch.failRequest', { requestId, errorReason: 'ConnectionRefused' }, sid).catch(() => {});
}
if (msg.id && pending.has(msg.id)) {
const { resolve, timer } = pending.get(msg.id);
clearTimeout(timer);
pending.delete(msg.id);
resolve(msg);
}
};
function cleanup() {
ws.removeEventListener?.('open', onOpen);
ws.removeEventListener?.('error', onError);
}
// 兼容 Node 原生 WebSocket 和 ws 模块的事件 API
if (ws.on) {
ws.on('open', onOpen);
ws.on('error', onError);
ws.on('close', onClose);
ws.on('message', onMessage);
} else {
ws.addEventListener('open', onOpen);
ws.addEventListener('error', onError);
ws.addEventListener('close', onClose);
ws.addEventListener('message', onMessage);
}
});
}
function sendCDP(method, params = {}, sessionId = null) {
return new Promise((resolve, reject) => {
if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) {
return reject(new Error('WebSocket 未连接'));
}
const id = ++cmdId;
const msg = { id, method, params };
if (sessionId) msg.sessionId = sessionId;
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('CDP 命令超时: ' + method));
}, 30000);
pending.set(id, { resolve, timer });
ws.send(JSON.stringify(msg));
});
}
// 已启用端口拦截的 session 集合(避免重复启用)
const portGuardedSessions = new Set();
async function ensureSession(targetId) {
if (sessions.has(targetId)) return sessions.get(targetId);
const resp = await sendCDP('Target.attachToTarget', { targetId, flatten: true });
if (resp.result?.sessionId) {
const sid = resp.result.sessionId;
sessions.set(targetId, sid);
// 启用调试端口探测拦截
await enablePortGuard(sid);
return sid;
}
throw new Error('attach 失败: ' + JSON.stringify(resp.error));
}
// 拦截页面对 Chrome 调试端口的探测(反风控)
// 只拦截 127.0.0.1:{chromePort} 的请求,不影响其他任何本地服务
async function enablePortGuard(sessionId) {
if (!chromePort || portGuardedSessions.has(sessionId)) return;
try {
await sendCDP('Fetch.enable', {
patterns: [
{ urlPattern: `http://127.0.0.1:${chromePort}/*`, requestStage: 'Request' },
{ urlPattern: `http://localhost:${chromePort}/*`, requestStage: 'Request' },
]
}, sessionId);
portGuardedSessions.add(sessionId);
} catch { /* Fetch 域启用失败不影响主流程 */ }
}
// --- 闲置 Tab 自动清理 ---
function touchTab(targetId) {
const entry = managedTabs.get(targetId);
if (entry) entry.lastAccessed = Date.now();
}
async function cleanupIdleTabs() {
if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) return;
const now = Date.now();
for (const [targetId, info] of managedTabs) {
if (now - info.lastAccessed < TAB_IDLE_TIMEOUT) continue;
try { await sendCDP('Target.closeTarget', { targetId }); } catch { /* tab may already be closed */ }
sessions.delete(targetId);
managedTabs.delete(targetId);
console.log(`[CDP Proxy] Auto-closed idle tab: ${targetId}`);
}
}
async function closeAllManagedTabs() {
if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) return;
const targets = [...managedTabs.keys()];
for (const targetId of targets) {
try { await sendCDP('Target.closeTarget', { targetId }); } catch { /* ignore */ }
sessions.delete(targetId);
managedTabs.delete(targetId);
}
if (targets.length) console.log(`[CDP Proxy] Shutdown: closed ${targets.length} managed tab(s)`);
}
// --- 等待页面加载 ---
async function waitForLoad(sessionId, timeoutMs = 15000) {
// 启用 Page 域
await sendCDP('Page.enable', {}, sessionId);
return new Promise((resolve) => {
let resolved = false;
const done = (result) => {
if (resolved) return;
resolved = true;
clearTimeout(timer);
clearInterval(checkInterval);
resolve(result);
};
const timer = setTimeout(() => done('timeout'), timeoutMs);
const checkInterval = setInterval(async () => {
try {
const resp = await sendCDP('Runtime.evaluate', {
expression: 'document.readyState',
returnByValue: true,
}, sessionId);
if (resp.result?.result?.value === 'complete') {
done('complete');
}
} catch { /* 忽略 */ }
}, 500);
});
}
// --- 读取 POST body ---
async function readBody(req) {
let body = '';
for await (const chunk of req) body += chunk;
return body;
}
// --- HTTP API ---
const server = http.createServer(async (req, res) => {
const parsed = new URL(req.url, `http://localhost:${PORT}`);
const pathname = parsed.pathname;
const q = Object.fromEntries(parsed.searchParams);
if (q.target) touchTab(q.target);
res.setHeader('Content-Type', 'application/json; charset=utf-8');
try {
// /health 不需要连接浏览器
if (pathname === '/health') {
const connected = ws && (ws.readyState === WS.OPEN || ws.readyState === 1);
res.end(JSON.stringify({
status: 'ok',
connected,
browser: connectedBrowser,
sessions: sessions.size,
managedTabs: managedTabs.size,
chromePort,
}));
return;
}
await connect();
// GET /targets - 列出所有页面
if (pathname === '/targets') {
const resp = await sendCDP('Target.getTargets');
const pages = resp.result.targetInfos.filter(t => t.type === 'page');
res.end(JSON.stringify(pages, null, 2));
}
// POST /new (body=URL) - 创建新后台 tab
else if (pathname === '/new') {
if (req.method !== 'POST') {
res.statusCode = 400;
res.end(JSON.stringify({
error: 'v2.5.3 起 /new 改为 POST 传 URL(避免目标 URL 含 query 时被错误切分)',
migration: 'references/migration-2.5.3.md',
example: "curl -X POST --data-raw 'https://example.com' http://localhost:3456/new",
}));
return;
}
const body = (await readBody(req)).trim();
const targetUrl = body || 'about:blank';
const resp = await sendCDP('Target.createTarget', { url: targetUrl, background: true });
const targetId = resp.result.targetId;
managedTabs.set(targetId, { lastAccessed: Date.now() });
// 等待页面加载
if (targetUrl !== 'about:blank') {
try {
const sid = await ensureSession(targetId);
await waitForLoad(sid);
} catch { /* 非致命,继续 */ }
}
res.end(JSON.stringify({ targetId }));
}
// GET /close?target=xxx - 关闭 tab
else if (pathname === '/close') {
const resp = await sendCDP('Target.closeTarget', { targetId: q.target });
sessions.delete(q.target);
managedTabs.delete(q.target);
res.end(JSON.stringify(resp.result));
}
// POST /navigate?target=xxx (body=URL) - 导航(自动等待加载)
else if (pathname === '/navigate') {
if (req.method !== 'POST') {
res.statusCode = 400;
res.end(JSON.stringify({
error: 'v2.5.3 起 /navigate 改为 POST 传 URL(避免目标 URL 含 query 时被错误切分)',
migration: 'references/migration-2.5.3.md',
example: "curl -X POST --data-raw 'https://example.com' 'http://localhost:3456/navigate?target=ID'",
}));
return;
}
const targetUrl = (await readBody(req)).trim();
const sid = await ensureSession(q.target);
const resp = await sendCDP('Page.navigate', { url: targetUrl }, sid);
// 等待页面加载完成
await waitForLoad(sid);
res.end(JSON.stringify(resp.result));
}
// GET /back?target=xxx - 后退
else if (pathname === '/back') {
const sid = await ensureSession(q.target);
await sendCDP('Runtime.evaluate', { expression: 'history.back()' }, sid);
await waitForLoad(sid);
res.end(JSON.stringify({ ok: true }));
}
// POST /eval?target=xxx - 执行 JS
else if (pathname === '/eval') {
const sid = await ensureSession(q.target);
const body = await readBody(req);
const expr = body || q.expr || 'document.title';
const resp = await sendCDP('Runtime.evaluate', {
expression: expr,
returnByValue: true,
awaitPromise: true,
}, sid);
if (resp.result?.result?.value !== undefined) {
res.end(JSON.stringify({ value: resp.result.result.value }));
} else if (resp.result?.exceptionDetails) {
res.statusCode = 400;
res.end(JSON.stringify({ error: resp.result.exceptionDetails.text }));
} else {
res.end(JSON.stringify(resp.result));
}
}
// POST /click?target=xxx - 点击(body 为 CSS 选择器)
// POST /click?target=xxx — JS 层面点击(简单快速,覆盖大多数场景)
else if (pathname === '/click') {
const sid = await ensureSession(q.target);
const selector = await readBody(req);
if (!selector) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' }));
return;
}
const selectorJson = JSON.stringify(selector);
const js = `(() => {
const el = document.querySelector(${selectorJson});
if (!el) return { error: '未找到元素: ' + ${selectorJson} };
el.scrollIntoView({ block: 'center' });
el.click();
return { clicked: true, tag: el.tagName, text: (el.textContent || '').slice(0, 100) };
})()`;
const resp = await sendCDP('Runtime.evaluate', {
expression: js,
returnByValue: true,
awaitPromise: true,
}, sid);
if (resp.result?.result?.value) {
const val = resp.result.result.value;
if (val.error) {
res.statusCode = 400;
res.end(JSON.stringify(val));
} else {
res.end(JSON.stringify(val));
}
} else {
res.end(JSON.stringify(resp.result));
}
}
// POST /clickAt?target=xxx — CDP 浏览器级真实鼠标点击(算用户手势,能触发文件对话框、绕过反自动化检测)
else if (pathname === '/clickAt') {
const sid = await ensureSession(q.target);
const selector = await readBody(req);
if (!selector) {
res.statusCode = 400;
res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' }));
return;
}
const selectorJson = JSON.stringify(selector);
const js = `(() => {
const el = document.querySelector(${selectorJson});
if (!el) return { error: '未找到元素: ' + ${selectorJson} };
el.scrollIntoView({ block: 'center' });
const rect = el.getBoundingClientRect();
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, tag: el.tagName, text: (el.textContent || '').slice(0, 100) };
})()`;
const coordResp = await sendCDP('Runtime.evaluate', {
expression: js,
returnByValue: true,
awaitPromise: true,
}, sid);
const coord = coordResp.result?.result?.value;
if (!coord || coord.error) {
res.statusCode = 400;
res.end(JSON.stringify(coord || coordResp.result));
return;
}
await sendCDP('Input.dispatchMouseEvent', {
type: 'mousePressed', x: coord.x, y: coord.y, button: 'left', clickCount: 1
}, sid);
await sendCDP('Input.dispatchMouseEvent', {
type: 'mouseReleased', x: coord.x, y: coord.y, button: 'left', clickCount: 1
}, sid);
res.end(JSON.stringify({ clicked: true, x: coord.x, y: coord.y, tag: coord.tag, text: coord.text }));
}
// POST /setFiles?target=xxx — 给 file input 设置本地文件(绕过文件对话框)
// body: JSON { "selector": "input[type=file]", "files": ["/path/to/file1.png", "/path/to/file2.png"] }
else if (pathname === '/setFiles') {
const sid = await ensureSession(q.target);
const body = JSON.parse(await readBody(req));
if (!body.selector || !body.files) {
res.statusCode = 400;
res.end(JSON.stringify({ error: '需要 selector 和 files 字段' }));
return;
}
// 获取 DOM 节点
await sendCDP('DOM.enable', {}, sid);
const doc = await sendCDP('DOM.getDocument', {}, sid);
const node = await sendCDP('DOM.querySelector', {
nodeId: doc.result.root.nodeId,
selector: body.selector
}, sid);
if (!node.result?.nodeId) {
res.statusCode = 400;
res.end(JSON.stringify({ error: '未找到元素: ' + body.selector }));
return;
}
// 设置文件
await sendCDP('DOM.setFileInputFiles', {
nodeId: node.result.nodeId,
files: body.files
}, sid);
res.end(JSON.stringify({ success: true, files: body.files.length }));
}
// GET /scroll?target=xxx&y=3000 - 滚动
else if (pathname === '/scroll') {
const sid = await ensureSession(q.target);
const y = parseInt(q.y || '3000');
const direction = q.direction || 'down'; // down | up | top | bottom
let js;
if (direction === 'top') {
js = 'window.scrollTo(0, 0); "scrolled to top"';
} else if (direction === 'bottom') {
js = 'window.scrollTo(0, document.body.scrollHeight); "scrolled to bottom"';
} else if (direction === 'up') {
js = `window.scrollBy(0, -${Math.abs(y)}); "scrolled up ${Math.abs(y)}px"`;
} else {
js = `window.scrollBy(0, ${Math.abs(y)}); "scrolled down ${Math.abs(y)}px"`;
}
const resp = await sendCDP('Runtime.evaluate', {
expression: js,
returnByValue: true,
}, sid);
// 等待懒加载触发
await new Promise(r => setTimeout(r, 800));
res.end(JSON.stringify({ value: resp.result?.result?.value }));
}
// GET /screenshot?target=xxx&file=/tmp/x.png - 截图
else if (pathname === '/screenshot') {
const sid = await ensureSession(q.target);
const format = q.format || 'png';
const resp = await sendCDP('Page.captureScreenshot', {
format,
quality: format === 'jpeg' ? 80 : undefined,
}, sid);
if (q.file) {
fs.writeFileSync(q.file, Buffer.from(resp.result.data, 'base64'));
res.end(JSON.stringify({ saved: q.file }));
} else {
res.setHeader('Content-Type', 'image/' + format);
res.end(Buffer.from(resp.result.data, 'base64'));
}
}
// GET /info?target=xxx - 获取页面信息
else if (pathname === '/info') {
const sid = await ensureSession(q.target);
const resp = await sendCDP('Runtime.evaluate', {
expression: 'JSON.stringify({title: document.title, url: location.href, ready: document.readyState})',
returnByValue: true,
}, sid);
res.end(resp.result?.result?.value || '{}');
}
else {
res.statusCode = 404;
res.end(JSON.stringify({
error: '未知端点',
endpoints: {
'/health': 'GET - 健康检查',
'/targets': 'GET - 列出所有页面 tab',
'/new': 'POST body=URL - 创建新后台 tab(自动等待加载)',
'/close?target=': 'GET - 关闭 tab',
'/navigate?target=': 'POST body=URL - 导航(自动等待加载)',
'/back?target=': 'GET - 后退',
'/info?target=': 'GET - 页面标题/URL/状态',
'/eval?target=': 'POST body=JS表达式 - 执行 JS',
'/click?target=': 'POST body=CSS选择器 - 点击元素',
'/scroll?target=&y=&direction=': 'GET - 滚动页面',
'/screenshot?target=&file=': 'GET - 截图',
},
}));
}
} catch (e) {
res.statusCode = 500;
res.end(JSON.stringify({ error: e.message }));
}
});
// 检查端口是否被占用
function checkPortAvailable(port) {
return new Promise((resolve) => {
const s = net.createServer();
s.once('error', () => resolve(false));
s.once('listening', () => { s.close(); resolve(true); });
s.listen(port, '127.0.0.1');
});
}
async function main() {
// 检查是否已有 proxy 在运行
const available = await checkPortAvailable(PORT);
if (!available) {
// 验证已有实例是否健康
try {
const ok = await new Promise((resolve) => {
http.get(`http://127.0.0.1:${PORT}/health`, { timeout: 2000 }, (res) => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => resolve(d.includes('"ok"')));
}).on('error', () => resolve(false));
});
if (ok) {
console.log(`[CDP Proxy] 已有实例运行在端口 ${PORT},退出`);
process.exit(0);
}
} catch { /* 端口占用但非 proxy,继续报错 */ }
console.error(`[CDP Proxy] 端口 ${PORT} 已被占用`);
process.exit(1);
}
server.listen(PORT, '127.0.0.1', () => {
console.log(`[CDP Proxy] 运行在 http://localhost:${PORT}`);
// 启动时尝试连接 Chrome(非阻塞)
connect().catch(e => console.error('[CDP Proxy] 初始连接失败:', e.message, '(将在首次请求时重试)'));
});
// 定时清理闲置 tab
const cleanupTimer = setInterval(cleanupIdleTabs, CLEANUP_INTERVAL);
cleanupTimer.unref();
const shutdown = async (sig) => {
console.log(`[CDP Proxy] ${sig}, cleaning up...`);
clearInterval(cleanupTimer);
await closeAllManagedTabs();
process.exit(0);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
// 防止未捕获异常导致进程崩溃
process.on('uncaughtException', (e) => {
console.error('[CDP Proxy] 未捕获异常:', e.message);
});
process.on('unhandledRejection', (e) => {
console.error('[CDP Proxy] 未处理拒绝:', e?.message || e);
});
main();
#!/usr/bin/env node
// 环境检查 + 确保 CDP Proxy 就绪
//
// 用法:
// node check-deps.mjs 默认行为:读 config.env 偏好
// node check-deps.mjs --browser edge 本次临时指定浏览器(不写 config.env)
//
// 持久偏好 → config.env (skill 根目录, gitignored)
// 单次覆盖 → --browser 命令行参数(全链路 argv,不碰 process.env)
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { selectBrowser, knownBrowsers, findFallbackPort } from './browser-discovery.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PROXY_SCRIPT = path.join(ROOT, 'scripts', 'cdp-proxy.mjs');
const PROXY_PORT = Number(process.env.CDP_PROXY_PORT || 3456);
const CONFIG_PATH = path.join(ROOT, 'config.env');
const CONFIG_TEMPLATE = path.join(ROOT, 'templates', 'config.env.template');
// --- 参数解析 ---
function parseArgs(argv) {
const opts = { browser: null };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--browser' && argv[i + 1]) { opts.browser = argv[i + 1]; i++; }
else if (argv[i].startsWith('--browser=')) { opts.browser = argv[i].slice('--browser='.length); }
}
return opts;
}
// --- 首次安装:从模板创建 config.env ---
function ensureConfigExists() {
if (fs.existsSync(CONFIG_PATH)) return;
try {
fs.copyFileSync(CONFIG_TEMPLATE, CONFIG_PATH);
console.log(`config: 已从模板创建 ${CONFIG_PATH}`);
} catch {
// 模板不存在或拷贝失败 —— 不阻塞,readConfig 会兜底
}
}
// --- Node.js 版本检查 ---
function checkNode() {
const major = Number(process.versions.node.split('.')[0]);
const version = `v${process.versions.node}`;
if (major >= 22) console.log(`node: ok (${version})`);
else console.log(`node: warn (${version}, 建议升级到 22+)`);
}
// --- CDP Proxy 启动与等待 ---
function httpGetJson(url, timeoutMs = 3000) {
return fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
.then(async (res) => { try { return JSON.parse(await res.text()); } catch { return null; } })
.catch(() => null);
}
function startProxyDetached(browserOverride) {
const logFile = path.join(os.tmpdir(), 'cdp-proxy.log');
const logFd = fs.openSync(logFile, 'a');
const args = [PROXY_SCRIPT];
if (browserOverride) args.push('--browser', browserOverride);
const child = spawn(process.execPath, args, {
detached: true,
stdio: ['ignore', logFd, logFd],
...(os.platform() === 'win32' ? { windowsHide: true } : {}),
});
child.unref();
fs.closeSync(logFd);
}
async function ensureProxy(expectedBrowserId, browserOverride) {
const healthUrl = `http://127.0.0.1:${PROXY_PORT}/health`;
const targetsUrl = `http://127.0.0.1:${PROXY_PORT}/targets`;
// 复用:proxy 已运行 + 已连接浏览器 → 校验 expected vs actual
const health = await httpGetJson(healthUrl);
if (health?.status === 'ok' && health.connected) {
const runningId = health.browser?.id;
const runningLabel = health.browser?.label || runningId || 'unknown';
if (expectedBrowserId && runningId && runningId !== 'unknown' && runningId !== expectedBrowserId) {
console.log(`proxy: 浏览器不一致 — 当前已连着 ${runningLabel},但本次需要 ${expectedBrowserId}`);
console.log(' 请在终端运行 pkill -f cdp-proxy.mjs 重置后再试');
return false;
}
console.log(`proxy: ready (${runningLabel})`);
return true;
}
console.log('proxy: connecting...');
startProxyDetached(browserOverride);
await new Promise((r) => setTimeout(r, 2000));
for (let i = 1; i <= 15; i++) {
const result = await httpGetJson(targetsUrl, 8000);
if (Array.isArray(result)) {
const newHealth = await httpGetJson(healthUrl);
const label = newHealth?.browser?.label || 'unknown';
console.log(`proxy: ready (${label})`);
return true;
}
if (i === 1) {
console.log('⚠️ 浏览器可能有授权弹窗,请点击「允许」后等待连接...');
}
await new Promise((r) => setTimeout(r, 1000));
}
console.log('❌ 连接超时,请检查浏览器调试设置');
console.log(` 日志:${path.join(os.tmpdir(), 'cdp-proxy.log')}`);
return false;
}
// --- 输出浏览器选择结果,返回是否可以继续启动 proxy ---
function printAvailableHint(detected) {
const detectedIds = new Set(detected.map(b => b.id));
const configurable = knownBrowsers().filter(b => !detectedIds.has(b.id));
if (detected.length) {
console.log(` 已开启远程调试:${detected.map(b => `${b.label} (${b.id}, port ${b.port})`).join('、')}`);
}
if (configurable.length) {
console.log(` 其他可配置:${configurable.map(b => `${b.label} (${b.id})`).join('、')}`);
}
}
async function resolveAndReport(override) {
const result = await selectBrowser(override);
switch (result.kind) {
case 'ok': {
const sourceTag = result.source === 'override' ? '[--browser 指定]' : '[config.env 偏好]';
console.log(`browser: ok (${result.browser.label}, port ${result.browser.port}) ${sourceTag}`);
return { proceed: true, browserId: result.browser.id };
}
case 'ambiguous': {
console.log('browser: needs decision — 用户尚未在 config.env 设置偏好');
printAvailableHint(result.detected);
console.log(' 请询问用户:哪个浏览器作为 Agent 的默认?(写入 config.env 的 WEB_ACCESS_BROWSER)');
console.log(' 若仅本次使用,可重跑:node check-deps.mjs --browser <id>');
return { proceed: false, exitCode: 2 };
}
case 'mismatch': {
const expected = result.override || result.configured;
const expectedLabel = knownBrowsers().find(b => b.id === expected)?.label || expected;
const sourceDesc = result.source === 'override' ? '本次指定' : '默认偏好';
console.log(`browser: error — ${sourceDesc}的浏览器是 "${expected}" (${expectedLabel}),但没连上`);
console.log(` Agent 处理顺序:`);
console.log(` 1. 先用系统命令打开 ${expectedLabel}(按你所在平台自行选择,如 macOS 的 open -a),再重新运行 node check-deps.mjs`);
console.log(` 2. 若仍报相同错误,可能是因为远程调试开关没启用 —— 告诉用户:在 ${expectedLabel} 的地址栏访问 ${expected}://inspect/#remote-debugging,勾选 "Allow remote debugging for this browser instance"`);
printAvailableHint(result.detected);
if (result.source === 'preference') {
console.log(` 也可以编辑 config.env 改默认偏好,或本次临时换浏览器:node check-deps.mjs --browser <id>`);
}
return { proceed: false, exitCode: 1 };
}
case 'empty': {
// 末路兜底:尝试常见固定端口(用户手动 --remote-debugging-port=9222 启动的场景)
const fallbackPort = await findFallbackPort();
if (fallbackPort) {
console.log(`browser: ok (port ${fallbackPort}) [通过手动调试端口连接]`);
return { proceed: true };
}
console.log('browser: 未连接 — 没有任何浏览器打开远程调试开关');
console.log(` 支持的浏览器:${knownBrowsers().map(b => b.label).join('、')}`);
console.log(' 在你想用的浏览器地址栏打开 chrome://inspect/#remote-debugging 或 edge://inspect/#remote-debugging,勾选 "Allow remote debugging for this browser instance"');
return { proceed: false, exitCode: 1 };
}
}
}
// --- main ---
async function main() {
const opts = parseArgs(process.argv.slice(2));
ensureConfigExists();
checkNode();
const { proceed, exitCode, browserId } = await resolveAndReport(opts.browser);
if (!proceed) process.exit(exitCode);
const proxyOk = await ensureProxy(browserId, opts.browser);
if (!proxyOk) process.exit(1);
// 列出已有站点经验
const patternsDir = path.join(ROOT, 'references', 'site-patterns');
try {
const sites = fs.readdirSync(patternsDir)
.filter(f => f.endsWith('.md'))
.map(f => f.replace(/\.md$/, ''));
if (sites.length) {
console.log(`\nsite-patterns: ${sites.join(', ')}`);
}
} catch {}
}
await main();
#!/usr/bin/env node
// find-url - 从本地 Chromium 系浏览器(Chrome / Edge)书签/历史中检索 URL
// 用于定位公网搜索覆盖不到的目标(组织内部系统、SSO 后台、内网域名等)。
//
// 用法:
// node find-url.mjs [关键词...] [--only bookmarks|history] [--limit N] [--since 1d|7h|YYYY-MM-DD]
//
// <关键词> 空格分词、多词 AND,匹配 title + url;可省略
// --only <source> 限定数据源(bookmarks / history),默认两者都查
// --browser <id> 限定浏览器(chrome / edge),默认遍历所有已安装的
// --limit N 条数上限,默认 20;0 = 不限
// --since <window> 时间窗(仅作用于历史)。1d / 7h / 30m 或 YYYY-MM-DD
// --sort recent|visits 历史排序:按最近访问 / 按访问次数,默认 recent
//
// 示例:
// node find-url.mjs 财务小智
// node find-url.mjs agent skills
// node find-url.mjs github --since 7d --only history
// node find-url.mjs --since 7d --only history --sort visits # 最近一周高频网站
// node find-url.mjs --since 2d --only history --limit 0
// node find-url.mjs github --browser edge # 只查 Edge
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { execFileSync } from 'node:child_process';
// --- 参数解析 -----------------------------------------------------------
function parseArgs(argv) {
const a = { keywords: [], only: null, browser: null, limit: 20, since: null, sort: 'recent' };
for (let i = 0; i < argv.length; i++) {
const v = argv[i];
if (v === '--only') a.only = argv[++i];
else if (v === '--browser') a.browser = argv[++i];
else if (v === '--limit') a.limit = parseInt(argv[++i], 10);
else if (v === '--since') a.since = parseSince(argv[++i]);
else if (v === '--sort') a.sort = argv[++i];
else if (v === '-h' || v === '--help') { printUsage(); process.exit(0); }
else if (v.startsWith('--')) die(`未知参数: ${v}`);
else a.keywords.push(v);
}
if (a.only && !['bookmarks', 'history'].includes(a.only)) die(`--only 仅支持 bookmarks|history`);
if (!['recent', 'visits'].includes(a.sort)) die(`--sort 仅支持 recent|visits`);
if (Number.isNaN(a.limit) || a.limit < 0) die('--limit 需为非负整数');
return a;
}
function parseSince(s) {
if (!s) die('--since 需要值');
const m = s.match(/^(\d+)([dhm])$/);
if (m) {
const n = parseInt(m[1], 10);
const ms = { d: 86400000, h: 3600000, m: 60000 }[m[2]];
return new Date(Date.now() - n * ms);
}
const d = new Date(s);
if (Number.isNaN(d.getTime())) die(`无效 --since 值: ${s}(用 1d / 7h / 30m / YYYY-MM-DD)`);
return d;
}
function die(msg) { console.error(msg); process.exit(1); }
function printUsage() { console.error(fs.readFileSync(new URL(import.meta.url)).toString().split('\n').slice(1, 21).map(l => l.replace(/^\/\/ ?/, '')).join('\n')); }
// --- 浏览器用户数据目录(跨平台 + 多浏览器) -----------------------------
// 加新浏览器:只改这里
function knownBrowserDataDirs() {
const home = os.homedir();
const localAppData = process.env.LOCALAPPDATA || '';
switch (os.platform()) {
case 'darwin':
return [
{ id: 'chrome', label: 'Chrome', dir: path.join(home, 'Library/Application Support/Google/Chrome') },
{ id: 'edge', label: 'Edge', dir: path.join(home, 'Library/Application Support/Microsoft Edge') },
];
case 'linux':
return [
{ id: 'chrome', label: 'Chrome', dir: path.join(home, '.config/google-chrome') },
{ id: 'edge', label: 'Edge', dir: path.join(home, '.config/microsoft-edge') },
];
case 'win32':
return [
{ id: 'chrome', label: 'Chrome', dir: path.join(localAppData, 'Google/Chrome/User Data') },
{ id: 'edge', label: 'Edge', dir: path.join(localAppData, 'Microsoft/Edge/User Data') },
];
default:
return [];
}
}
// --- Profile 枚举 -------------------------------------------------------
function listProfiles(dataDir) {
try {
const state = JSON.parse(fs.readFileSync(path.join(dataDir, 'Local State'), 'utf-8'));
const info = state?.profile?.info_cache || {};
const list = Object.keys(info).map(dir => ({ dir, name: info[dir].name || dir }));
if (list.length) return list;
} catch { /* 回退 */ }
return [{ dir: 'Default', name: 'Default' }];
}
// --- 书签检索 -----------------------------------------------------------
function searchBookmarks(profileDir, profileName, browserLabel, keywords) {
const file = path.join(profileDir, 'Bookmarks');
if (!fs.existsSync(file)) return [];
let data;
try { data = JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return []; }
if (!keywords.length) return []; // 书签无时间维度,无关键词不返回
const needles = keywords.map(k => k.toLowerCase());
const out = [];
function walk(node, trail) {
if (!node) return;
if (node.type === 'url') {
const hay = `${node.name || ''} ${node.url || ''}`.toLowerCase();
if (needles.every(n => hay.includes(n))) {
out.push({ browser: browserLabel, profile: profileName, name: node.name || '', url: node.url || '', folder: trail.join(' / ') });
}
}
if (Array.isArray(node.children)) {
const sub = node.name ? [...trail, node.name] : trail;
for (const c of node.children) walk(c, sub);
}
}
for (const root of Object.values(data.roots || {})) walk(root, []);
return out;
}
// --- 历史检索(SQLite 运行时锁定,需 copy 到 tmp) ------------------------
const WEBKIT_EPOCH_DIFF_US = 11644473600000000n; // 1601→1970 微秒差
function searchHistory(profileDir, profileName, browserLabel, keywords, since, limit, sort) {
const src = path.join(profileDir, 'History');
if (!fs.existsSync(src)) return [];
const tmp = path.join(os.tmpdir(), `browser-history-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.sqlite`);
try {
fs.copyFileSync(src, tmp);
const conds = ['last_visit_time > 0'];
for (const kw of keywords) {
const esc = kw.toLowerCase().replace(/'/g, "''");
conds.push(`LOWER(title || ' ' || url) LIKE '%${esc}%'`);
}
if (since) {
const webkitUs = BigInt(since.getTime()) * 1000n + WEBKIT_EPOCH_DIFF_US;
conds.push(`last_visit_time >= ${webkitUs}`);
}
const limitClause = limit === 0 ? -1 : limit;
const orderBy = sort === 'visits'
? 'visit_count DESC, last_visit_time DESC'
: 'last_visit_time DESC';
const sql = `SELECT title, url,
datetime((last_visit_time - 11644473600000000)/1000000, 'unixepoch', 'localtime') AS visit,
visit_count
FROM urls WHERE ${conds.join(' AND ')}
ORDER BY ${orderBy} LIMIT ${limitClause};`;
const raw = execFileSync('sqlite3', ['-separator', '\t', tmp, sql], { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
return raw.trim().split('\n').filter(Boolean).map(line => {
const [title, url, visit, visit_count] = line.split('\t');
return { browser: browserLabel, profile: profileName, title, url, visit, visit_count: parseInt(visit_count, 10) };
});
} catch (e) {
if (e.code === 'ENOENT') die('未找到 sqlite3 命令。macOS/Linux 通常自带;Windows 可用 `winget install sqlite.sqlite` 或从 https://sqlite.org/download.html 下载后加入 PATH。');
return [];
} finally {
try { fs.unlinkSync(tmp); } catch {}
}
}
// --- 输出格式化 ---------------------------------------------------------
// 用 `|` 作字段分隔符;字段内含 `|` 的替换成 `│`(全宽竖线)避免歧义
const clean = s => String(s ?? '').replaceAll('|', '│').trim();
function originTag(item, showBrowser, showProfile) {
if (showBrowser && showProfile) return '@' + clean(item.browser) + '-' + clean(item.profile);
if (showBrowser) return '@' + clean(item.browser);
if (showProfile) return '@' + clean(item.profile);
return null;
}
function printBookmarks(items, showBrowser, showProfile) {
console.log(`[书签] ${items.length} 条`);
for (const b of items) {
const segs = [clean(b.name) || '(无标题)', clean(b.url)];
if (b.folder) segs.push(clean(b.folder));
const tag = originTag(b, showBrowser, showProfile);
if (tag) segs.push(tag);
console.log(' ' + segs.join(' | '));
}
}
function printHistory(items, showBrowser, showProfile, sortLabel) {
console.log(`[历史] ${items.length} 条(${sortLabel})`);
for (const h of items) {
const segs = [clean(h.title) || '(无标题)', clean(h.url), h.visit];
if (h.visit_count > 1) segs.push(`visits=${h.visit_count}`);
const tag = originTag(h, showBrowser, showProfile);
if (tag) segs.push(tag);
console.log(' ' + segs.join(' | '));
}
}
// --- main ---------------------------------------------------------------
const args = parseArgs(process.argv.slice(2));
let browsers = knownBrowserDataDirs().filter(b => fs.existsSync(b.dir));
if (args.browser) {
const filtered = browsers.filter(b => b.id === args.browser);
if (!filtered.length) {
const available = browsers.map(b => b.id).join('、') || '无';
die(`未找到浏览器 ${args.browser} 的用户数据目录(已检测到:${available})`);
}
browsers = filtered;
}
if (!browsers.length) die('未找到任何浏览器(Chrome / Edge)的用户数据目录');
const doBookmarks = args.only !== 'history';
const doHistory = args.only !== 'bookmarks';
const bookmarks = [];
const history = [];
for (const browser of browsers) {
const profiles = listProfiles(browser.dir);
for (const p of profiles) {
const pDir = path.join(browser.dir, p.dir);
if (!fs.existsSync(pDir)) continue;
if (doBookmarks) bookmarks.push(...searchBookmarks(pDir, p.name, browser.label, args.keywords));
if (doHistory) history.push(...searchHistory(pDir, p.name, browser.label, args.keywords, args.since, args.limit === 0 ? 0 : args.limit * 2, args.sort));
}
}
// 历史跨 profile/浏览器 合并后按指定 sort 重排 + 切顶
if (args.sort === 'visits') {
history.sort((a, b) => (b.visit_count || 0) - (a.visit_count || 0) || (b.visit || '').localeCompare(a.visit || ''));
} else {
history.sort((a, b) => (b.visit || '').localeCompare(a.visit || ''));
}
const bookmarksOut = args.limit === 0 ? bookmarks : bookmarks.slice(0, args.limit);
const historyOut = args.limit === 0 ? history : history.slice(0, args.limit);
// 仅当结果真的横跨多个 browser/profile 时才输出 @ 标注(避免单源场景噪音)
const seenBrowsers = new Set([...bookmarksOut, ...historyOut].map(x => x.browser));
const seenProfiles = new Set([...bookmarksOut, ...historyOut].map(x => x.profile));
const showBrowser = seenBrowsers.size > 1;
const showProfile = seenProfiles.size > 1;
const sortLabel = args.sort === 'visits' ? '按访问次数' : '按最近访问';
if (doBookmarks) printBookmarks(bookmarksOut, showBrowser, showProfile);
if (doBookmarks && doHistory) console.log();
if (doHistory) printHistory(historyOut, showBrowser, showProfile, sortLabel);
if (!args.keywords.length && doBookmarks && !doHistory) {
console.error('\n提示:书签无时间维度,无关键词查询无意义。加关键词或切换 --only history。');
}
#!/usr/bin/env node
// 根据用户输入匹配站点经验文件(跨平台,替代 match-site.sh)
// 用法:node match-site.mjs "用户输入文本"
// 输出:匹配到的站点经验内容,无匹配则静默
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PATTERNS_DIR = path.join(ROOT, 'references', 'site-patterns');
const query = (process.argv[2] || '').trim();
if (!query || !fs.existsSync(PATTERNS_DIR)) {
process.exit(0);
}
for (const entry of fs.readdirSync(PATTERNS_DIR, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const domain = entry.name.replace(/\.md$/, '');
const raw = fs.readFileSync(path.join(PATTERNS_DIR, entry.name), 'utf8');
// 提取 aliases
const aliasesLine = raw.split(/\r?\n/).find((l) => l.startsWith('aliases:')) || '';
const aliases = aliasesLine
.replace(/^aliases:\s*/, '')
.replace(/^\[/, '').replace(/\]$/, '')
.split(',')
.map((v) => v.trim())
.filter(Boolean);
// 构建匹配模式
const escaped = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = [domain, ...aliases].map(escaped).join('|');
if (!new RegExp(pattern, 'i').test(query)) continue;
// 跳过 frontmatter,输出正文
const fences = [...raw.matchAll(/^---\s*$/gm)];
const body = fences.length >= 2
? raw.slice(fences[1].index + fences[1][0].length).replace(/^\r?\n/, '')
: raw;
process.stdout.write(`--- 站点经验: ${domain} ---\n`);
process.stdout.write(body.trimEnd() + '\n\n');
}
# web-access skill 用户偏好配置
# 此文件是模板。首次运行 check-deps.mjs 时会自动复制为 config.env(git ignored)。
#
# 用户长期偏好的默认 Agent 浏览器
# - 留空:每次启动都问用户偏好哪个
# - 设值:固定使用该浏览器;若未检测到对应 toggle 则硬错(不擅自降级)
#
# 合法值:chrome / edge
WEB_ACCESS_BROWSER=
Related skills
Forks & variants (2)
Web Access has 2 known copies in the catalog totaling 6 installs. They canonicalize to this original listing.
- binggandata - 5 installs
- eze-is - 1 installs
How it compares
Choose web-access when agents need full browser fidelity (logins, JS rendering, UI clicks); use lightweight search APIs for read-only fact lookup.
FAQ
What does web-access do?
所有联网操作必须通过此 skill 处理,包括:搜索、网页抓取、登录后操作、网络交互等。
When should I use web-access?
所有联网操作必须通过此 skill 处理,包括:搜索、网页抓取、登录后操作、网络交互等。
Is web-access safe to install?
Review the Security Audits panel on this page before installing in production.