
Web Access
- 1 installs
- Updated March 31, 2026
- ysyyrps777/web-access-openclaw
Run browser-backed search and site tasks through your existing Chrome session from OpenClaw without rebuilding login state on every request.
About
web-access is an OpenClaw-oriented adaptation of the popular web-access skill so developers can delegate real browser work to their agent while reusing an existing Chrome profile. Instead of headless-only fetches that fail on interactive or session-bound pages, the skill drives automation through Chrome DevTools Protocol against the user’s browser, which is useful for research, checking live UIs, and repeating workflows that depend on cookies or manual login once. The published tree is a packaging and integration layer: non-skill artifacts were stripped so you can drop the folder into OpenClaw’s custom skills directory. It fits developers who already run OpenClaw on desktop (including Windows) and want one skill for search plus browser-backed tasks without maintaining separate Playwright scripts. Expect to configure CDP attachment and respect site terms; it complements API integrations rather than replacing them for stable, key-based data pulls.
- OpenClaw custom skill packaging of upstream eze-is/web-access
- Browser-session-backed automation via Chrome CDP
- Session reuse for logged-in or cookie-dependent sites
- Validated on Windows in an OpenClaw environment per adapter notes
- MIT upstream with explicit attribution to 一泽 Eze
Web Access by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,980 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Security screen: CRITICAL risk (skills.sh audit)
- Data as of Jul 7, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ysyyrps777/web-access-openclaw --skill web-accessAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Security audit | 1 / 3 scanners passed |
| Last updated | March 31, 2026 |
| Repository | ysyyrps777/web-access-openclaw ↗ |
What it does
Run browser-backed search and site tasks through your existing Chrome session from OpenClaw without rebuilding login state on every request.
Files
web-access Skill
前置检查
在开始联网操作前,先检查 CDP 模式可用性:
node "$CLAUDE_SKILL_DIR/scripts/check-deps.mjs"- 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,保持环境整洁。
启动
node "$CLAUDE_SKILL_DIR/scripts/check-deps.mjs"脚本会依次检查 Node.js、Chrome 端口,并确保 Proxy 已连接(未运行则自动启动并等待)。Proxy 启动后持续运行。
Proxy API
所有操作通过 curl 调用 HTTP API:
# 列出用户已打开的 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/ 下。
已有经验的站点:!node -e "const fs=require('fs'),p=require('path').join(process.env.CLAUDE_SKILL_DIR||'.','references','site-patterns');try{console.log(fs.readdirSync(p).filter(f=>f.endsWith('.md')).map(f=>f.replace(/\\.md$/,'')).join(', ')||'暂无')}catch{console.log('暂无')}"
确定目标网站后,如果上方列表中有匹配的站点,必须读取对应文件获取先验知识(平台特征、有效模式、已知陷阱)。经验内容标注了发现日期,当作可能有效的提示而非保证——如果按经验操作失败,回退通用模式并更新经验文件。
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 | 确定目标网站后,读取对应站点经验 |
Attribution
This repository adapts the upstream project below for use as an OpenClaw custom skill:
- Original project:
eze-is/web-access - Upstream URL: https://github.com/eze-is/web-access
- Original author: 一泽 Eze
- Upstream license: MIT
What this adaptation changed:
- prepared the repository as an OpenClaw custom skill folder
- removed non-skill packaging artifacts from the published skill tree
- validated runtime behavior in an OpenClaw environment on Windows
- verified browser-session-backed automation through Chrome CDP
This adaptation should be understood as a packaging/integration layer for OpenClaw usage, not a replacement for the upstream project's authorship or identity.
MIT
This repository contains an adaptation of the upstream project below:
https://github.com/eze-is/web-access
Original project author: 一泽 Eze
Refer to the upstream project for the original license text and provenance.
This repository preserves attribution and presents an OpenClaw-oriented adaptation/integration of that work.
web-access-openclaw
An OpenClaw-adapted version of `eze-is/web-access`, packaged as a custom OpenClaw skill for browser-backed web tasks with session reuse.
What this is
This repository adapts the original web-access skill so it can be dropped into OpenClaw's custom skills directory and used for real browser automation through the user's existing Chrome session.
It is intended for tasks such as:
- searching and reading dynamic websites
- using logged-in browser sessions via CDP
- interacting with real web UIs instead of static fetches
- handling sites that require JavaScript, authentication, or human-like flows
What was adapted for OpenClaw
This repo keeps the skill focused and publishable as an OpenClaw custom skill:
- cleaned skill layout (
SKILL.md,scripts/,references/) - removed Claude plugin packaging artifacts from the published skill folder
- validated on Windows with OpenClaw + Chrome remote debugging
- confirmed CDP proxy startup and browser-session reuse
- tested a real browser-session flow against Xiaohongshu creator publishing
Install in OpenClaw
Copy this folder into your OpenClaw custom skills directory:
~/.openclaw/workspace/skills/web-accessOpenClaw already loads custom skills from the workspace skills directory when configured with:
{
"skills": {
"load": {
"extraDirs": ["~/.openclaw/workspace/skills"]
}
}
}Requirements
- Node.js 22+
- Google Chrome with remote debugging enabled
- A live Chrome profile/session for sites that need login
- OpenClaw running on the same machine
Chrome setup
Enable Chrome remote debugging from:
chrome://inspect/#remote-debuggingEnable Allow remote debugging for this browser instance.
How it works
The skill uses a local CDP proxy to connect to the user's normal Chrome browser, so it can:
- reuse logged-in sessions
- open background tabs
- read dynamic DOM content
- click, scroll, upload files, and submit forms
- operate on sites that do not work well through static scraping alone
Tested status
Validated locally on Windows with OpenClaw:
- Node.js detected successfully
- Chrome remote debugging reachable
- CDP proxy reported ready
- browser targets visible through proxy
- logged-in session reuse confirmed
- Xiaohongshu creator flow tested, including text-to-image posting and publish success
Safety notes
This skill can operate inside real logged-in browser sessions. That is powerful and risky.
Recommended practice:
- require explicit user confirmation before posting, publishing, submitting, deleting, or changing account data
- prefer read-only inspection before write actions
- avoid touching existing user tabs unless necessary
Upstream source and attribution
This adaptation is based on the original project by 一泽 Eze:
- Upstream: https://github.com/eze-is/web-access
The original project is MIT licensed. This repository is an OpenClaw-oriented adaptation and packaging pass, not a claim of authorship over the original concept or implementation.
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/targetsGET /new?url=URL
创建新后台 tab,自动等待页面加载完成。返回 { targetId }.
curl -s "http://localhost:3456/new?url=https://example.com"GET /close?target=ID
关闭指定 tab。
curl -s "http://localhost:3456/close?target=TARGET_ID"GET /navigate?target=ID&url=URL
在已有 tab 中导航到新 URL,自动等待加载。
curl -s "http://localhost:3456/navigate?target=ID&url=https://example.com"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 已在运行 | 已有实例可直接复用 |
#!/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;
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();
};
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();
#!/usr/bin/env node
// 环境检查 + 确保 CDP Proxy 就绪(跨平台,替代 check-deps.sh)
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
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);
// --- 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+)`);
}
}
// --- TCP 端口探测 ---
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); });
});
}
// --- Chrome 调试端口检测(DevToolsActivePort 多路径 + 常见端口回退) ---
function activePortFiles() {
const home = os.homedir();
const localAppData = process.env.LOCALAPPDATA || '';
switch (os.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 function detectChromePort() {
// 优先从 DevToolsActivePort 文件读取
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)) {
return port;
}
} catch (_) {}
}
// 回退:探测常见端口
for (const port of [9222, 9229, 9333]) {
if (await checkPort(port)) {
return port;
}
}
return null;
}
// --- 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() {
const logFile = path.join(os.tmpdir(), 'cdp-proxy.log');
const logFd = fs.openSync(logFile, 'a');
const child = spawn(process.execPath, [PROXY_SCRIPT], {
detached: true,
stdio: ['ignore', logFd, logFd],
...(os.platform() === 'win32' ? { windowsHide: true } : {}),
});
child.unref();
fs.closeSync(logFd);
}
async function ensureProxy() {
const targetsUrl = `http://127.0.0.1:${PROXY_PORT}/targets`;
// /targets 返回 JSON 数组即 ready
const targets = await httpGetJson(targetsUrl);
if (Array.isArray(targets)) {
console.log('proxy: ready');
return true;
}
// 未运行或未连接,启动并等待
console.log('proxy: connecting...');
startProxyDetached();
// 等 proxy 进程就绪
await new Promise((r) => setTimeout(r, 2000));
for (let i = 1; i <= 15; i++) {
const result = await httpGetJson(targetsUrl, 8000);
if (Array.isArray(result)) {
console.log('proxy: ready');
return true;
}
if (i === 1) {
console.log('⚠️ Chrome 可能有授权弹窗,请点击「允许」后等待连接...');
}
await new Promise((r) => setTimeout(r, 1000));
}
console.log('❌ 连接超时,请检查 Chrome 调试设置');
console.log(` 日志:${path.join(os.tmpdir(), 'cdp-proxy.log')}`);
return false;
}
// --- main ---
async function main() {
checkNode();
const chromePort = await detectChromePort();
if (!chromePort) {
console.log('chrome: not connected — 请确保 Chrome 已打开,然后访问 chrome://inspect/#remote-debugging 并勾选 Allow remote debugging');
process.exit(1);
}
console.log(`chrome: ok (port ${chromePort})`);
const proxyOk = await ensureProxy();
if (!proxyOk) {
process.exit(1);
}
}
await main();
#!/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');
}
Related skills
FAQ
Is Web Access safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.