[
  {
    "path": ".claude-plugin/marketplace.json",
    "content": "{\n  \"name\": \"web-access\",\n  \"owner\": {\n    \"name\": \"一泽 Eze\"\n  },\n  \"metadata\": {\n    \"description\": \"Complete web browsing and automation skill for Claude Code\"\n  },\n  \"plugins\": [\n    {\n      \"name\": \"web-access\",\n      \"description\": \"All web operations: search, fetch, CDP browser automation, site experience accumulation\",\n      \"source\": \"./\",\n      \"category\": \"productivity\",\n      \"tags\": [\"web\", \"browser\", \"cdp\", \"automation\", \"search\"]\n    }\n  ]\n}\n"
  },
  {
    "path": ".claude-plugin/plugin.json",
    "content": "{\n  \"name\": \"web-access\",\n  \"description\": \"Complete web browsing and automation skill for Claude Code - intelligent tool selection, CDP browser automation, and site experience accumulation\",\n  \"version\": \"2.4.2\",\n  \"author\": {\n    \"name\": \"一泽 Eze\"\n  },\n  \"homepage\": \"https://github.com/eze-is/web-access\",\n  \"repository\": \"https://github.com/eze-is/web-access\",\n  \"license\": \"MIT\",\n  \"keywords\": [\"web\", \"browser\", \"cdp\", \"automation\", \"search\"],\n  \"skills\": \"./\"\n}\n"
  },
  {
    "path": ".gitignore",
    "content": ".DS_Store\n*.log\nreferences/site-patterns/*.md\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"right\">\n  <details>\n    <summary>🌐 Language</summary>\n    <div>\n      <div align=\"center\">\n        <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=en\">English</a>\n        | <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=zh-CN\">简体中文</a>\n        | <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=zh-TW\">繁體中文</a>\n        | <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=ja\">日本語</a>\n        | <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=ko\">한국어</a>\n        | <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=fr\">Français</a>\n        | <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=de\">Deutsch</a>\n        | <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=es\">Español</a>\n        | <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=pt\">Português</a>\n        | <a href=\"https://openaitx.github.io/view.html?user=eze-is&project=web-access&lang=ru\">Русский</a>\n      </div>\n    </div>\n  </details>\n</div>\n\n<img width=\"879\" height=\"376\" alt=\"image\" src=\"https://github.com/user-attachments/assets/a87fd816-a0b5-4264-b01c-9466eae90723\" />\n\n<p align=\"center\">\n  <b>给 AI Agent 装上完整联网能力的 Skill。</b><br/>\n  <a href=\"https://web-access.eze.is\">🌐 官网</a> · <a href=\"https://mp.weixin.qq.com/s/rps5YVB6TchT9npAaIWKCw\">📖 设计详解</a> · <a href=\"#安装\">⚡ 快速安装</a>\n</p>\n\nAI Agent 原本的联网能力（WebSearch、WebFetch）缺少调度策略和浏览器自动化能力。这个 Agent Skill 补上的是：**联网策略 + CDP 浏览器操作 + 站点经验积累**。兼容所有支持 SKILL.md 的 Agent（Claude Code、Cursor、Gemini CLI、Codex CLI 等）。\n\n> 推荐必读：[Web Access：一个 Skill，拉满 Agent 联网和浏览器能力](https://mp.weixin.qq.com/s/rps5YVB6TchT9npAaIWKCw) ，完整介绍了 Web-Access Skill 的开发细节与 Agent Skill 设计哲学，帮助你也能写出类似通用、高上限的 Skill\n\n---\n\n## v2.5.0 能力\n\n| 能力 | 说明 |\n|------|------|\n| 联网工具自动选择 | WebSearch / WebFetch / curl / Jina / CDP，按场景自主判断，可任意组合 |\n| CDP Proxy 浏览器操作 | 直连用户日常 Chrome，天然携带登录态，支持动态页面、交互操作、视频截帧 |\n| 三种点击方式 | `/click`（JS click）、`/clickAt`（CDP 真实鼠标事件）、`/setFiles`（文件上传） |\n| 本地 Chrome 书签/历史检索 | `find-url.mjs` 查询公网搜不到的目标（内部系统）或用户访问过的页面，支持关键词/时间窗/访问频度排序 |\n| 并行分治 | 多目标时分发子 Agent 并行执行，共享一个 Proxy，tab 级隔离 |\n| 站点经验积累 | 按域名存储操作经验（URL 模式、平台特征、已知陷阱），跨 session 复用 |\n| 媒体提取 | 从 DOM 直取图片/视频 URL，或对视频任意时间点截帧分析 |\n\n**v2.5.0 更新：**\n- **本地 Chrome 资源检索** — 新增 `scripts/find-url.mjs`，从本地 Chrome 书签/历史按关键词/时间窗/访问频度定位 URL。典型场景：用户提到组织内部系统（\"我们的 XX 平台\"等公网搜不到的目标）、回查之前访问过但不记得地址的页面、查看最近高频访问网站等（场景感谢 @MVPGFC 在 #60 提出）\n\n<details><summary>v2.4.3 更新</summary>\n\n- **修复 CLAUDE_SKILL_DIR 路径问题** — bash 代码块改用 `${CLAUDE_SKILL_DIR}` 字符串替换语法，修复 Windows Git Bash 路径转换错误和变量未设置问题（#47 #46）\n- **站点经验列表合并到前置检查** — 启动检查通过后自动输出已有站点经验列表，移除不可靠的 `!` 内联注入\n</details>\n\n<details><summary>v2.4.1 更新</summary>\n\n- **跨平台支持** — 脚本从 bash 迁移到 Node.js，Windows / Linux / macOS 均可使用\n- **DOM 边界穿透** — 新增技术事实：eval 递归遍历可穿透 Shadow DOM、iframe 等选择器不可跨越的边界\n</details>\n\n<details><summary>v2.4 更新</summary>\n\n- **站点内 URL 可靠性** — 新增事实说明：站点生成的链接自带完整上下文，手动构造的 URL 可能缺失隐式必要参数\n- **平台错误提示不可信** — 新增技术事实：平台返回的\"内容不存在\"等提示可能是访问方式问题而非内容本身问题\n- **小红书站点经验增强** — xsec_token 机制、创作者平台状态校验、暂存草稿流程\n</details>\n\n<details><summary>v2.3 更新</summary>\n\n- **浏览哲学重构** — 更清晰的「像人一样思考」框架，强调目标驱动而非步骤驱动\n- **Jina 积极推荐** — 明确鼓励在合适场景主动使用 Jina 节省 token\n- **子 Agent prompt 指引优化** — 明确加载写法，增加避免动词暗示执行方式的说明\n</details>\n\n## 安装\n\n**方式一：npx skills 一键安装（推荐）**\n\n```bash\nnpx skills add eze-is/web-access\n```\n\n> [skills CLI](https://github.com/vercel-labs/skills) 是开源的 Agent Skill 包管理器，自动检测你的 Agent 环境并安装到正确位置。\n\n**方式二：让 Agent 自动安装**\n\n```\n帮我安装这个 skill：https://github.com/eze-is/web-access\n```\n\n**方式三：Plugin 安装（Claude Code）**\n\n```bash\nclaude plugin marketplace add https://github.com/eze-is/web-access\nclaude plugin install web-access@web-access --scope user\n```\n\n**方式四：手动**\n\n```bash\ngit clone https://github.com/eze-is/web-access ~/.claude/skills/web-access\n```\n\n## 前置配置（CDP 模式）\n\nCDP 模式需要 **Node.js 22+** 和 Chrome 开启远程调试：\n\n1. Chrome 地址栏打开 `chrome://inspect/#remote-debugging`\n2. 勾选 **Allow remote debugging for this browser instance**（可能需要重启浏览器）\n\n环境检查（Agent 运行时会自动完成前置检查，无需手动执行）：\n\n```bash\nnode \"${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs\"\n# $CLAUDE_SKILL_DIR 是 skill 加载时自动设置的环境变量\n# 手动运行请替换为实际路径，如 ~/.claude/skills/web-access\n```\n\n## CDP Proxy API\n\nProxy 通过 WebSocket 直连 Chrome（兼容 `chrome://inspect` 方式，无需命令行参数启动），提供 HTTP API：\n\n```bash\n# 启动（Agent 会自动管理 Proxy 生命周期，无需手动启动）\nnode \"${CLAUDE_SKILL_DIR}/scripts/cdp-proxy.mjs\" &\n\n# 页面操作\ncurl -s \"http://localhost:3456/new?url=https://example.com\"     # 新建 tab\ncurl -s -X POST \"http://localhost:3456/eval?target=ID\" -d 'document.title'  # 执行 JS\ncurl -s -X POST \"http://localhost:3456/click?target=ID\" -d 'button.submit'  # JS 点击\ncurl -s -X POST \"http://localhost:3456/clickAt?target=ID\" -d '.upload-btn'  # 真实鼠标点击\ncurl -s -X POST \"http://localhost:3456/setFiles?target=ID\" \\\n  -d '{\"selector\":\"input[type=file]\",\"files\":[\"/path/to/file.png\"]}'        # 文件上传\ncurl -s \"http://localhost:3456/screenshot?target=ID&file=/tmp/shot.png\"     # 截图\ncurl -s \"http://localhost:3456/scroll?target=ID&direction=bottom\"           # 滚动\ncurl -s \"http://localhost:3456/close?target=ID\"                             # 关闭 tab\n```\n\n## ⚠️ 使用前提醒\n\n通过浏览器自动化操作社交平台（如小红书）存在账号被平台限流或封禁的风险。**强烈建议使用小号进行操作。**\n\n## 使用\n\n安装后直接让 Agent 执行联网任务，skill 自动接管：\n\n- \"帮我搜索 xxx 最新进展\"\n- \"读一下这个页面：[URL]\"\n- \"去小红书搜索 xxx 的账号\"\n- \"帮我在创作者平台发一篇图文\"\n- \"同时调研这 5 个产品的官网，给我对比摘要\"\n\n## 设计哲学\n\n> Skill = 哲学 + 技术事实，不是操作手册。讲清 tradeoff 让 AI 自己选，不替它推理。\n\n详见 [SKILL.md](./SKILL.md) 中的浏览哲学部分。\n\n## License\n\nMIT · 作者：[一泽 Eze](https://github.com/eze-is) · [官网](https://web-access.eze.is)\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=eze-is/web-access&type=Date)](https://star-history.com/#eze-is/web-access&Date)\n\n<img width=\"1280\" height=\"306\" alt=\"image\" src=\"https://github.com/user-attachments/assets/2afa25c2-3730-413e-b40f-94e52567249d\" />\n"
  },
  {
    "path": "SKILL.md",
    "content": "---\nname: web-access\nlicense: MIT\ngithub: https://github.com/eze-is/web-access\ndescription:\n  所有联网操作必须通过此 skill 处理，包括：搜索、网页抓取、登录后操作、网络交互等。\n  触发场景：用户要求搜索信息、查看网页内容、访问需要登录的网站、操作网页界面、抓取社交媒体内容（小红书、微博、推特等）、读取动态渲染页面、以及任何需要真实浏览器环境的网络任务。\nmetadata:\n  author: 一泽Eze\n  version: \"2.5.0\"\n---\n\n# web-access Skill\n\n## 前置检查\n\n在开始联网操作前，先检查 CDP 模式可用性：\n\n```bash\nnode \"${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs\"\n```\n\n未通过时引导用户完成设置：\n- **Node.js 22+**：必需（使用原生 WebSocket）。版本低于 22 可用但需安装 `ws` 模块。\n- **Chrome remote-debugging**：在 Chrome 地址栏打开 `chrome://inspect/#remote-debugging`，勾选 **\"Allow remote debugging for this browser instance\"** 即可，可能需要重启浏览器。\n\n检查通过后并必须在回复中向用户直接展示以下须知，再启动 CDP Proxy 执行操作：\n\n```\n温馨提示：部分站点对浏览器自动化操作检测严格，存在账号封禁风险。已内置防护措施但无法完全避免，Agent 继续操作即视为接受。\n```\n\n## 浏览哲学\n\n**像人一样思考，兼顾高效与适应性的完成任务。**\n\n执行任务时不会过度依赖固有印象所规划的步骤，而是带着目标进入，边看边判断，遇到阻碍就解决，发现内容不够就深入——全程围绕「我要达成什么」做决策。这个 skill 的所有行为都应遵循这个逻辑。\n\n**① 拿到请求** — 先明确用户要做什么，定义成功标准：什么算完成了？需要获取什么信息、执行什么操作、达到什么结果？这是后续所有判断的锚点。\n\n**② 选择起点** — 根据任务性质、平台特征、达成条件，选一个最可能直达的方式作为第一步去验证。一次成功当然最好；不成功则在③中调整。比如，需要操作页面、需要登录态、已知静态方式不可达的平台（小红书、微信公众号等）→ 直接 CDP\n\n**③ 过程校验** — 每一步的结果都是证据，不只是成功或失败的二元信号。用结果对照①的成功标准，更新你对目标的判断：路径在推进吗？结果的整体面貌（质量、相关度、量级）是否指向目标可达？发现方向错了立即调整，不在同一个方式上反复重试——搜索没命中不等于\"还没找对方法\"，也可能是\"目标不存在\"；API 报错、页面缺少预期元素、重试无改善，都是在告诉你该重新评估方向。遇到弹窗、登录墙等障碍，判断它是否真的挡住了目标：挡住了就处理，没挡住就绕过——内容可能已在页面 DOM 中，交互只是展示手段。\n\n**④ 完成判断** — 对照定义的任务成功标准，确认任务完成后才停止，但也不要过度操作，不为了\"完整\"而浪费代价。\n\n## 联网工具选择\n\n- **确保信息的真实性，一手信息优于二手信息**：搜索引擎和聚合平台是信息发现入口。当多次搜索尝试后没有质的改进时，升级到更根本的获取方式：定位一手来源（官网、官方平台、原始页面）。\n\n| 场景 | 工具 |\n|------|------|\n| 搜索摘要或关键词结果，发现信息来源 | **WebSearch** |\n| URL 已知，需要从页面定向提取特定信息 | **WebFetch**（拉取网页内容，由小模型根据 prompt 提取，返回处理后结果） |\n| URL 已知，需要原始 HTML 源码（meta、JSON-LD 等结构化字段） | **curl** |\n| 非公开内容，或已知静态层无效的平台（小红书、微信公众号等公开内容也被反爬限制） | **浏览器 CDP**（直接，跳过静态层） |\n| 需要登录态、交互操作，或需要像人一样在浏览器内自由导航探索 | **浏览器 CDP** |\n\n浏览器 CDP 不要求 URL 已知——可从任意入口出发，通过页面内搜索、点击、跳转等方式找到目标内容。WebSearch、WebFetch、curl 均不处理登录态。\n\n**Jina**（可选预处理层，可与 WebFetch/curl 组合使用，由于其特性可节省 tokens 消耗，请积极在任务合适时组合使用）：第三方网络服务，可将网页转为 Markdown，大幅节省 token 但可能有信息损耗。调用方式为 `r.jina.ai/example.com`（URL 前加前缀，不保留原网址 http 前缀），限 20 RPM。适合文章、博客、文档、PDF 等以正文为核心的页面；对数据面板、商品页等非文章结构页面可能提取到错误区块。\n\n进入浏览器层后，`/eval` 就是你的眼睛和手：\n\n- **看**：用 `/eval` 查询 DOM，发现页面上的链接、按钮、表单、文本内容——相当于「看看这个页面有什么」\n- **做**：用 `/click` 点击元素、`/scroll` 滚动加载、`/eval` 填表提交——像人一样在页面内自然导航\n- **读**：用 `/eval` 提取文字内容，判断图片/视频是否承载核心信息——是则提取媒体 URL 定向读取或 `/screenshot` 视觉识别\n\n浏览网页时，**先了解页面结构，再决定下一步动作**。不需要提前规划所有步骤。\n\n### 补充：本地 Chrome 资源\n\n用户指向**本人访问过的页面**（\"我之前看的那个讲 X 的文章\"、\"上次打开过的 XX 面板\"）或**组织内部系统**（\"我们的 XX 平台\"、\"公司那个 YY 系统\"等公网搜不到的目标）时，检索本地 Chrome 书签/历史：\n\n```bash\nnode \"${CLAUDE_SKILL_DIR}/scripts/find-url.mjs\" [关键词...] [--only bookmarks|history] [--limit N] [--since 1d|7h|YYYY-MM-DD] [--sort recent|visits]\n```\n\n关键词空格分词、多词 AND，匹配 title + url（可省略）；`--since` / `--sort` 仅作用于历史；默认按最近访问倒序，`--sort visits` 按访问次数排序（适合\"高频访问的网站\"这类场景）。\n\n### 程序化操作与 GUI 交互\n\n浏览器内操作页面有两种方式：\n\n- **程序化方式**（构造 URL 直接导航、eval 操作 DOM）：成功时速度快、精确，但对网站来说不是正常用户行为，可能触发反爬机制。\n- **GUI 交互**（点击按钮、填写输入框、滚动浏览）：GUI 是为人设计的，网站不会限制正常的 UI 操作，确定性最高，但步骤多、速度慢。\n\n根据对目标平台的了解来灵活选择方式。GUI 交互也是程序化方式的有效探测——通过一次真实交互观察站点的实际行为（URL 模式、必需参数、页面跳转逻辑），为后续程序化操作提供依据；同时当程序化方式受阻时，GUI 交互是可靠的兜底。\n\n**站点内交互产生的链接是可靠的**：通过用户视角中的可交互单元（卡片、条目、按钮）进行的站点内交互，自然到达的 URL 天然携带平台所需的完整上下文。而手动构造的 URL 可能缺失隐式必要参数，导致被拦截、返回错误页面、甚至触发反爬。\n\n## 浏览器 CDP 模式\n\n通过 CDP Proxy 直连用户日常 Chrome，天然携带登录态，无需启动独立浏览器。\n若无用户明确要求，不主动操作用户已有 tab，所有操作都在自己创建的后台 tab 中进行，保持对用户环境的最小侵入。不关闭用户 tab 的前提下，完成任务后关闭自己创建的 tab，保持环境整洁。\n\n### 启动\n\n```bash\nnode \"${CLAUDE_SKILL_DIR}/scripts/check-deps.mjs\"\n```\n\n脚本会依次检查 Node.js、Chrome 端口，并确保 Proxy 已连接（未运行则自动启动并等待）。Proxy 启动后持续运行。\n\n### Proxy API\n\n所有操作通过 curl 调用 HTTP API：\n\n```bash\n# 列出用户已打开的 tab\ncurl -s http://localhost:3456/targets\n\n# 创建新后台 tab（自动等待加载）\ncurl -s \"http://localhost:3456/new?url=https://example.com\"\n\n# 页面信息\ncurl -s \"http://localhost:3456/info?target=ID\"\n\n# 执行任意 JS：可读写 DOM、提取数据、操控元素、触发状态变更、提交表单、调用内部方法\ncurl -s -X POST \"http://localhost:3456/eval?target=ID\" -d 'document.title'\n\n# 捕获页面渲染状态（含视频当前帧）\ncurl -s \"http://localhost:3456/screenshot?target=ID&file=/tmp/shot.png\"\n\n# 导航、后退\ncurl -s \"http://localhost:3456/navigate?target=ID&url=URL\"\ncurl -s \"http://localhost:3456/back?target=ID\"\n\n# 点击（POST body 为 CSS 选择器）— JS el.click()，简单快速，覆盖大多数场景\ncurl -s -X POST \"http://localhost:3456/click?target=ID\" -d 'button.submit'\n\n# 真实鼠标点击 — CDP Input.dispatchMouseEvent，算用户手势，能触发文件对话框\ncurl -s -X POST \"http://localhost:3456/clickAt?target=ID\" -d 'button.upload'\n\n# 文件上传 — 直接设置 file input 的本地文件路径，绕过文件对话框\ncurl -s -X POST \"http://localhost:3456/setFiles?target=ID\" -d '{\"selector\":\"input[type=file]\",\"files\":[\"/path/to/file.png\"]}'\n\n# 滚动（触发懒加载）\ncurl -s \"http://localhost:3456/scroll?target=ID&y=3000\"\ncurl -s \"http://localhost:3456/scroll?target=ID&direction=bottom\"\n\n# 关闭 tab\ncurl -s \"http://localhost:3456/close?target=ID\"\n```\n\n### 页面内导航\n\n两种方式打开页面内的链接：\n\n- **`/click`**：在当前 tab 内直接点击用户视角中的可交互单元，简单直接，串行处理。适合需要在同一页面内连续操作的场景，如点击展开、翻页、进入详情等。\n- **`/new` + 完整 URL**：使用目标链接的完整地址（包含所有URL参数），在新 tab 中打开。适合需要同时访问多个页面的场景。\n\n很多网站的链接包含会话相关的参数（如 token），这些参数是正常访问所必需的。提取 URL 时应保留完整地址，不要裁剪或省略参数。\n\n### 媒体资源提取\n\n判断内容在图片里时，用 `/eval` 从 DOM 直接拿图片 URL，再定向读取——比全页截图精准得多。\n\n### 技术事实\n- 页面中存在大量已加载但未展示的内容——轮播中非当前帧的图片、折叠区块的文字、懒加载占位元素等，它们存在于 DOM 中但对用户不可见。以数据结构（容器、属性、节点关系）为单位思考，可以直接触达这些内容。\n- DOM 中存在选择器不可跨越的边界（Shadow DOM 的 `shadowRoot`、iframe 的 `contentDocument`等）。eval 递归遍历可一次穿透所有层级，返回带标签的结构化内容，适合快速了解未知页面的完整结构。\n- `/scroll` 到底部会触发懒加载，使未进入视口的图片完成加载。提取图片 URL 前若未滚动，部分图片可能尚未加载。\n- 拿到媒体资源 URL 后，公开资源可直接下载到本地后用读取；需要登录态才可获取的资源才需要在浏览器内 navigate + screenshot。\n- 短时间内密集打开大量页面（如批量 `/new`）可能触发网站的反爬风控。\n- 平台返回的\"内容不存在\"\"页面不见了\"等提示不一定反映真实状态，也可能是访问方式的问题（如 URL 缺失必要参数、触发反爬）而非内容本身的问题。\n\n### 视频内容获取\n\n用户 Chrome 真实渲染，截图可捕获当前视频帧。核心能力：通过 `/eval` 操控 `<video>` 元素（获取时长、seek 到任意时间点、播放/暂停/全屏），配合 `/screenshot` 采帧，可对视频内容进行离散采样分析。\n\n### 登录判断\n\n用户日常 Chrome 天然携带登录态，大多数常用网站已登录。\n\n登录判断的核心问题只有一个：**目标内容拿到了吗？**\n\n打开页面后先尝试获取目标内容。只有当确认**目标内容无法获取**且判断登录能解决时，才告知用户：\n> \"当前页面在未登录状态下无法获取[具体内容]，请在你的 Chrome 中登录 [网站名]，完成后告诉我继续。\"\n\n登录完成后无需重启任何东西，直接刷新页面继续。\n\n### 任务结束\n\n用 `/close` 关闭自己创建的 tab，必须保留用户原有的 tab 不受影响。\n\nProxy 持续运行，不建议主动停止——重启后需要在 Chrome 中重新授权 CDP 连接。\n\n## 并行调研：子 Agent 分治策略\n\n任务包含多个**独立**调研目标时（如同时调研 N 个项目、N 个来源），鼓励合理分治给子 Agent 并行执行，而非主 Agent 串行处理。\n\n**好处：**\n- **速度**：多子 Agent 并行，总耗时约等于单个子任务时长\n- **上下文保护**：抓取内容不进入主 Agent 上下文，主 Agent 只接收摘要，节省 token\n\n**并行 CDP 操作**：每个子 Agent 在当前用户浏览器实例中，自行创建所需的后台 tab（`/new`），自行操作，任务结束自行关闭（`/close`）。所有子 Agent 共享一个 Chrome、一个 Proxy，通过不同 targetId 操作不同 tab，无竞态风险。\n\n**子 Agent Prompt 写法：目标导向，而非步骤指令**\n- 必须在子 Agent prompt 中写 `必须加载 web-access skill 并遵循指引` ，子 Agent 会自动加载 skill，无需在 prompt 中复制 skill 内容或指定路径。\n- 子 Agent 有自主判断能力。主 Agent 的职责是说清楚**要什么**，仅在必要与确信时限定**怎么做**。过度指定步骤会剥夺子 Agent 的判断空间，反而引入主 Agent 的假设错误。**避免 prompt 用词对子 Agent 行为的暗示**：「搜索xx」会把子 Agent 锚定到 WebSearch，而实际上有些反爬站点需要 CDP 直接访问主站才能有效获取内容。主 Agent 写 prompt 时应描述目标（「获取」「调研」「了解」），避免用暗示具体手段的动词（「搜索」「抓取」「爬取」）。\n\n**分治判断标准：**\n\n| 适合分治 | 不适合分治 |\n|----------|-----------|\n| 目标相互独立，结果互不依赖 | 目标有依赖关系，下一个需要上一个的结果 |\n| 每个子任务量足够大（多页抓取、多轮搜索） | 简单单页查询，分治开销大于收益 |\n| 需要 CDP 浏览器或长时间运行的任务 | 几次 WebSearch / Jina 就能完成的轻量查询 |\n\n## 信息核实类任务\n\n核实的目标是**一手来源**，而非更多的二手报道。多个媒体引用同一个错误会造成循环印证假象。\n\n搜索引擎和聚合平台是信息发现入口，是**定位**信息的工具，不可用于直接**证明**真伪。找到来源后，直接访问读取原文。同一原则适用于工具能力/用法的调研——官方文档是一手来源，不确定时先查文档或源码，不猜测。\n\n| 信息类型 | 一手来源 |\n|----------|---------|\n| 政策/法规 | 发布机构官网 |\n| 企业公告 | 公司官方新闻页 |\n| 学术声明 | 原始论文/机构官网 |\n| 工具能力/用法 | 官方文档、源码 |\n\n**找不到官网时**：权威媒体的原创报道（非转载）可作为次级依据，但需向用户说明：\"未找到官方原文，以下核实来自[媒体名]报道，存在转述误差可能。\"单一来源时同样向用户声明。\n\n## 站点经验\n\n操作中积累的特定网站经验，按域名存储在 `references/site-patterns/` 下。\n\n确定目标网站后，如果前置检查输出的 site-patterns 列表中有匹配的站点，必须读取对应文件获取先验知识（平台特征、有效模式、已知陷阱）。经验内容标注了发现日期，当作可能有效的提示而非保证——如果按经验操作失败，回退通用模式并更新经验文件。\n\nCDP 操作成功完成后，如果发现了有必要记录经验的新站点或新模式（URL 结构、平台特征、操作策略），主动写入对应的站点经验文件。只写经过验证的事实，不写未确认的猜测。\n\n文件格式：\n```markdown\n---\ndomain: example.com\naliases: [示例, Example]\nupdated: 2026-03-19\n---\n## 平台特征\n架构、反爬行为、登录需求、内容加载方式等事实\n\n## 有效模式\n已验证的 URL 模式、操作策略、选择器\n\n## 已知陷阱\n什么会失败以及为什么\n```\n经验/陷阱内容标注发现日期，当作\"可能有效的提示\"而非\"保证正确的事实\"。\n\n## References 索引\n\n| 文件 | 何时加载 |\n|------|---------|\n| `references/cdp-api.md` | 需要 CDP API 详细参考、JS 提取模式、错误处理时 |\n| `references/site-patterns/{domain}.md` | 确定目标网站后，读取对应站点经验 |\n"
  },
  {
    "path": "references/cdp-api.md",
    "content": "# CDP Proxy API 参考\n\n## 基础信息\n\n- 地址：`http://localhost:3456`\n- 启动：`node ~/.claude/skills/web-access/scripts/cdp-proxy.mjs &`\n- 启动后持续运行，不建议主动停止（重启需 Chrome 重新授权）\n- 强制停止：`pkill -f cdp-proxy.mjs`\n\n## API 端点\n\n### GET /health\n健康检查，返回连接状态。\n```bash\ncurl -s http://localhost:3456/health\n```\n\n### GET /targets\n列出所有已打开的页面 tab。返回数组，每项含 `targetId`、`title`、`url`。\n```bash\ncurl -s http://localhost:3456/targets\n```\n\n### GET /new?url=URL\n创建新后台 tab，自动等待页面加载完成。返回 `{ targetId }`.\n```bash\ncurl -s \"http://localhost:3456/new?url=https://example.com\"\n```\n\n### GET /close?target=ID\n关闭指定 tab。\n```bash\ncurl -s \"http://localhost:3456/close?target=TARGET_ID\"\n```\n\n### GET /navigate?target=ID&url=URL\n在已有 tab 中导航到新 URL，自动等待加载。\n```bash\ncurl -s \"http://localhost:3456/navigate?target=ID&url=https://example.com\"\n```\n\n### GET /back?target=ID\n后退一页。\n```bash\ncurl -s \"http://localhost:3456/back?target=ID\"\n```\n\n### GET /info?target=ID\n获取页面基础信息（title、url、readyState）。\n```bash\ncurl -s \"http://localhost:3456/info?target=ID\"\n```\n\n### POST /eval?target=ID\n执行 JavaScript 表达式，POST body 为 JS 代码。\n```bash\ncurl -s -X POST \"http://localhost:3456/eval?target=ID\" -d 'document.title'\n```\n\n### POST /click?target=ID\nJS 层面点击（`el.click()`），POST body 为 CSS 选择器。自动 scrollIntoView 后点击。简单快速，覆盖大多数场景。\n```bash\ncurl -s -X POST \"http://localhost:3456/click?target=ID\" -d 'button.submit'\n```\n\n### POST /clickAt?target=ID\nCDP 浏览器级真实鼠标点击（`Input.dispatchMouseEvent`），POST body 为 CSS 选择器。先获取元素坐标，再模拟鼠标按下/释放。算真实用户手势，能触发文件对话框、绕过部分反自动化检测。\n```bash\ncurl -s -X POST \"http://localhost:3456/clickAt?target=ID\" -d 'button.upload'\n```\n\n### POST /setFiles?target=ID\n给 file input 设置本地文件路径（`DOM.setFileInputFiles`），完全绕过文件对话框。POST body 为 JSON。\n```bash\ncurl -s -X POST \"http://localhost:3456/setFiles?target=ID\" -d '{\"selector\":\"input[type=file]\",\"files\":[\"/path/to/file1.png\",\"/path/to/file2.png\"]}'\n```\n\n### GET /scroll?target=ID&y=3000&direction=down\n滚动页面。`direction` 可选 `down`（默认）、`up`、`top`、`bottom`。滚动后自动等待 800ms 供懒加载触发。\n```bash\ncurl -s \"http://localhost:3456/scroll?target=ID&y=3000\"\ncurl -s \"http://localhost:3456/scroll?target=ID&direction=bottom\"\n```\n\n### GET /screenshot?target=ID&file=/tmp/shot.png\n截图。指定 `file` 参数保存到本地文件；不指定则返回图片二进制。可选 `format=jpeg`。\n```bash\ncurl -s \"http://localhost:3456/screenshot?target=ID&file=/tmp/shot.png\"\n```\n\n## /eval 使用提示\n\n- POST body 为任意 JS 表达式，返回 `{ value }` 或 `{ error }`\n- 支持 `awaitPromise`：可以写 async 表达式\n- 返回值必须是可序列化的（字符串、数字、对象），DOM 节点不能直接返回，需要提取属性\n- 提取大量数据时用 `JSON.stringify()` 包裹，确保返回字符串\n- 根据页面实际 DOM 结构编写选择器，不要套用固定模板\n\n## 错误处理\n\n| 错误 | 原因 | 解决 |\n|------|------|------|\n| `Chrome 未开启远程调试端口` | Chrome 未开启远程调试 | 提示用户打开 `chrome://inspect/#remote-debugging` 并勾选 Allow |\n| `attach 失败` | targetId 无效或 tab 已关闭 | 用 `/targets` 获取最新列表 |\n| `CDP 命令超时` | 页面长时间未响应 | 重试或检查 tab 状态 |\n| `端口已被占用` | 另一个 proxy 已在运行 | 已有实例可直接复用 |\n"
  },
  {
    "path": "references/site-patterns/.gitkeep",
    "content": ""
  },
  {
    "path": "scripts/cdp-proxy.mjs",
    "content": "#!/usr/bin/env node\n// CDP Proxy - 通过 HTTP API 操控用户日常 Chrome\n// 要求：Chrome 已开启 --remote-debugging-port\n// Node.js 22+（使用原生 WebSocket）\n\nimport http from 'node:http';\nimport { URL } from 'node:url';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport os from 'node:os';\nimport net from 'node:net';\n\nconst PORT = parseInt(process.env.CDP_PROXY_PORT || '3456');\nlet ws = null;\nlet cmdId = 0;\nconst pending = new Map(); // id -> {resolve, timer}\nconst sessions = new Map(); // targetId -> sessionId\n\n// --- WebSocket 兼容层 ---\nlet WS;\nif (typeof globalThis.WebSocket !== 'undefined') {\n  // Node 22+ 原生 WebSocket（浏览器兼容 API）\n  WS = globalThis.WebSocket;\n} else {\n  // 回退到 ws 模块\n  try {\n    WS = (await import('ws')).default;\n  } catch {\n    console.error('[CDP Proxy] 错误：Node.js 版本 < 22 且未安装 ws 模块');\n    console.error('  解决方案：升级到 Node.js 22+ 或执行 npm install -g ws');\n    process.exit(1);\n  }\n}\n\n// --- 自动发现 Chrome 调试端口 ---\nasync function discoverChromePort() {\n  // 1. 尝试读 DevToolsActivePort 文件\n  const possiblePaths = [];\n  const platform = os.platform();\n\n  if (platform === 'darwin') {\n    const home = os.homedir();\n    possiblePaths.push(\n      path.join(home, 'Library/Application Support/Google/Chrome/DevToolsActivePort'),\n      path.join(home, 'Library/Application Support/Google/Chrome Canary/DevToolsActivePort'),\n      path.join(home, 'Library/Application Support/Chromium/DevToolsActivePort'),\n    );\n  } else if (platform === 'linux') {\n    const home = os.homedir();\n    possiblePaths.push(\n      path.join(home, '.config/google-chrome/DevToolsActivePort'),\n      path.join(home, '.config/chromium/DevToolsActivePort'),\n    );\n  } else if (platform === 'win32') {\n    const localAppData = process.env.LOCALAPPDATA || '';\n    possiblePaths.push(\n      path.join(localAppData, 'Google/Chrome/User Data/DevToolsActivePort'),\n      path.join(localAppData, 'Chromium/User Data/DevToolsActivePort'),\n    );\n  }\n\n  for (const p of possiblePaths) {\n    try {\n      const content = fs.readFileSync(p, 'utf-8').trim();\n      const lines = content.split('\\n');\n      const port = parseInt(lines[0]);\n      if (port > 0 && port < 65536) {\n        const ok = await checkPort(port);\n        if (ok) {\n          // 第二行是带 UUID 的 WebSocket 路径（如 /devtools/browser/xxx-xxx）\n          // 非显式 --remote-debugging-port 启动时，Chrome 可能只接受此路径\n          const wsPath = lines[1] || null;\n          console.log(`[CDP Proxy] 从 DevToolsActivePort 发现端口: ${port}${wsPath ? ' (带 wsPath)' : ''}`);\n          return { port, wsPath };\n        }\n      }\n    } catch { /* 文件不存在，继续 */ }\n  }\n\n  // 2. 扫描常用端口\n  const commonPorts = [9222, 9229, 9333];\n  for (const port of commonPorts) {\n    const ok = await checkPort(port);\n    if (ok) {\n      console.log(`[CDP Proxy] 扫描发现 Chrome 调试端口: ${port}`);\n      return { port, wsPath: null };\n    }\n  }\n\n  return null;\n}\n\n// 用 TCP 探测端口是否监听——避免 WebSocket 连接触发 Chrome 安全弹窗\n// （WebSocket 探测会被 Chrome 视为调试连接，弹出授权对话框）\nfunction checkPort(port) {\n  return new Promise((resolve) => {\n    const socket = net.createConnection(port, '127.0.0.1');\n    const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 2000);\n    socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); });\n    socket.once('error', () => { clearTimeout(timer); resolve(false); });\n  });\n}\n\nfunction getWebSocketUrl(port, wsPath) {\n  if (wsPath) return `ws://127.0.0.1:${port}${wsPath}`;\n  return `ws://127.0.0.1:${port}/devtools/browser`;\n}\n\n// --- WebSocket 连接管理 ---\nlet chromePort = null;\nlet chromeWsPath = null;\n\nlet connectingPromise = null;\nasync function connect() {\n  if (ws && (ws.readyState === WS.OPEN || ws.readyState === 1)) return;\n  if (connectingPromise) return connectingPromise;  // 复用进行中的连接\n\n  if (!chromePort) {\n    const discovered = await discoverChromePort();\n    if (!discovered) {\n      throw new Error(\n        'Chrome 未开启远程调试端口。请用以下方式启动 Chrome：\\n' +\n        '  macOS: /Applications/Google\\\\ Chrome.app/Contents/MacOS/Google\\\\ Chrome --remote-debugging-port=9222\\n' +\n        '  Linux: google-chrome --remote-debugging-port=9222\\n' +\n        '  或在 chrome://flags 中搜索 \"remote debugging\" 并启用'\n      );\n    }\n    chromePort = discovered.port;\n    chromeWsPath = discovered.wsPath;\n  }\n\n  const wsUrl = getWebSocketUrl(chromePort, chromeWsPath);\n  if (!wsUrl) throw new Error('无法获取 Chrome WebSocket URL');\n\n  return connectingPromise = new Promise((resolve, reject) => {\n    ws = new WS(wsUrl);\n\n    const onOpen = () => {\n      cleanup();\n      connectingPromise = null;\n      console.log(`[CDP Proxy] 已连接 Chrome (端口 ${chromePort})`);\n      resolve();\n    };\n    const onError = (e) => {\n      cleanup();\n      connectingPromise = null;\n      ws = null;\n      chromePort = null;\n      chromeWsPath = null;\n      const msg = e.message || e.error?.message || '连接失败';\n      console.error('[CDP Proxy] 连接错误:', msg, '（端口缓存已清除，下次将重新发现）');\n      reject(new Error(msg));\n    };\n    const onClose = () => {\n      console.log('[CDP Proxy] 连接断开');\n      ws = null;\n      chromePort = null; // 重置端口缓存，下次连接重新发现\n      chromeWsPath = null;\n      sessions.clear();\n    };\n    const onMessage = (evt) => {\n      const data = typeof evt === 'string' ? evt : (evt.data || evt);\n      const msg = JSON.parse(typeof data === 'string' ? data : data.toString());\n\n      if (msg.method === 'Target.attachedToTarget') {\n        const { sessionId, targetInfo } = msg.params;\n        sessions.set(targetInfo.targetId, sessionId);\n      }\n      // 拦截页面对 Chrome 调试端口的探测请求（反风控）\n      if (msg.method === 'Fetch.requestPaused') {\n        const { requestId, sessionId: sid } = msg.params;\n        sendCDP('Fetch.failRequest', { requestId, errorReason: 'ConnectionRefused' }, sid).catch(() => {});\n      }\n      if (msg.id && pending.has(msg.id)) {\n        const { resolve, timer } = pending.get(msg.id);\n        clearTimeout(timer);\n        pending.delete(msg.id);\n        resolve(msg);\n      }\n    };\n\n    function cleanup() {\n      ws.removeEventListener?.('open', onOpen);\n      ws.removeEventListener?.('error', onError);\n    }\n\n    // 兼容 Node 原生 WebSocket 和 ws 模块的事件 API\n    if (ws.on) {\n      ws.on('open', onOpen);\n      ws.on('error', onError);\n      ws.on('close', onClose);\n      ws.on('message', onMessage);\n    } else {\n      ws.addEventListener('open', onOpen);\n      ws.addEventListener('error', onError);\n      ws.addEventListener('close', onClose);\n      ws.addEventListener('message', onMessage);\n    }\n  });\n}\n\nfunction sendCDP(method, params = {}, sessionId = null) {\n  return new Promise((resolve, reject) => {\n    if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) {\n      return reject(new Error('WebSocket 未连接'));\n    }\n    const id = ++cmdId;\n    const msg = { id, method, params };\n    if (sessionId) msg.sessionId = sessionId;\n    const timer = setTimeout(() => {\n      pending.delete(id);\n      reject(new Error('CDP 命令超时: ' + method));\n    }, 30000);\n    pending.set(id, { resolve, timer });\n    ws.send(JSON.stringify(msg));\n  });\n}\n\n// 已启用端口拦截的 session 集合（避免重复启用）\nconst portGuardedSessions = new Set();\n\nasync function ensureSession(targetId) {\n  if (sessions.has(targetId)) return sessions.get(targetId);\n  const resp = await sendCDP('Target.attachToTarget', { targetId, flatten: true });\n  if (resp.result?.sessionId) {\n    const sid = resp.result.sessionId;\n    sessions.set(targetId, sid);\n    // 启用调试端口探测拦截\n    await enablePortGuard(sid);\n    return sid;\n  }\n  throw new Error('attach 失败: ' + JSON.stringify(resp.error));\n}\n\n// 拦截页面对 Chrome 调试端口的探测（反风控）\n// 只拦截 127.0.0.1:{chromePort} 的请求，不影响其他任何本地服务\nasync function enablePortGuard(sessionId) {\n  if (!chromePort || portGuardedSessions.has(sessionId)) return;\n  try {\n    await sendCDP('Fetch.enable', {\n      patterns: [\n        { urlPattern: `http://127.0.0.1:${chromePort}/*`, requestStage: 'Request' },\n        { urlPattern: `http://localhost:${chromePort}/*`, requestStage: 'Request' },\n      ]\n    }, sessionId);\n    portGuardedSessions.add(sessionId);\n  } catch { /* Fetch 域启用失败不影响主流程 */ }\n}\n\n// --- 等待页面加载 ---\nasync function waitForLoad(sessionId, timeoutMs = 15000) {\n  // 启用 Page 域\n  await sendCDP('Page.enable', {}, sessionId);\n\n  return new Promise((resolve) => {\n    let resolved = false;\n    const done = (result) => {\n      if (resolved) return;\n      resolved = true;\n      clearTimeout(timer);\n      clearInterval(checkInterval);\n      resolve(result);\n    };\n\n    const timer = setTimeout(() => done('timeout'), timeoutMs);\n    const checkInterval = setInterval(async () => {\n      try {\n        const resp = await sendCDP('Runtime.evaluate', {\n          expression: 'document.readyState',\n          returnByValue: true,\n        }, sessionId);\n        if (resp.result?.result?.value === 'complete') {\n          done('complete');\n        }\n      } catch { /* 忽略 */ }\n    }, 500);\n  });\n}\n\n// --- 读取 POST body ---\nasync function readBody(req) {\n  let body = '';\n  for await (const chunk of req) body += chunk;\n  return body;\n}\n\n// --- HTTP API ---\nconst server = http.createServer(async (req, res) => {\n  const parsed = new URL(req.url, `http://localhost:${PORT}`);\n  const pathname = parsed.pathname;\n  const q = Object.fromEntries(parsed.searchParams);\n\n  res.setHeader('Content-Type', 'application/json; charset=utf-8');\n\n  try {\n    // /health 不需要连接 Chrome\n    if (pathname === '/health') {\n      const connected = ws && (ws.readyState === WS.OPEN || ws.readyState === 1);\n      res.end(JSON.stringify({ status: 'ok', connected, sessions: sessions.size, chromePort }));\n      return;\n    }\n\n    await connect();\n\n    // GET /targets - 列出所有页面\n    if (pathname === '/targets') {\n      const resp = await sendCDP('Target.getTargets');\n      const pages = resp.result.targetInfos.filter(t => t.type === 'page');\n      res.end(JSON.stringify(pages, null, 2));\n    }\n\n    // GET /new?url=xxx - 创建新后台 tab\n    else if (pathname === '/new') {\n      const targetUrl = q.url || 'about:blank';\n      const resp = await sendCDP('Target.createTarget', { url: targetUrl, background: true });\n      const targetId = resp.result.targetId;\n\n      // 等待页面加载\n      if (targetUrl !== 'about:blank') {\n        try {\n          const sid = await ensureSession(targetId);\n          await waitForLoad(sid);\n        } catch { /* 非致命，继续 */ }\n      }\n\n      res.end(JSON.stringify({ targetId }));\n    }\n\n    // GET /close?target=xxx - 关闭 tab\n    else if (pathname === '/close') {\n      const resp = await sendCDP('Target.closeTarget', { targetId: q.target });\n      sessions.delete(q.target);\n      res.end(JSON.stringify(resp.result));\n    }\n\n    // GET /navigate?target=xxx&url=yyy - 导航（自动等待加载）\n    else if (pathname === '/navigate') {\n      const sid = await ensureSession(q.target);\n      const resp = await sendCDP('Page.navigate', { url: q.url }, sid);\n\n      // 等待页面加载完成\n      await waitForLoad(sid);\n\n      res.end(JSON.stringify(resp.result));\n    }\n\n    // GET /back?target=xxx - 后退\n    else if (pathname === '/back') {\n      const sid = await ensureSession(q.target);\n      await sendCDP('Runtime.evaluate', { expression: 'history.back()' }, sid);\n      await waitForLoad(sid);\n      res.end(JSON.stringify({ ok: true }));\n    }\n\n    // POST /eval?target=xxx - 执行 JS\n    else if (pathname === '/eval') {\n      const sid = await ensureSession(q.target);\n      const body = await readBody(req);\n      const expr = body || q.expr || 'document.title';\n      const resp = await sendCDP('Runtime.evaluate', {\n        expression: expr,\n        returnByValue: true,\n        awaitPromise: true,\n      }, sid);\n      if (resp.result?.result?.value !== undefined) {\n        res.end(JSON.stringify({ value: resp.result.result.value }));\n      } else if (resp.result?.exceptionDetails) {\n        res.statusCode = 400;\n        res.end(JSON.stringify({ error: resp.result.exceptionDetails.text }));\n      } else {\n        res.end(JSON.stringify(resp.result));\n      }\n    }\n\n    // POST /click?target=xxx - 点击（body 为 CSS 选择器）\n    // POST /click?target=xxx — JS 层面点击（简单快速，覆盖大多数场景）\n    else if (pathname === '/click') {\n      const sid = await ensureSession(q.target);\n      const selector = await readBody(req);\n      if (!selector) {\n        res.statusCode = 400;\n        res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' }));\n        return;\n      }\n      const selectorJson = JSON.stringify(selector);\n      const js = `(() => {\n        const el = document.querySelector(${selectorJson});\n        if (!el) return { error: '未找到元素: ' + ${selectorJson} };\n        el.scrollIntoView({ block: 'center' });\n        el.click();\n        return { clicked: true, tag: el.tagName, text: (el.textContent || '').slice(0, 100) };\n      })()`;\n      const resp = await sendCDP('Runtime.evaluate', {\n        expression: js,\n        returnByValue: true,\n        awaitPromise: true,\n      }, sid);\n      if (resp.result?.result?.value) {\n        const val = resp.result.result.value;\n        if (val.error) {\n          res.statusCode = 400;\n          res.end(JSON.stringify(val));\n        } else {\n          res.end(JSON.stringify(val));\n        }\n      } else {\n        res.end(JSON.stringify(resp.result));\n      }\n    }\n\n    // POST /clickAt?target=xxx — CDP 浏览器级真实鼠标点击（算用户手势，能触发文件对话框、绕过反自动化检测）\n    else if (pathname === '/clickAt') {\n      const sid = await ensureSession(q.target);\n      const selector = await readBody(req);\n      if (!selector) {\n        res.statusCode = 400;\n        res.end(JSON.stringify({ error: 'POST body 需要 CSS 选择器' }));\n        return;\n      }\n      const selectorJson = JSON.stringify(selector);\n      const js = `(() => {\n        const el = document.querySelector(${selectorJson});\n        if (!el) return { error: '未找到元素: ' + ${selectorJson} };\n        el.scrollIntoView({ block: 'center' });\n        const rect = el.getBoundingClientRect();\n        return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, tag: el.tagName, text: (el.textContent || '').slice(0, 100) };\n      })()`;\n      const coordResp = await sendCDP('Runtime.evaluate', {\n        expression: js,\n        returnByValue: true,\n        awaitPromise: true,\n      }, sid);\n      const coord = coordResp.result?.result?.value;\n      if (!coord || coord.error) {\n        res.statusCode = 400;\n        res.end(JSON.stringify(coord || coordResp.result));\n        return;\n      }\n      await sendCDP('Input.dispatchMouseEvent', {\n        type: 'mousePressed', x: coord.x, y: coord.y, button: 'left', clickCount: 1\n      }, sid);\n      await sendCDP('Input.dispatchMouseEvent', {\n        type: 'mouseReleased', x: coord.x, y: coord.y, button: 'left', clickCount: 1\n      }, sid);\n      res.end(JSON.stringify({ clicked: true, x: coord.x, y: coord.y, tag: coord.tag, text: coord.text }));\n    }\n\n    // POST /setFiles?target=xxx — 给 file input 设置本地文件（绕过文件对话框）\n    // body: JSON { \"selector\": \"input[type=file]\", \"files\": [\"/path/to/file1.png\", \"/path/to/file2.png\"] }\n    else if (pathname === '/setFiles') {\n      const sid = await ensureSession(q.target);\n      const body = JSON.parse(await readBody(req));\n      if (!body.selector || !body.files) {\n        res.statusCode = 400;\n        res.end(JSON.stringify({ error: '需要 selector 和 files 字段' }));\n        return;\n      }\n      // 获取 DOM 节点\n      await sendCDP('DOM.enable', {}, sid);\n      const doc = await sendCDP('DOM.getDocument', {}, sid);\n      const node = await sendCDP('DOM.querySelector', {\n        nodeId: doc.result.root.nodeId,\n        selector: body.selector\n      }, sid);\n      if (!node.result?.nodeId) {\n        res.statusCode = 400;\n        res.end(JSON.stringify({ error: '未找到元素: ' + body.selector }));\n        return;\n      }\n      // 设置文件\n      await sendCDP('DOM.setFileInputFiles', {\n        nodeId: node.result.nodeId,\n        files: body.files\n      }, sid);\n      res.end(JSON.stringify({ success: true, files: body.files.length }));\n    }\n\n    // GET /scroll?target=xxx&y=3000 - 滚动\n    else if (pathname === '/scroll') {\n      const sid = await ensureSession(q.target);\n      const y = parseInt(q.y || '3000');\n      const direction = q.direction || 'down'; // down | up | top | bottom\n      let js;\n      if (direction === 'top') {\n        js = 'window.scrollTo(0, 0); \"scrolled to top\"';\n      } else if (direction === 'bottom') {\n        js = 'window.scrollTo(0, document.body.scrollHeight); \"scrolled to bottom\"';\n      } else if (direction === 'up') {\n        js = `window.scrollBy(0, -${Math.abs(y)}); \"scrolled up ${Math.abs(y)}px\"`;\n      } else {\n        js = `window.scrollBy(0, ${Math.abs(y)}); \"scrolled down ${Math.abs(y)}px\"`;\n      }\n      const resp = await sendCDP('Runtime.evaluate', {\n        expression: js,\n        returnByValue: true,\n      }, sid);\n      // 等待懒加载触发\n      await new Promise(r => setTimeout(r, 800));\n      res.end(JSON.stringify({ value: resp.result?.result?.value }));\n    }\n\n    // GET /screenshot?target=xxx&file=/tmp/x.png - 截图\n    else if (pathname === '/screenshot') {\n      const sid = await ensureSession(q.target);\n      const format = q.format || 'png';\n      const resp = await sendCDP('Page.captureScreenshot', {\n        format,\n        quality: format === 'jpeg' ? 80 : undefined,\n      }, sid);\n      if (q.file) {\n        fs.writeFileSync(q.file, Buffer.from(resp.result.data, 'base64'));\n        res.end(JSON.stringify({ saved: q.file }));\n      } else {\n        res.setHeader('Content-Type', 'image/' + format);\n        res.end(Buffer.from(resp.result.data, 'base64'));\n      }\n    }\n\n    // GET /info?target=xxx - 获取页面信息\n    else if (pathname === '/info') {\n      const sid = await ensureSession(q.target);\n      const resp = await sendCDP('Runtime.evaluate', {\n        expression: 'JSON.stringify({title: document.title, url: location.href, ready: document.readyState})',\n        returnByValue: true,\n      }, sid);\n      res.end(resp.result?.result?.value || '{}');\n    }\n\n    else {\n      res.statusCode = 404;\n      res.end(JSON.stringify({\n        error: '未知端点',\n        endpoints: {\n          '/health': 'GET - 健康检查',\n          '/targets': 'GET - 列出所有页面 tab',\n          '/new?url=': 'GET - 创建新后台 tab（自动等待加载）',\n          '/close?target=': 'GET - 关闭 tab',\n          '/navigate?target=&url=': 'GET - 导航（自动等待加载）',\n          '/back?target=': 'GET - 后退',\n          '/info?target=': 'GET - 页面标题/URL/状态',\n          '/eval?target=': 'POST body=JS表达式 - 执行 JS',\n          '/click?target=': 'POST body=CSS选择器 - 点击元素',\n          '/scroll?target=&y=&direction=': 'GET - 滚动页面',\n          '/screenshot?target=&file=': 'GET - 截图',\n        },\n      }));\n    }\n  } catch (e) {\n    res.statusCode = 500;\n    res.end(JSON.stringify({ error: e.message }));\n  }\n});\n\n// 检查端口是否被占用\nfunction checkPortAvailable(port) {\n  return new Promise((resolve) => {\n    const s = net.createServer();\n    s.once('error', () => resolve(false));\n    s.once('listening', () => { s.close(); resolve(true); });\n    s.listen(port, '127.0.0.1');\n  });\n}\n\nasync function main() {\n  // 检查是否已有 proxy 在运行\n  const available = await checkPortAvailable(PORT);\n  if (!available) {\n    // 验证已有实例是否健康\n    try {\n      const ok = await new Promise((resolve) => {\n        http.get(`http://127.0.0.1:${PORT}/health`, { timeout: 2000 }, (res) => {\n          let d = '';\n          res.on('data', c => d += c);\n          res.on('end', () => resolve(d.includes('\"ok\"')));\n        }).on('error', () => resolve(false));\n      });\n      if (ok) {\n        console.log(`[CDP Proxy] 已有实例运行在端口 ${PORT}，退出`);\n        process.exit(0);\n      }\n    } catch { /* 端口占用但非 proxy，继续报错 */ }\n    console.error(`[CDP Proxy] 端口 ${PORT} 已被占用`);\n    process.exit(1);\n  }\n\n  server.listen(PORT, '127.0.0.1', () => {\n    console.log(`[CDP Proxy] 运行在 http://localhost:${PORT}`);\n    // 启动时尝试连接 Chrome（非阻塞）\n    connect().catch(e => console.error('[CDP Proxy] 初始连接失败:', e.message, '（将在首次请求时重试）'));\n  });\n}\n\n// 防止未捕获异常导致进程崩溃\nprocess.on('uncaughtException', (e) => {\n  console.error('[CDP Proxy] 未捕获异常:', e.message);\n});\nprocess.on('unhandledRejection', (e) => {\n  console.error('[CDP Proxy] 未处理拒绝:', e?.message || e);\n});\n\nmain();\n"
  },
  {
    "path": "scripts/check-deps.mjs",
    "content": "#!/usr/bin/env node\n// 环境检查 + 确保 CDP Proxy 就绪（跨平台，替代 check-deps.sh）\n\nimport { spawn } from 'node:child_process';\nimport fs from 'node:fs';\nimport net from 'node:net';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');\nconst PROXY_SCRIPT = path.join(ROOT, 'scripts', 'cdp-proxy.mjs');\nconst PROXY_PORT = Number(process.env.CDP_PROXY_PORT || 3456);\n\n// --- Node.js 版本检查 ---\n\nfunction checkNode() {\n  const major = Number(process.versions.node.split('.')[0]);\n  const version = `v${process.versions.node}`;\n  if (major >= 22) {\n    console.log(`node: ok (${version})`);\n  } else {\n    console.log(`node: warn (${version}, 建议升级到 22+)`);\n  }\n}\n\n// --- TCP 端口探测 ---\n\nfunction checkPort(port, host = '127.0.0.1', timeoutMs = 2000) {\n  return new Promise((resolve) => {\n    const socket = net.createConnection(port, host);\n    const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs);\n    socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); });\n    socket.once('error', () => { clearTimeout(timer); resolve(false); });\n  });\n}\n\n// --- Chrome 调试端口检测（DevToolsActivePort 多路径 + 常见端口回退） ---\n\nfunction activePortFiles() {\n  const home = os.homedir();\n  const localAppData = process.env.LOCALAPPDATA || '';\n  switch (os.platform()) {\n    case 'darwin':\n      return [\n        path.join(home, 'Library/Application Support/Google/Chrome/DevToolsActivePort'),\n        path.join(home, 'Library/Application Support/Google/Chrome Canary/DevToolsActivePort'),\n        path.join(home, 'Library/Application Support/Chromium/DevToolsActivePort'),\n      ];\n    case 'linux':\n      return [\n        path.join(home, '.config/google-chrome/DevToolsActivePort'),\n        path.join(home, '.config/chromium/DevToolsActivePort'),\n      ];\n    case 'win32':\n      return [\n        path.join(localAppData, 'Google/Chrome/User Data/DevToolsActivePort'),\n        path.join(localAppData, 'Chromium/User Data/DevToolsActivePort'),\n      ];\n    default:\n      return [];\n  }\n}\n\nasync function detectChromePort() {\n  // 优先从 DevToolsActivePort 文件读取\n  for (const filePath of activePortFiles()) {\n    try {\n      const lines = fs.readFileSync(filePath, 'utf8').trim().split(/\\r?\\n/).filter(Boolean);\n      const port = parseInt(lines[0], 10);\n      if (port > 0 && port < 65536 && await checkPort(port)) {\n        return port;\n      }\n    } catch (_) {}\n  }\n  // 回退：探测常见端口\n  for (const port of [9222, 9229, 9333]) {\n    if (await checkPort(port)) {\n      return port;\n    }\n  }\n  return null;\n}\n\n// --- CDP Proxy 启动与等待 ---\n\nfunction httpGetJson(url, timeoutMs = 3000) {\n  return fetch(url, { signal: AbortSignal.timeout(timeoutMs) })\n    .then(async (res) => {\n      try { return JSON.parse(await res.text()); } catch { return null; }\n    })\n    .catch(() => null);\n}\n\nfunction startProxyDetached() {\n  const logFile = path.join(os.tmpdir(), 'cdp-proxy.log');\n  const logFd = fs.openSync(logFile, 'a');\n  const child = spawn(process.execPath, [PROXY_SCRIPT], {\n    detached: true,\n    stdio: ['ignore', logFd, logFd],\n    ...(os.platform() === 'win32' ? { windowsHide: true } : {}),\n  });\n  child.unref();\n  fs.closeSync(logFd);\n}\n\nasync function ensureProxy() {\n  const targetsUrl = `http://127.0.0.1:${PROXY_PORT}/targets`;\n\n  // /targets 返回 JSON 数组即 ready\n  const targets = await httpGetJson(targetsUrl);\n  if (Array.isArray(targets)) {\n    console.log('proxy: ready');\n    return true;\n  }\n\n  // 未运行或未连接，启动并等待\n  console.log('proxy: connecting...');\n  startProxyDetached();\n\n  // 等 proxy 进程就绪\n  await new Promise((r) => setTimeout(r, 2000));\n\n  for (let i = 1; i <= 15; i++) {\n    const result = await httpGetJson(targetsUrl, 8000);\n    if (Array.isArray(result)) {\n      console.log('proxy: ready');\n      return true;\n    }\n    if (i === 1) {\n      console.log('⚠️  Chrome 可能有授权弹窗，请点击「允许」后等待连接...');\n    }\n    await new Promise((r) => setTimeout(r, 1000));\n  }\n\n  console.log('❌ 连接超时，请检查 Chrome 调试设置');\n  console.log(`  日志：${path.join(os.tmpdir(), 'cdp-proxy.log')}`);\n  return false;\n}\n\n// --- main ---\n\nasync function main() {\n  checkNode();\n\n  const chromePort = await detectChromePort();\n  if (!chromePort) {\n    console.log('chrome: not connected — 请确保 Chrome 已打开，然后访问 chrome://inspect/#remote-debugging 并勾选 Allow remote debugging');\n    process.exit(1);\n  }\n  console.log(`chrome: ok (port ${chromePort})`);\n\n  const proxyOk = await ensureProxy();\n  if (!proxyOk) {\n    process.exit(1);\n  }\n\n  // 列出已有站点经验\n  const patternsDir = path.join(ROOT, 'references', 'site-patterns');\n  try {\n    const sites = fs.readdirSync(patternsDir)\n      .filter(f => f.endsWith('.md'))\n      .map(f => f.replace(/\\.md$/, ''));\n    if (sites.length) {\n      console.log(`\\nsite-patterns: ${sites.join(', ')}`);\n    }\n  } catch {}\n\n}\n\nawait main();\n"
  },
  {
    "path": "scripts/find-url.mjs",
    "content": "#!/usr/bin/env node\n// find-url - 从本地 Chrome 书签/历史中检索 URL\n// 用于定位公网搜索覆盖不到的目标（组织内部系统、SSO 后台、内网域名等）。\n//\n// 用法：\n//   node find-url.mjs [关键词...] [--only bookmarks|history] [--limit N] [--since 1d|7h|YYYY-MM-DD]\n//\n//   <关键词>            空格分词、多词 AND，匹配 title + url；可省略\n//   --only <source>     限定数据源（bookmarks / history），默认两者都查\n//   --limit N           条数上限，默认 20；0 = 不限\n//   --since <window>    时间窗（仅作用于历史）。1d / 7h / 30m 或 YYYY-MM-DD\n//   --sort recent|visits  历史排序：按最近访问 / 按访问次数，默认 recent\n//\n// 示例：\n//   node find-url.mjs 财务小智\n//   node find-url.mjs agent skills\n//   node find-url.mjs github --since 7d --only history\n//   node find-url.mjs --since 7d --only history --sort visits   # 最近一周高频网站\n//   node find-url.mjs --since 2d --only history --limit 0\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport os from 'node:os';\nimport { execFileSync } from 'node:child_process';\n\n// --- 参数解析 -----------------------------------------------------------\nfunction parseArgs(argv) {\n  const a = { keywords: [], only: null, limit: 20, since: null, sort: 'recent' };\n  for (let i = 0; i < argv.length; i++) {\n    const v = argv[i];\n    if (v === '--only')        a.only  = argv[++i];\n    else if (v === '--limit')  a.limit = parseInt(argv[++i], 10);\n    else if (v === '--since')  a.since = parseSince(argv[++i]);\n    else if (v === '--sort')   a.sort  = argv[++i];\n    else if (v === '-h' || v === '--help') { printUsage(); process.exit(0); }\n    else if (v.startsWith('--')) die(`未知参数: ${v}`);\n    else a.keywords.push(v);\n  }\n  if (a.only && !['bookmarks', 'history'].includes(a.only)) die(`--only 仅支持 bookmarks|history`);\n  if (!['recent', 'visits'].includes(a.sort)) die(`--sort 仅支持 recent|visits`);\n  if (Number.isNaN(a.limit) || a.limit < 0) die('--limit 需为非负整数');\n  return a;\n}\n\nfunction parseSince(s) {\n  if (!s) die('--since 需要值');\n  const m = s.match(/^(\\d+)([dhm])$/);\n  if (m) {\n    const n = parseInt(m[1], 10);\n    const ms = { d: 86400000, h: 3600000, m: 60000 }[m[2]];\n    return new Date(Date.now() - n * ms);\n  }\n  const d = new Date(s);\n  if (Number.isNaN(d.getTime())) die(`无效 --since 值: ${s}（用 1d / 7h / 30m / YYYY-MM-DD）`);\n  return d;\n}\n\nfunction die(msg) { console.error(msg); process.exit(1); }\nfunction printUsage() { console.error(fs.readFileSync(new URL(import.meta.url)).toString().split('\\n').slice(1, 19).map(l => l.replace(/^\\/\\/ ?/, '')).join('\\n')); }\n\n// --- Chrome 用户数据目录（跨平台） ---------------------------------------\nfunction getChromeDataDir() {\n  const home = os.homedir();\n  switch (os.platform()) {\n    case 'darwin': return path.join(home, 'Library/Application Support/Google/Chrome');\n    case 'linux':  return path.join(home, '.config/google-chrome');\n    case 'win32':  return path.join(process.env.LOCALAPPDATA || '', 'Google/Chrome/User Data');\n    default: return null;\n  }\n}\n\n// --- Profile 枚举 -------------------------------------------------------\nfunction listProfiles(dataDir) {\n  try {\n    const state = JSON.parse(fs.readFileSync(path.join(dataDir, 'Local State'), 'utf-8'));\n    const info = state?.profile?.info_cache || {};\n    const list = Object.keys(info).map(dir => ({ dir, name: info[dir].name || dir }));\n    if (list.length) return list;\n  } catch { /* 回退 */ }\n  return [{ dir: 'Default', name: 'Default' }];\n}\n\n// --- 书签检索 -----------------------------------------------------------\nfunction searchBookmarks(profileDir, profileName, keywords) {\n  const file = path.join(profileDir, 'Bookmarks');\n  if (!fs.existsSync(file)) return [];\n  let data;\n  try { data = JSON.parse(fs.readFileSync(file, 'utf-8')); } catch { return []; }\n  if (!keywords.length) return [];  // 书签无时间维度，无关键词不返回\n\n  const needles = keywords.map(k => k.toLowerCase());\n  const out = [];\n  function walk(node, trail) {\n    if (!node) return;\n    if (node.type === 'url') {\n      const hay = `${node.name || ''} ${node.url || ''}`.toLowerCase();\n      if (needles.every(n => hay.includes(n))) {\n        out.push({ profile: profileName, name: node.name || '', url: node.url || '', folder: trail.join(' / ') });\n      }\n    }\n    if (Array.isArray(node.children)) {\n      const sub = node.name ? [...trail, node.name] : trail;\n      for (const c of node.children) walk(c, sub);\n    }\n  }\n  for (const root of Object.values(data.roots || {})) walk(root, []);\n  return out;\n}\n\n// --- 历史检索（SQLite 运行时锁定，需 copy 到 tmp） ------------------------\nconst WEBKIT_EPOCH_DIFF_US = 11644473600000000n;  // 1601→1970 微秒差\n\nfunction searchHistory(profileDir, profileName, keywords, since, limit, sort) {\n  const src = path.join(profileDir, 'History');\n  if (!fs.existsSync(src)) return [];\n  const tmp = path.join(os.tmpdir(), `chrome-history-${process.pid}-${Date.now()}.sqlite`);\n  try {\n    fs.copyFileSync(src, tmp);\n    const conds = ['last_visit_time > 0'];\n    for (const kw of keywords) {\n      const esc = kw.toLowerCase().replace(/'/g, \"''\");\n      conds.push(`LOWER(title || ' ' || url) LIKE '%${esc}%'`);\n    }\n    if (since) {\n      const webkitUs = BigInt(since.getTime()) * 1000n + WEBKIT_EPOCH_DIFF_US;\n      conds.push(`last_visit_time >= ${webkitUs}`);\n    }\n    const limitClause = limit === 0 ? -1 : limit;\n    const orderBy = sort === 'visits'\n      ? 'visit_count DESC, last_visit_time DESC'\n      : 'last_visit_time DESC';\n    const sql = `SELECT title, url,\n      datetime((last_visit_time - 11644473600000000)/1000000, 'unixepoch', 'localtime') AS visit,\n      visit_count\n      FROM urls WHERE ${conds.join(' AND ')}\n      ORDER BY ${orderBy} LIMIT ${limitClause};`;\n\n    const raw = execFileSync('sqlite3', ['-separator', '\\t', tmp, sql], { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });\n    return raw.trim().split('\\n').filter(Boolean).map(line => {\n      const [title, url, visit, visit_count] = line.split('\\t');\n      return { profile: profileName, title, url, visit, visit_count: parseInt(visit_count, 10) };\n    });\n  } catch (e) {\n    if (e.code === 'ENOENT') die('未找到 sqlite3 命令。macOS/Linux 通常自带；Windows 可用 `winget install sqlite.sqlite` 或从 https://sqlite.org/download.html 下载后加入 PATH。');\n    return [];\n  } finally {\n    try { fs.unlinkSync(tmp); } catch {}\n  }\n}\n\n// --- 输出格式化 ---------------------------------------------------------\n// 用 `|` 作字段分隔符；字段内含 `|` 的替换成 `│`（全宽竖线）避免歧义\nconst clean = s => String(s ?? '').replaceAll('|', '│').trim();\n\nfunction printBookmarks(items, multiProfile) {\n  console.log(`[书签] ${items.length} 条`);\n  for (const b of items) {\n    const segs = [clean(b.name) || '(无标题)', clean(b.url)];\n    if (b.folder) segs.push(clean(b.folder));\n    if (multiProfile) segs.push('@' + clean(b.profile));\n    console.log('  ' + segs.join(' | '));\n  }\n}\n\nfunction printHistory(items, multiProfile, sortLabel) {\n  console.log(`[历史] ${items.length} 条（${sortLabel}）`);\n  for (const h of items) {\n    const segs = [clean(h.title) || '(无标题)', clean(h.url), h.visit];\n    if (h.visit_count > 1) segs.push(`visits=${h.visit_count}`);\n    if (multiProfile) segs.push('@' + clean(h.profile));\n    console.log('  ' + segs.join(' | '));\n  }\n}\n\n// --- main ---------------------------------------------------------------\nconst args = parseArgs(process.argv.slice(2));\n\nconst dataDir = getChromeDataDir();\nif (!dataDir || !fs.existsSync(dataDir)) die('未找到 Chrome 用户数据目录');\n\nconst profiles = listProfiles(dataDir);\nconst doBookmarks = args.only !== 'history';\nconst doHistory   = args.only !== 'bookmarks';\n\nconst bookmarks = [];\nconst history = [];\nfor (const p of profiles) {\n  const pDir = path.join(dataDir, p.dir);\n  if (!fs.existsSync(pDir)) continue;\n  if (doBookmarks) bookmarks.push(...searchBookmarks(pDir, p.name, args.keywords));\n  if (doHistory)   history.push(...searchHistory(pDir, p.name, args.keywords, args.since, args.limit === 0 ? 0 : args.limit * 2, args.sort));\n}\n\n// 历史跨 profile 合并后按指定 sort 重排 + 切顶\nif (args.sort === 'visits') {\n  history.sort((a, b) => (b.visit_count || 0) - (a.visit_count || 0) || (b.visit || '').localeCompare(a.visit || ''));\n} else {\n  history.sort((a, b) => (b.visit || '').localeCompare(a.visit || ''));\n}\nconst bookmarksOut = args.limit === 0 ? bookmarks : bookmarks.slice(0, args.limit);\nconst historyOut   = args.limit === 0 ? history   : history.slice(0, args.limit);\n\n// 仅当结果真的横跨多个 profile 时，才输出 @profile 标注（空 profile 不算）\nconst seenProfiles = new Set([...bookmarksOut, ...historyOut].map(x => x.profile));\nconst showProfile = seenProfiles.size > 1;\n\nconst sortLabel = args.sort === 'visits' ? '按访问次数' : '按最近访问';\nif (doBookmarks) printBookmarks(bookmarksOut, showProfile);\nif (doBookmarks && doHistory) console.log();\nif (doHistory)   printHistory(historyOut, showProfile, sortLabel);\n\nif (!args.keywords.length && doBookmarks && !doHistory) {\n  console.error('\\n提示：书签无时间维度，无关键词查询无意义。加关键词或切换 --only history。');\n}\n"
  },
  {
    "path": "scripts/match-site.mjs",
    "content": "#!/usr/bin/env node\n// 根据用户输入匹配站点经验文件（跨平台，替代 match-site.sh）\n// 用法：node match-site.mjs \"用户输入文本\"\n// 输出：匹配到的站点经验内容，无匹配则静默\n\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nconst ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');\nconst PATTERNS_DIR = path.join(ROOT, 'references', 'site-patterns');\nconst query = (process.argv[2] || '').trim();\n\nif (!query || !fs.existsSync(PATTERNS_DIR)) {\n  process.exit(0);\n}\n\nfor (const entry of fs.readdirSync(PATTERNS_DIR, { withFileTypes: true })) {\n  if (!entry.isFile() || !entry.name.endsWith('.md')) continue;\n\n  const domain = entry.name.replace(/\\.md$/, '');\n  const raw = fs.readFileSync(path.join(PATTERNS_DIR, entry.name), 'utf8');\n\n  // 提取 aliases\n  const aliasesLine = raw.split(/\\r?\\n/).find((l) => l.startsWith('aliases:')) || '';\n  const aliases = aliasesLine\n    .replace(/^aliases:\\s*/, '')\n    .replace(/^\\[/, '').replace(/\\]$/, '')\n    .split(',')\n    .map((v) => v.trim())\n    .filter(Boolean);\n\n  // 构建匹配模式\n  const escaped = (t) => t.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n  const pattern = [domain, ...aliases].map(escaped).join('|');\n  if (!new RegExp(pattern, 'i').test(query)) continue;\n\n  // 跳过 frontmatter，输出正文\n  const fences = [...raw.matchAll(/^---\\s*$/gm)];\n  const body = fences.length >= 2\n    ? raw.slice(fences[1].index + fences[1][0].length).replace(/^\\r?\\n/, '')\n    : raw;\n\n  process.stdout.write(`--- 站点经验: ${domain} ---\\n`);\n  process.stdout.write(body.trimEnd() + '\\n\\n');\n}\n"
  }
]