// AUTO-GENERATED by scripts/generate-bundled-skills.ts — DO NOT EDIT. // Regenerate with: bun run scripts/generate-bundled-skills.ts // In-memory snapshot of built-in skills that ship executable assets. export interface BundledSkillAsset { path: string content: string mode?: number } export const BUNDLED_SKILL_ASSETS: Record = { "web-access": [ { path: "references/cdp-api.md", content: "# CDP Proxy API 参考\n\n## 基础信息\n\n- 地址:`http://localhost:3456`\n- 启动:`node ~/.mipham/skills/web-access/scripts/cdp-proxy.mjs &`\n- 启动后持续运行,不建议主动停止(重启需 Chrome 重新授权)\n- 强制停止:`pkill -f cdp-proxy.mjs`\n- 鉴权:除 `/health` 外,所有端点要求请求头 `X-CDP-Token`,值 = `~/.mipham/skills/web-access/.cdp-token` 的内容(下方示例省略此头)\n\n## API 端点\n\n### GET /health\n\n健康检查,返回连接状态。\n\n```bash\ncurl -s http://localhost:3456/health\n```\n\n### GET /targets\n\n列出所有已打开的页面 tab。返回数组,每项含 `targetId`、`title`、`url`。\n\n```bash\ncurl -s http://localhost:3456/targets\n```\n\n### GET /new?url=URL\n\n创建新后台 tab,自动等待页面加载完成。返回 `{ targetId }`.\n\n```bash\ncurl -s \"http://localhost:3456/new?url=https://example.com\"\n```\n\n### GET /close?target=ID\n\n关闭指定 tab。\n\n```bash\ncurl -s \"http://localhost:3456/close?target=TARGET_ID\"\n```\n\n### GET /navigate?target=ID&url=URL\n\n在已有 tab 中导航到新 URL,自动等待加载。\n\n```bash\ncurl -s \"http://localhost:3456/navigate?target=ID&url=https://example.com\"\n```\n\n### GET /back?target=ID\n\n后退一页。\n\n```bash\ncurl -s \"http://localhost:3456/back?target=ID\"\n```\n\n### GET /info?target=ID\n\n获取页面基础信息(title、url、readyState)。\n\n```bash\ncurl -s \"http://localhost:3456/info?target=ID\"\n```\n\n### POST /eval?target=ID\n\n执行 JavaScript 表达式,POST body 为 JS 代码。\n\n```bash\ncurl -s -X POST \"http://localhost:3456/eval?target=ID\" -d 'document.title'\n```\n\n### POST /click?target=ID\n\nJS 层面点击(`el.click()`),POST body 为 CSS 选择器。自动 scrollIntoView 后点击。简单快速,覆盖大多数场景。\n\n```bash\ncurl -s -X POST \"http://localhost:3456/click?target=ID\" -d 'button.submit'\n```\n\n### POST /clickAt?target=ID\n\nCDP 浏览器级真实鼠标点击(`Input.dispatchMouseEvent`),POST body 为 CSS 选择器。先获取元素坐标,再模拟鼠标按下/释放。算真实用户手势,能触发文件对话框、绕过部分反自动化检测。\n\n```bash\ncurl -s -X POST \"http://localhost:3456/clickAt?target=ID\" -d 'button.upload'\n```\n\n### POST /setFiles?target=ID\n\n给 file input 设置本地文件路径(`DOM.setFileInputFiles`),完全绕过文件对话框。POST body 为 JSON。\n\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\n滚动页面。`direction` 可选 `down`(默认)、`up`、`top`、`bottom`。滚动后自动等待 800ms 供懒加载触发。\n\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\n截图。指定 `file` 参数保存到本地文件;不指定则返回图片二进制。可选 `format=jpeg`。\n\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", mode: 420 }, { 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, fileURLToPath } from 'node:url'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport os from 'node:os'\nimport net from 'node:net'\nimport crypto from 'node:crypto'\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\nconst managedTabs = new Map() // targetId -> { lastAccessed: number }\nconst TAB_IDLE_TIMEOUT = parseInt(process.env.CDP_TAB_IDLE_TIMEOUT || '900000') // 15 min default\nconst CLEANUP_INTERVAL = 60000 // sweep every 60s\n\nconst ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')\nconst TOKEN_FILE = path.join(ROOT, '.cdp-token')\n\n// --- 共享密钥鉴权 ---\n// 防御浏览器 CSRF 到 localhost:恶意网页能 fetch 127.0.0.1:3456,但读不到本文件。\nlet authToken = null\nfunction readToken() {\n try {\n const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim()\n if (t) return t\n } catch {\n /* 尚未生成 */\n }\n return null\n}\nfunction ensureToken() {\n const existing = readToken()\n if (existing) {\n authToken = existing\n return existing\n }\n authToken = crypto.randomBytes(32).toString('hex')\n try {\n fs.writeFileSync(TOKEN_FILE, authToken, { mode: 0o600 })\n } catch {\n /* 无写权限时仅内存持有,进程存活期内仍可鉴权 */\n }\n return authToken\n}\n\n// --- 窄 SSRF 防护 ---\n// 本工具用途即访问组织内网(SSO 后台/内部系统),故不做私有网段全量屏蔽;\n// 仅挡云元数据端点与非 http(s)/about 协议。完整 rebinding 防护见 src/security/url.ts。\nfunction validateNavUrl(rawUrl) {\n let u\n try {\n u = new URL(rawUrl)\n } catch {\n return { ok: false, reason: 'invalid URL' }\n }\n if (u.protocol !== 'http:' && u.protocol !== 'https:' && u.protocol !== 'about:') {\n return { ok: false, reason: `unsupported protocol: ${u.protocol}` }\n }\n const host = u.hostname.toLowerCase()\n if (host === '169.254.169.254' || host === 'metadata.google.internal') {\n return { ok: false, reason: 'blocked: cloud metadata endpoint' }\n }\n return { ok: true }\n}\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(\n `[CDP Proxy] 从 DevToolsActivePort 发现端口: ${port}${wsPath ? ' (带 wsPath)' : ''}`,\n )\n return { port, wsPath }\n }\n }\n } catch {\n /* 文件不存在,继续 */\n }\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(() => {\n socket.destroy()\n resolve(false)\n }, 2000)\n socket.once('connect', () => {\n clearTimeout(timer)\n socket.destroy()\n resolve(true)\n })\n socket.once('error', () => {\n clearTimeout(timer)\n resolve(false)\n })\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 managedTabs.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 )\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(\n 'Fetch.enable',\n {\n patterns: [\n { urlPattern: `http://127.0.0.1:${chromePort}/*`, requestStage: 'Request' },\n { urlPattern: `http://localhost:${chromePort}/*`, requestStage: 'Request' },\n ],\n },\n sessionId,\n )\n portGuardedSessions.add(sessionId)\n } catch {\n /* Fetch 域启用失败不影响主流程 */\n }\n}\n\n// --- 闲置 Tab 自动清理 ---\nfunction touchTab(targetId) {\n const entry = managedTabs.get(targetId)\n if (entry) entry.lastAccessed = Date.now()\n}\n\nasync function cleanupIdleTabs() {\n if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) return\n const now = Date.now()\n for (const [targetId, info] of managedTabs) {\n if (now - info.lastAccessed < TAB_IDLE_TIMEOUT) continue\n try {\n await sendCDP('Target.closeTarget', { targetId })\n } catch {\n /* tab may already be closed */\n }\n sessions.delete(targetId)\n managedTabs.delete(targetId)\n console.log(`[CDP Proxy] Auto-closed idle tab: ${targetId}`)\n }\n}\n\nasync function closeAllManagedTabs() {\n if (!ws || (ws.readyState !== WS.OPEN && ws.readyState !== 1)) return\n const targets = [...managedTabs.keys()]\n for (const targetId of targets) {\n try {\n await sendCDP('Target.closeTarget', { targetId })\n } catch {\n /* ignore */\n }\n sessions.delete(targetId)\n managedTabs.delete(targetId)\n }\n if (targets.length) console.log(`[CDP Proxy] Shutdown: closed ${targets.length} managed tab(s)`)\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(\n 'Runtime.evaluate',\n {\n expression: 'document.readyState',\n returnByValue: true,\n },\n sessionId,\n )\n if (resp.result?.result?.value === 'complete') {\n done('complete')\n }\n } catch {\n /* 忽略 */\n }\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 if (q.target) touchTab(q.target)\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(\n JSON.stringify({\n status: 'ok',\n connected,\n sessions: sessions.size,\n managedTabs: managedTabs.size,\n chromePort,\n }),\n )\n return\n }\n\n // 鉴权:非 /health 端点要求 X-CDP-Token(防浏览器 CSRF 到 localhost)\n const provided = req.headers['x-cdp-token'] || ''\n if (!authToken || provided !== authToken) {\n res.statusCode = 401\n res.end(JSON.stringify({ error: 'unauthorized: missing or invalid X-CDP-Token' }))\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 check = validateNavUrl(targetUrl)\n if (!check.ok) {\n res.statusCode = 400\n res.end(JSON.stringify({ error: check.reason }))\n return\n }\n const resp = await sendCDP('Target.createTarget', { url: targetUrl, background: true })\n const targetId = resp.result.targetId\n managedTabs.set(targetId, { lastAccessed: Date.now() })\n\n // 等待页面加载\n if (targetUrl !== 'about:blank') {\n try {\n const sid = await ensureSession(targetId)\n await waitForLoad(sid)\n } catch {\n /* 非致命,继续 */\n }\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 managedTabs.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 check = validateNavUrl(q.url || '')\n if (!check.ok) {\n res.statusCode = 400\n res.end(JSON.stringify({ error: check.reason }))\n return\n }\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(\n 'Runtime.evaluate',\n {\n expression: expr,\n returnByValue: true,\n awaitPromise: true,\n },\n sid,\n )\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(\n 'Runtime.evaluate',\n {\n expression: js,\n returnByValue: true,\n awaitPromise: true,\n },\n sid,\n )\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(\n 'Runtime.evaluate',\n {\n expression: js,\n returnByValue: true,\n awaitPromise: true,\n },\n sid,\n )\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(\n 'Input.dispatchMouseEvent',\n {\n type: 'mousePressed',\n x: coord.x,\n y: coord.y,\n button: 'left',\n clickCount: 1,\n },\n sid,\n )\n await sendCDP(\n 'Input.dispatchMouseEvent',\n {\n type: 'mouseReleased',\n x: coord.x,\n y: coord.y,\n button: 'left',\n clickCount: 1,\n },\n sid,\n )\n res.end(\n JSON.stringify({ clicked: true, x: coord.x, y: coord.y, tag: coord.tag, text: coord.text }),\n )\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(\n 'DOM.querySelector',\n {\n nodeId: doc.result.root.nodeId,\n selector: body.selector,\n },\n sid,\n )\n if (!node.result?.nodeId) {\n res.statusCode = 400\n res.end(JSON.stringify({ error: '未找到元素: ' + body.selector }))\n return\n }\n // 设置文件\n await sendCDP(\n 'DOM.setFileInputFiles',\n {\n nodeId: node.result.nodeId,\n files: body.files,\n },\n sid,\n )\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(\n 'Runtime.evaluate',\n {\n expression: js,\n returnByValue: true,\n },\n sid,\n )\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(\n 'Page.captureScreenshot',\n {\n format,\n quality: format === 'jpeg' ? 80 : undefined,\n },\n sid,\n )\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(\n 'Runtime.evaluate',\n {\n expression:\n 'JSON.stringify({title: document.title, url: location.href, ready: document.readyState})',\n returnByValue: true,\n },\n sid,\n )\n res.end(resp.result?.result?.value || '{}')\n } else {\n res.statusCode = 404\n res.end(\n 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 }\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', () => {\n s.close()\n resolve(true)\n })\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\n .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 })\n .on('error', () => resolve(false))\n })\n if (ok) {\n console.log(`[CDP Proxy] 已有实例运行在端口 ${PORT},退出`)\n process.exit(0)\n }\n } catch {\n /* 端口占用但非 proxy,继续报错 */\n }\n console.error(`[CDP Proxy] 端口 ${PORT} 已被占用`)\n process.exit(1)\n }\n\n // 只有成功绑定端口的实例才生成/复用 token(避免多实例竞态)\n ensureToken()\n\n server.listen(PORT, '127.0.0.1', () => {\n console.log(`[CDP Proxy] 运行在 http://localhost:${PORT}`)\n // 启动时尝试连接 Chrome(非阻塞)\n connect().catch((e) =>\n console.error('[CDP Proxy] 初始连接失败:', e.message, '(将在首次请求时重试)'),\n )\n })\n\n // 定时清理闲置 tab\n const cleanupTimer = setInterval(cleanupIdleTabs, CLEANUP_INTERVAL)\n cleanupTimer.unref()\n\n const shutdown = async (sig) => {\n console.log(`[CDP Proxy] ${sig}, cleaning up...`)\n clearInterval(cleanupTimer)\n await closeAllManagedTabs()\n process.exit(0)\n }\n process.on('SIGINT', () => shutdown('SIGINT'))\n process.on('SIGTERM', () => shutdown('SIGTERM'))\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", mode: 493 }, { 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)\nconst TOKEN_FILE = path.join(ROOT, '.cdp-token')\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(() => {\n socket.destroy()\n resolve(false)\n }, timeoutMs)\n socket.once('connect', () => {\n clearTimeout(timer)\n socket.destroy()\n resolve(true)\n })\n socket.once('error', () => {\n clearTimeout(timer)\n resolve(false)\n })\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 readToken() {\n try {\n const t = fs.readFileSync(TOKEN_FILE, 'utf8').trim()\n if (t) return t\n } catch {\n /* proxy 尚未生成 token */\n }\n return null\n}\n\nfunction httpGetJson(url, timeoutMs = 3000) {\n const token = readToken()\n return fetch(url, {\n signal: AbortSignal.timeout(timeoutMs),\n headers: token ? { 'X-CDP-Token': token } : {},\n })\n .then(async (res) => {\n try {\n return JSON.parse(await res.text())\n } catch {\n return null\n }\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(\n 'chrome: not connected — 请确保 Chrome 已打开,然后访问 chrome://inspect/#remote-debugging 并勾选 Allow remote debugging',\n )\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\n .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\nawait main()\n", mode: 493 }, { 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 限定数据源(bookmarks / history),默认两者都查\n// --limit N 条数上限,默认 20;0 = 不限\n// --since 时间窗(仅作用于历史)。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') {\n printUsage()\n 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) {\n console.error(msg)\n process.exit(1)\n}\nfunction printUsage() {\n console.error(\n fs\n .readFileSync(new URL(import.meta.url))\n .toString()\n .split('\\n')\n .slice(1, 19)\n .map((l) => l.replace(/^\\/\\/ ?/, ''))\n .join('\\n'),\n )\n}\n\n// --- Chrome 用户数据目录(跨平台) ---------------------------------------\nfunction getChromeDataDir() {\n const home = os.homedir()\n switch (os.platform()) {\n case 'darwin':\n return path.join(home, 'Library/Application Support/Google/Chrome')\n case 'linux':\n return path.join(home, '.config/google-chrome')\n case 'win32':\n return path.join(process.env.LOCALAPPDATA || '', 'Google/Chrome/User Data')\n default:\n 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 /* 回退 */\n }\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 {\n data = JSON.parse(fs.readFileSync(file, 'utf-8'))\n } catch {\n return []\n }\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({\n profile: profileName,\n name: node.name || '',\n url: node.url || '',\n folder: trail.join(' / '),\n })\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 =\n sort === 'visits' ? 'visit_count DESC, last_visit_time DESC' : '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], {\n encoding: 'utf-8',\n maxBuffer: 50 * 1024 * 1024,\n })\n return raw\n .trim()\n .split('\\n')\n .filter(Boolean)\n .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')\n die(\n '未找到 sqlite3 命令。macOS/Linux 通常自带;Windows 可用 `winget install sqlite.sqlite` 或从 https://sqlite.org/download.html 下载后加入 PATH。',\n )\n return []\n } finally {\n try {\n fs.unlinkSync(tmp)\n } catch {}\n }\n}\n\n// --- 输出格式化 ---------------------------------------------------------\n// 用 `|` 作字段分隔符;字段内含 `|` 的替换成 `│`(全宽竖线)避免歧义\nconst clean = (s) =>\n String(s ?? '')\n .replaceAll('|', '│')\n .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)\n history.push(\n ...searchHistory(\n pDir,\n p.name,\n args.keywords,\n args.since,\n args.limit === 0 ? 0 : args.limit * 2,\n args.sort,\n ),\n )\n}\n\n// 历史跨 profile 合并后按指定 sort 重排 + 切顶\nif (args.sort === 'visits') {\n history.sort(\n (a, b) =>\n (b.visit_count || 0) - (a.visit_count || 0) || (b.visit || '').localeCompare(a.visit || ''),\n )\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", mode: 493 }, { 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(/^\\[/, '')\n .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 =\n 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", mode: 493 }, ], }