
Web Access
- 5 installs
- 553 repo stars
- Updated August 5, 2026
- binggandata/bggg-skills
This is a copy of web-access by eze-is - installs and ranking accrue to the original listing.
web-access is a skill that routes an agent's web search, scraping and login-gated browser operations through a CDP proxy to the user's real Chrome or Edge.
About
This skill routes all of an agent's networked operations (search, web scraping, login-gated actions, dynamic-page reads) through a single browser-access path. It connects to the user's daily Chrome or Edge via a CDP proxy so requests carry existing login state, and exposes an HTTP API for eval, click, scroll, screenshot, navigate and file upload. It also guides when to use WebSearch, WebFetch, curl or Jina versus the browser. A developer uses it as the connectivity layer for tasks that need a real browser environment, such as scraping sites that block static fetches. It is a third-party MIT skill by Eze bundled as an optional component.
- Routes all networked work (search, scrape, login-gated actions) through one browser-access skill
- Connects to the user's everyday Chrome/Edge via a CDP proxy, inheriting logged-in state
- HTTP proxy API for eval/click/scroll/screenshot/navigate/file-upload plus WebSearch/WebFetch/curl/Jina guidance
Web Access by the numbers
- 5 all-time installs (skills.sh)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
web-access capabilities & compatibility
Free and MIT-licensed; optional Jina preprocessing layer is a separate third-party service (20 RPM).
- Capabilities
- web scraping · browser automation · web search
- Works with
- chrome
- Use cases
- web scraping · web search · research
- Platforms
- macOS
- Pricing
- Free
What web-access says it does
所有联网操作必须通过此 skill 处理,包括:搜索、网页抓取、登录后操作、网络交互等。
通过 CDP Proxy 直连用户日常浏览器(Chrome / Edge / Chromium 等 Chromium 系),天然携带登录态,无需启动独立浏览器。
**Node.js 22+** 必需(使用原生 WebSocket)。
npx skills add https://github.com/binggandata/bggg-skills --skill web-accessAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 553 |
| Last updated | August 5, 2026 |
| Repository | binggandata/bggg-skills ↗ |
What it does
Give an agent one browser-access layer that inherits the user's Chrome login to search, scrape and operate login-gated pages via a CDP proxy.
Who is it for?
Login-gated or dynamically rendered pages where static WebFetch/curl fail and browser state is required.
Skip if: Simple public pages where WebSearch, WebFetch or curl already return what you need.
When should I use this skill?
The user asks to search, view a web page, access a login-gated site, operate a web UI, or scrape dynamically rendered or anti-scraped content.
What you get
A running CDP proxy plus an HTTP API that lets an agent search, read, click, scroll and upload files inside the user's logged-in browser.
- running CDP proxy
- browser HTTP API (eval/click/scroll/screenshot/navigate)
- extracted page content and screenshots
By the numbers
- proxy served on localhost:3456
- version 2.5.3
Files
web-access Skill
来源与授权
| 项 | 说明 |
|---|---|
| GitHub | https://github.com/eze-is/web-access |
| 作者 | 一泽 Eze |
| 协议 | MIT |
| 官网 | https://web-access.eze.is |
本 Skill 由 一泽 Eze 维护。若你通过 Sif 关键词 Skills 分发包获得此目录,其为上游项目的打包副本,仅作可选联网/浏览器组件;问题反馈与版本更新以上游仓库为准。
---
前置检查
在开始联网操作前,先检查 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.3",
"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
!references/site-patterns/sif.com.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>
随 Sif 关键词 Skills 分发包打包的第三方组件
上游项目:github.com/eze-is/web-access · 作者:一泽 Eze · 协议:MIT · 官网
本目录内容为上游 Skill 的副本,用于 Sif 方式 B(浏览器导出);更新请从上游仓库获取。完整说明见下文。
<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.3 能力
| 能力 | 说明 |
|---|---|
| 联网工具自动选择 | 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.3 更新:
- URL 通过 POST body 传递 —
/new与/navigate不再把目标 URL 放入 query,避免目标 URL 自带 query 时丢失 token 或被错误切分。
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 —— 内部一致性提升。
平台特征
- 亚马逊关键词 SaaS,需登录态
- 默认 US:
https://www.sif.com/skill/keywords-library?country=US - 三张表入口:
- 表1:
/skill/keywords-library?country=US→ 8 步向导 → Step8.download-card button - 表2:
/reverse→ 工具栏.downloadPolorBtn.noMargin:not(.compareDownload)(第 2 个下载图标) - 表3:
/adxray-searchterm?country=US→button.el-button--primary文本「下载数据」
有效模式
find-url.mjs sif 关键词从历史定位入口- 登录态来自用户 Chrome/Edge CDP
- 下载目录因用户而异:写入工作区
.sif-config.json的download_dir,不要假设 Downloads 路径
数据获取方式(sif-keyword-scout)
| 方式 | 说明 |
|---|---|
| A 手动 | 给用户 URL + 按钮指引,用户自行下载(更快) |
| B CDP | Agent 按 browser-export-sop 点击,从 download_dir 取文件 |
表结构(处理脚本依赖)
- 表1:合并 Sheet 或 高/中/低 三 Sheet → 见 sif-keyword-scout
references/table-schema.md - 表2/表3:第 1 行元信息,第 2 行表头
- F 列 ABA 排名 ≠ 搜索量
已知陷阱
- 未登录无法导出
- 表1 已从「一键下载」改为 8 步向导
- 表2 下载在工具栏,不是页面底部
// 浏览器 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
FAQ
How does it get past logins?
It connects through a CDP proxy to the user's everyday Chrome/Edge, so operations carry existing login state without launching a separate browser.
What Node version does it need?
Node.js 22+ is required because it uses the native WebSocket implementation.