Initial commit: skills library
- 70 skills with code and documentation - Add .gitignore (ignore __pycache__, output/, temp/, venv/) - Clean up test intermediates and caches
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
references/site-patterns/*.md
|
||||
@@ -0,0 +1,125 @@
|
||||
<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" />
|
||||
|
||||
给 Claude Code 装上完整联网能力的 skill。
|
||||
|
||||
Claude Code 原本有 WebSearch、WebFetch,但缺少调度策略和浏览器自动化能力。这个 skill 补上的是:**联网策略 + CDP 浏览器操作 + 站点经验积累**。
|
||||
|
||||
> 推荐必读:[Web Access:一个 Skill,拉满 Agent 联网和浏览器能力](https://mp.weixin.qq.com/s/rps5YVB6TchT9npAaIWKCw) ,完整介绍了 Web-Access Skill 的开发细节与 Agent Skill 设计哲学,帮助你也能写出类似通用、高上限的 Skill
|
||||
|
||||
---
|
||||
|
||||
## v2.4 能力
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| 联网工具自动选择 | WebSearch / WebFetch / curl / Jina / CDP,按场景自主判断,可任意组合 |
|
||||
| CDP Proxy 浏览器操作 | 直连用户日常 Chrome,天然携带登录态,支持动态页面、交互操作、视频截帧 |
|
||||
| 三种点击方式 | `/click`(JS click)、`/clickAt`(CDP 真实鼠标事件)、`/setFiles`(文件上传) |
|
||||
| 并行分治 | 多目标时分发子 Agent 并行执行,共享一个 Proxy,tab 级隔离 |
|
||||
| 站点经验积累 | 按域名存储操作经验(URL 模式、平台特征、已知陷阱),跨 session 复用 |
|
||||
| 媒体提取 | 从 DOM 直取图片/视频 URL,或对视频任意时间点截帧分析 |
|
||||
|
||||
**v2.4 更新:**
|
||||
- **站点内 URL 可靠性** — 新增事实说明:站点生成的链接自带完整上下文,手动构造的 URL 可能缺失隐式必要参数
|
||||
- **平台错误提示不可信** — 新增技术事实:平台返回的"内容不存在"等提示可能是访问方式问题而非内容本身问题
|
||||
- **小红书站点经验增强** — xsec_token 机制、创作者平台状态校验、暂存草稿流程
|
||||
|
||||
<details><summary>v2.3 更新</summary>
|
||||
|
||||
- **浏览哲学重构** — 更清晰的「像人一样思考」框架,强调目标驱动而非步骤驱动
|
||||
- **Jina 积极推荐** — 明确鼓励在合适场景主动使用 Jina 节省 token
|
||||
- **子 Agent prompt 指引优化** — 明确加载写法,增加避免动词暗示执行方式的说明
|
||||
</details>
|
||||
|
||||
## 安装
|
||||
|
||||
**方式一:让 Claude 自动安装**
|
||||
|
||||
```
|
||||
帮我安装这个 skill:https://github.com/eze-is/web-access
|
||||
```
|
||||
|
||||
**方式二:手动**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/eze-is/web-access ~/.claude/skills/web-access
|
||||
```
|
||||
|
||||
## 前置配置(CDP 模式)
|
||||
|
||||
CDP 模式需要 **Node.js 22+** 和 Chrome 开启远程调试:
|
||||
|
||||
1. Chrome 地址栏打开 `chrome://inspect/#remote-debugging`
|
||||
2. 勾选 **Allow remote debugging for this browser instance**(可能需要重启浏览器)
|
||||
|
||||
运行环境检查:
|
||||
|
||||
```bash
|
||||
bash ~/.claude/skills/web-access/scripts/check-deps.sh
|
||||
```
|
||||
|
||||
## CDP Proxy API
|
||||
|
||||
Proxy 通过 WebSocket 直连 Chrome(兼容 `chrome://inspect` 方式,无需命令行参数启动),提供 HTTP API:
|
||||
|
||||
```bash
|
||||
# 启动
|
||||
node ~/.claude/skills/web-access/scripts/cdp-proxy.mjs &
|
||||
|
||||
# 页面操作
|
||||
curl -s "http://localhost:3456/new?url=https://example.com" # 新建 tab
|
||||
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
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
安装后直接让 Agent 执行联网任务,skill 自动接管:
|
||||
|
||||
- "帮我搜索 xxx 最新进展"
|
||||
- "读一下这个页面:[URL]"
|
||||
- "去小红书搜索 xxx 的账号"
|
||||
- "帮我在创作者平台发一篇图文"
|
||||
- "同时调研这 5 个产品的官网,给我对比摘要"
|
||||
|
||||
## 设计哲学
|
||||
|
||||
> Skill = 哲学 + 技术事实,不是操作手册。讲清 tradeoff 让 AI 自己选,不替它推理。
|
||||
|
||||
详见 [SKILL.md](./SKILL.md) 中的浏览哲学部分。
|
||||
|
||||
## License
|
||||
|
||||
MIT · 作者:[一泽 Eze](https://github.com/eze-is)
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#eze-is/web-access&Date)
|
||||
|
||||
<img width="1280" height="306" alt="image" src="https://github.com/user-attachments/assets/2afa25c2-3730-413e-b40f-94e52567249d" />
|
||||
@@ -0,0 +1,243 @@
|
||||
---
|
||||
name: web-access
|
||||
license: MIT
|
||||
github: https://github.com/eze-is/web-access
|
||||
description:
|
||||
所有联网操作必须通过此 skill 处理,包括:搜索、网页抓取、登录后操作、网络交互等。
|
||||
触发场景:用户要求搜索信息、查看网页内容、访问需要登录的网站、操作网页界面、抓取社交媒体内容(小红书、微博、推特等)、读取动态渲染页面、以及任何需要真实浏览器环境的网络任务。
|
||||
metadata:
|
||||
author: 一泽Eze
|
||||
version: "2.4.1"
|
||||
---
|
||||
|
||||
# web-access Skill
|
||||
|
||||
## 前置检查
|
||||
|
||||
在开始联网操作前,先检查 CDP 模式可用性:
|
||||
|
||||
```bash
|
||||
bash ~/.claude/skills/web-access/scripts/check-deps.sh
|
||||
```
|
||||
|
||||
- **Node.js 22+**:必需(使用原生 WebSocket)。版本低于 22 可用但需安装 `ws` 模块。
|
||||
- **Chrome remote-debugging**:在 Chrome 地址栏打开 `chrome://inspect/#remote-debugging`,勾选 **"Allow remote debugging for this browser instance"** 即可,可能需要重启浏览器。
|
||||
|
||||
检查通过后再启动 CDP Proxy 执行操作,未通过则引导用户完成设置。
|
||||
|
||||
## 浏览哲学
|
||||
|
||||
**像人一样思考,兼顾高效与适应性的完成任务。**
|
||||
|
||||
执行任务时不会过度依赖固有印象所规划的步骤,而是带着目标进入,边看边判断,遇到阻碍就解决,发现内容不够就深入——全程围绕「我要达成什么」做决策。这个 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` 视觉识别
|
||||
|
||||
浏览网页时,**先了解页面结构,再决定下一步动作**。不需要提前规划所有步骤。
|
||||
|
||||
### 程序化操作与 GUI 交互
|
||||
|
||||
浏览器内操作页面有两种方式:
|
||||
|
||||
- **程序化方式**(构造 URL 直接导航、eval 操作 DOM):成功时速度快、精确,但对网站来说不是正常用户行为,更容易触发反爬机制。
|
||||
- **GUI 交互**(点击按钮、填写输入框、滚动浏览):GUI 是为人设计的,网站不会限制正常的 UI 操作,确定性最高,但步骤多、速度慢。
|
||||
|
||||
根据对目标平台的了解来判断。当程序化方式受阻时,GUI 交互是可靠的兜底。
|
||||
|
||||
**站点内 URL 的可靠性**:站点自己生成的链接(DOM 中的 href)天然携带平台所需的完整上下文,而手动构造的 URL 可能缺失隐式必要参数,导致被拦截、返回错误页面、甚至触发反爬。当构造的 URL 出现这类异常时,应考虑是否是缺失参数所致。
|
||||
|
||||
## 浏览器 CDP 模式
|
||||
|
||||
通过 CDP Proxy 直连用户日常 Chrome,天然携带登录态,无需启动独立浏览器。
|
||||
若无用户明确要求,不主动操作用户已有 tab,所有操作都在自己创建的后台 tab 中进行,保持对用户环境的最小侵入。不关闭用户 tab 的前提下,完成任务后关闭自己创建的 tab,保持环境整洁。
|
||||
|
||||
### 启动
|
||||
|
||||
```bash
|
||||
bash ~/.claude/skills/web-access/scripts/check-deps.sh
|
||||
```
|
||||
|
||||
脚本会依次检查 Node.js、Chrome 端口,并确保 Proxy 已连接(未运行则自动启动并等待)。Proxy 启动后持续运行。
|
||||
|
||||
### Proxy API
|
||||
|
||||
所有操作通过 curl 调用 HTTP API:
|
||||
|
||||
```bash
|
||||
# 列出用户已打开的 tab
|
||||
curl -s http://localhost:3456/targets
|
||||
|
||||
# 创建新后台 tab(自动等待加载)
|
||||
curl -s "http://localhost:3456/new?url=https://example.com"
|
||||
|
||||
# 页面信息
|
||||
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"
|
||||
|
||||
# 导航、后退
|
||||
curl -s "http://localhost:3456/navigate?target=ID&url=URL"
|
||||
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**:从 DOM 提取对象链接的完整地址(包含所有查询参数),在新 tab 中打开。适合需要同时访问多个页面的场景。
|
||||
|
||||
很多网站的链接包含会话相关的参数(如 token),这些参数是正常访问所必需的。提取 URL 时应保留完整地址,不要裁剪或省略参数。
|
||||
|
||||
### 媒体资源提取
|
||||
|
||||
判断内容在图片里时,用 `/eval` 从 DOM 直接拿图片 URL,再定向读取——比全页截图精准得多。
|
||||
|
||||
### 技术事实
|
||||
- 页面中存在大量已加载但未展示的内容——轮播中非当前帧的图片、折叠区块的文字、懒加载占位元素等,它们存在于 DOM 中但对用户不可见。以数据结构(容器、属性、节点关系)为单位思考,可以直接触达这些内容。
|
||||
- DOM 中存在选择器不可跨越的边界(Shadow DOM 的 `shadowRoot`、iframe 的 `contentDocument`等)。eval 递归遍历可一次穿透所有层级,返回带标签的结构化内容,适合快速了解未知页面的完整结构。
|
||||
- `/scroll` 到底部会触发懒加载,使未进入视口的图片完成加载。提取图片 URL 前若未滚动,部分图片可能尚未加载。
|
||||
- 拿到媒体资源 URL 后,公开资源可直接下载到本地后用读取;需要登录态才可获取的资源才需要在浏览器内 navigate + screenshot。
|
||||
- 短时间内密集打开大量页面(如批量 `/new`)可能触发网站的反爬风控。
|
||||
- 平台返回的"内容不存在""页面不见了"等提示不一定反映真实状态,也可能是访问方式的问题(如 URL 缺失必要参数、触发反爬)而非内容本身的问题。
|
||||
|
||||
### 视频内容获取
|
||||
|
||||
用户 Chrome 真实渲染,截图可捕获当前视频帧。核心能力:通过 `/eval` 操控 `<video>` 元素(获取时长、seek 到任意时间点、播放/暂停/全屏),配合 `/screenshot` 采帧,可对视频内容进行离散采样分析。
|
||||
|
||||
### 登录判断
|
||||
|
||||
用户日常 Chrome 天然携带登录态,大多数常用网站已登录。
|
||||
|
||||
登录判断的核心问题只有一个:**目标内容拿到了吗?**
|
||||
|
||||
打开页面后先尝试获取目标内容。只有当确认**目标内容无法获取**且判断登录能解决时,才告知用户:
|
||||
> "当前页面在未登录状态下无法获取[具体内容],请在你的 Chrome 中登录 [网站名],完成后告诉我继续。"
|
||||
|
||||
登录完成后无需重启任何东西,直接刷新页面继续。
|
||||
|
||||
### 任务结束
|
||||
|
||||
用 `/close` 关闭自己创建的 tab,必须保留用户原有的 tab 不受影响。
|
||||
|
||||
Proxy 持续运行,不建议主动停止——重启后需要在 Chrome 中重新授权 CDP 连接。
|
||||
|
||||
## 并行调研:子 Agent 分治策略
|
||||
|
||||
任务包含多个**独立**调研目标时(如同时调研 N 个项目、N 个来源),鼓励合理分治给子 Agent 并行执行,而非主 Agent 串行处理。
|
||||
|
||||
**好处:**
|
||||
- **速度**:多子 Agent 并行,总耗时约等于单个子任务时长
|
||||
- **上下文保护**:抓取内容不进入主 Agent 上下文,主 Agent 只接收摘要,节省 token
|
||||
|
||||
**并行 CDP 操作**:每个子 Agent 在当前用户浏览器实例中,自行创建所需的后台 tab(`/new`),自行操作,任务结束自行关闭(`/close`)。所有子 Agent 共享一个 Chrome、一个 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/` 下。
|
||||
|
||||
已有经验的站点:!`ls ${CLAUDE_SKILL_DIR}/references/site-patterns/ 2>/dev/null | sed 's/\.md$//' || echo "暂无"`
|
||||
|
||||
确定目标网站后,如果上方列表中有匹配的站点,必须读取对应文件获取先验知识(平台特征、有效模式、已知陷阱)。经验内容标注了发现日期,当作可能有效的提示而非保证——如果按经验操作失败,回退通用模式并更新经验文件。
|
||||
|
||||
CDP 操作成功完成后,如果发现了有必要记录经验的新站点或新模式(URL 结构、平台特征、操作策略),主动写入对应的站点经验文件。只写经过验证的事实,不写未确认的猜测。
|
||||
|
||||
文件格式:
|
||||
```markdown
|
||||
---
|
||||
domain: example.com
|
||||
aliases: [示例, Example]
|
||||
updated: 2026-03-19
|
||||
---
|
||||
## 平台特征
|
||||
架构、反爬行为、登录需求、内容加载方式等事实
|
||||
|
||||
## 有效模式
|
||||
已验证的 URL 模式、操作策略、选择器
|
||||
|
||||
## 已知陷阱
|
||||
什么会失败以及为什么
|
||||
```
|
||||
经验/陷阱内容标注发现日期,当作"可能有效的提示"而非"保证正确的事实"。
|
||||
|
||||
## References 索引
|
||||
|
||||
| 文件 | 何时加载 |
|
||||
|------|---------|
|
||||
| `references/cdp-api.md` | 需要 CDP API 详细参考、JS 提取模式、错误处理时 |
|
||||
| `references/site-patterns/{domain}.md` | 确定目标网站后,读取对应站点经验 |
|
||||
@@ -0,0 +1,106 @@
|
||||
# CDP Proxy API 参考
|
||||
|
||||
## 基础信息
|
||||
|
||||
- 地址:`http://localhost:3456`
|
||||
- 启动:`node ~/.claude/skills/web-access/scripts/cdp-proxy.mjs &`
|
||||
- 启动后持续运行,不建议主动停止(重启需 Chrome 重新授权)
|
||||
- 强制停止:`pkill -f cdp-proxy.mjs`
|
||||
|
||||
## API 端点
|
||||
|
||||
### GET /health
|
||||
健康检查,返回连接状态。
|
||||
```bash
|
||||
curl -s http://localhost:3456/health
|
||||
```
|
||||
|
||||
### GET /targets
|
||||
列出所有已打开的页面 tab。返回数组,每项含 `targetId`、`title`、`url`。
|
||||
```bash
|
||||
curl -s http://localhost:3456/targets
|
||||
```
|
||||
|
||||
### GET /new?url=URL
|
||||
创建新后台 tab,自动等待页面加载完成。返回 `{ targetId }`.
|
||||
```bash
|
||||
curl -s "http://localhost:3456/new?url=https://example.com"
|
||||
```
|
||||
|
||||
### GET /close?target=ID
|
||||
关闭指定 tab。
|
||||
```bash
|
||||
curl -s "http://localhost:3456/close?target=TARGET_ID"
|
||||
```
|
||||
|
||||
### GET /navigate?target=ID&url=URL
|
||||
在已有 tab 中导航到新 URL,自动等待加载。
|
||||
```bash
|
||||
curl -s "http://localhost:3456/navigate?target=ID&url=https://example.com"
|
||||
```
|
||||
|
||||
### GET /back?target=ID
|
||||
后退一页。
|
||||
```bash
|
||||
curl -s "http://localhost:3456/back?target=ID"
|
||||
```
|
||||
|
||||
### GET /info?target=ID
|
||||
获取页面基础信息(title、url、readyState)。
|
||||
```bash
|
||||
curl -s "http://localhost:3456/info?target=ID"
|
||||
```
|
||||
|
||||
### POST /eval?target=ID
|
||||
执行 JavaScript 表达式,POST body 为 JS 代码。
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:3456/eval?target=ID" -d 'document.title'
|
||||
```
|
||||
|
||||
### POST /click?target=ID
|
||||
JS 层面点击(`el.click()`),POST body 为 CSS 选择器。自动 scrollIntoView 后点击。简单快速,覆盖大多数场景。
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:3456/click?target=ID" -d 'button.submit'
|
||||
```
|
||||
|
||||
### POST /clickAt?target=ID
|
||||
CDP 浏览器级真实鼠标点击(`Input.dispatchMouseEvent`),POST body 为 CSS 选择器。先获取元素坐标,再模拟鼠标按下/释放。算真实用户手势,能触发文件对话框、绕过部分反自动化检测。
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:3456/clickAt?target=ID" -d 'button.upload'
|
||||
```
|
||||
|
||||
### POST /setFiles?target=ID
|
||||
给 file input 设置本地文件路径(`DOM.setFileInputFiles`),完全绕过文件对话框。POST body 为 JSON。
|
||||
```bash
|
||||
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 供懒加载触发。
|
||||
```bash
|
||||
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`。
|
||||
```bash
|
||||
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 已在运行 | 已有实例可直接复用 |
|
||||
@@ -0,0 +1,572 @@
|
||||
#!/usr/bin/env node
|
||||
// CDP Proxy - 通过 HTTP API 操控用户日常 Chrome
|
||||
// 要求:Chrome 已开启 --remote-debugging-port
|
||||
// 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';
|
||||
|
||||
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
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 自动发现 Chrome 调试端口 ---
|
||||
async function discoverChromePort() {
|
||||
// 1. 尝试读 DevToolsActivePort 文件
|
||||
const possiblePaths = [];
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const home = os.homedir();
|
||||
possiblePaths.push(
|
||||
path.join(home, 'Library/Application Support/Google/Chrome/DevToolsActivePort'),
|
||||
path.join(home, 'Library/Application Support/Google/Chrome Canary/DevToolsActivePort'),
|
||||
path.join(home, 'Library/Application Support/Chromium/DevToolsActivePort'),
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const home = os.homedir();
|
||||
possiblePaths.push(
|
||||
path.join(home, '.config/google-chrome/DevToolsActivePort'),
|
||||
path.join(home, '.config/chromium/DevToolsActivePort'),
|
||||
);
|
||||
} else if (platform === 'win32') {
|
||||
const localAppData = process.env.LOCALAPPDATA || '';
|
||||
possiblePaths.push(
|
||||
path.join(localAppData, 'Google/Chrome/User Data/DevToolsActivePort'),
|
||||
path.join(localAppData, 'Chromium/User Data/DevToolsActivePort'),
|
||||
);
|
||||
}
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
try {
|
||||
const content = fs.readFileSync(p, 'utf-8').trim();
|
||||
const lines = content.split('\n');
|
||||
const port = parseInt(lines[0]);
|
||||
if (port > 0 && port < 65536) {
|
||||
const ok = await checkPort(port);
|
||||
if (ok) {
|
||||
// 第二行是带 UUID 的 WebSocket 路径(如 /devtools/browser/xxx-xxx)
|
||||
// 非显式 --remote-debugging-port 启动时,Chrome 可能只接受此路径
|
||||
const wsPath = lines[1] || null;
|
||||
console.log(`[CDP Proxy] 从 DevToolsActivePort 发现端口: ${port}${wsPath ? ' (带 wsPath)' : ''}`);
|
||||
return { port, wsPath };
|
||||
}
|
||||
}
|
||||
} catch { /* 文件不存在,继续 */ }
|
||||
}
|
||||
|
||||
// 2. 扫描常用端口
|
||||
const commonPorts = [9222, 9229, 9333];
|
||||
for (const port of commonPorts) {
|
||||
const ok = await checkPort(port);
|
||||
if (ok) {
|
||||
console.log(`[CDP Proxy] 扫描发现 Chrome 调试端口: ${port}`);
|
||||
return { port, wsPath: null };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 用 TCP 探测端口是否监听——避免 WebSocket 连接触发 Chrome 安全弹窗
|
||||
// (WebSocket 探测会被 Chrome 视为调试连接,弹出授权对话框)
|
||||
function checkPort(port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection(port, '127.0.0.1');
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 2000);
|
||||
socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once('error', () => { clearTimeout(timer); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
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] 已连接 Chrome (端口 ${chromePort})`);
|
||||
resolve();
|
||||
};
|
||||
const onError = (e) => {
|
||||
cleanup();
|
||||
connectingPromise = 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();
|
||||
};
|
||||
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);
|
||||
}
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
sessions.set(targetId, resp.result.sessionId);
|
||||
return resp.result.sessionId;
|
||||
}
|
||||
throw new Error('attach 失败: ' + JSON.stringify(resp.error));
|
||||
}
|
||||
|
||||
// --- 等待页面加载 ---
|
||||
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);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
// /health 不需要连接 Chrome
|
||||
if (pathname === '/health') {
|
||||
const connected = ws && (ws.readyState === WS.OPEN || ws.readyState === 1);
|
||||
res.end(JSON.stringify({ status: 'ok', connected, sessions: sessions.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));
|
||||
}
|
||||
|
||||
// GET /new?url=xxx - 创建新后台 tab
|
||||
else if (pathname === '/new') {
|
||||
const targetUrl = q.url || 'about:blank';
|
||||
const resp = await sendCDP('Target.createTarget', { url: targetUrl, background: true });
|
||||
const targetId = resp.result.targetId;
|
||||
|
||||
// 等待页面加载
|
||||
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);
|
||||
res.end(JSON.stringify(resp.result));
|
||||
}
|
||||
|
||||
// GET /navigate?target=xxx&url=yyy - 导航(自动等待加载)
|
||||
else if (pathname === '/navigate') {
|
||||
const sid = await ensureSession(q.target);
|
||||
const resp = await sendCDP('Page.navigate', { url: q.url }, 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?url=': 'GET - 创建新后台 tab(自动等待加载)',
|
||||
'/close?target=': 'GET - 关闭 tab',
|
||||
'/navigate?target=&url=': 'GET - 导航(自动等待加载)',
|
||||
'/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, '(将在首次请求时重试)'));
|
||||
});
|
||||
}
|
||||
|
||||
// 防止未捕获异常导致进程崩溃
|
||||
process.on('uncaughtException', (e) => {
|
||||
console.error('[CDP Proxy] 未捕获异常:', e.message);
|
||||
});
|
||||
process.on('unhandledRejection', (e) => {
|
||||
console.error('[CDP Proxy] 未处理拒绝:', e?.message || e);
|
||||
});
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# 环境检查 + 确保 CDP Proxy 就绪
|
||||
|
||||
# Node.js
|
||||
if command -v node &>/dev/null; then
|
||||
NODE_VER=$(node --version 2>/dev/null)
|
||||
NODE_MAJOR=$(echo "$NODE_VER" | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_MAJOR" -ge 22 ] 2>/dev/null; then
|
||||
echo "node: ok ($NODE_VER)"
|
||||
else
|
||||
echo "node: warn ($NODE_VER, 建议升级到 22+)"
|
||||
fi
|
||||
else
|
||||
echo "node: missing — 请安装 Node.js 22+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Chrome 调试端口 — 先读 DevToolsActivePort,再回退常见端口
|
||||
if ! CHROME_PORT=$(node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const net = require('net');
|
||||
|
||||
function checkPort(port) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection(port, '127.0.0.1');
|
||||
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 2000);
|
||||
socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
||||
socket.once('error', () => { clearTimeout(timer); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
function activePortFiles() {
|
||||
const home = os.homedir();
|
||||
const localAppData = process.env.LOCALAPPDATA || '';
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return [
|
||||
path.join(home, 'Library/Application Support/Google/Chrome/DevToolsActivePort'),
|
||||
path.join(home, 'Library/Application Support/Google/Chrome Canary/DevToolsActivePort'),
|
||||
path.join(home, 'Library/Application Support/Chromium/DevToolsActivePort'),
|
||||
];
|
||||
case 'linux':
|
||||
return [
|
||||
path.join(home, '.config/google-chrome/DevToolsActivePort'),
|
||||
path.join(home, '.config/chromium/DevToolsActivePort'),
|
||||
];
|
||||
case 'win32':
|
||||
return [
|
||||
path.join(localAppData, 'Google/Chrome/User Data/DevToolsActivePort'),
|
||||
path.join(localAppData, 'Chromium/User Data/DevToolsActivePort'),
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
for (const filePath of activePortFiles()) {
|
||||
try {
|
||||
const lines = fs.readFileSync(filePath, 'utf8').trim().split(/\\r?\\n/).filter(Boolean);
|
||||
const port = parseInt(lines[0], 10);
|
||||
if (port > 0 && port < 65536 && await checkPort(port)) {
|
||||
console.log(port);
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
for (const port of [9222, 9229, 9333]) {
|
||||
if (await checkPort(port)) {
|
||||
console.log(port);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
})();
|
||||
" 2>/dev/null); then
|
||||
echo "chrome: not connected — 请打开 chrome://inspect/#remote-debugging 并勾选 Allow remote debugging"
|
||||
exit 1
|
||||
fi
|
||||
echo "chrome: ok (port $CHROME_PORT)"
|
||||
|
||||
# CDP Proxy — 用 /targets 统一判断:返回 JSON 数组即 ready,失败则启动并重试
|
||||
TARGETS=$(curl -s --connect-timeout 3 "http://127.0.0.1:3456/targets" 2>/dev/null)
|
||||
if echo "$TARGETS" | grep -q '^\['; then
|
||||
echo "proxy: ready"
|
||||
else
|
||||
# /targets 失败:proxy 未运行或未连接 Chrome,尝试启动(已运行会自动跳过)
|
||||
echo "proxy: connecting..."
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
node "$SCRIPT_DIR/cdp-proxy.mjs" > /tmp/cdp-proxy.log 2>&1 &
|
||||
sleep 2 # 等 proxy 进程就绪
|
||||
for i in $(seq 1 15); do
|
||||
# connect-timeout 5s:给 Chrome 授权弹窗留够响应时间,避免超时后重复触发连接
|
||||
curl -s --connect-timeout 5 --max-time 8 http://localhost:3456/targets 2>/dev/null | grep -q '^\[' && echo "proxy: ready" && exit 0
|
||||
[ $i -eq 1 ] && echo "⚠️ Chrome 可能有授权弹窗,请点击「允许」后等待连接..."
|
||||
done
|
||||
echo "❌ 连接超时,请检查 Chrome 调试设置"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# 根据用户输入匹配站点经验文件
|
||||
# 用法:match-site.sh "用户输入文本"
|
||||
# 输出:匹配到的站点经验内容,无匹配则静默
|
||||
|
||||
DIR="$(dirname "$0")/../references/site-patterns"
|
||||
[ ! -d "$DIR" ] && exit 0
|
||||
[ -z "$1" ] && exit 0
|
||||
|
||||
for f in "$DIR"/*.md; do
|
||||
[ ! -f "$f" ] && continue
|
||||
domain=$(basename "$f" .md)
|
||||
# 提取 aliases 行,去掉 yaml 格式字符,逗号分隔转竖线
|
||||
aliases=$(grep '^aliases:' "$f" | sed 's/^aliases: *//;s/\[//g;s/\]//g;s/, */|/g;s/ *$//')
|
||||
# 构建匹配模式:domain|alias1|alias2
|
||||
patterns="$domain"
|
||||
[ -n "$aliases" ] && patterns="$patterns|$aliases"
|
||||
# 匹配用户输入(不区分大小写,固定字符串用 fgrep 思路但需要多模式所以用 grep -iE)
|
||||
if echo "$1" | grep -qiE "$patterns"; then
|
||||
echo "--- 站点经验: $domain ---"
|
||||
# 跳过 frontmatter(两个 --- 之间),输出正文
|
||||
awk 'BEGIN{n=0} /^---$/{n++;next} n>=2{print}' "$f"
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
Reference in New Issue
Block a user