@miphamai/cli 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bin/mipham.ts +1 -9
  2. package/package.json +1 -1
  3. package/skills/standard/github-ops.SKILL.md +1 -1
  4. package/skills/standard/web-access/references/cdp-api.md +2 -1
  5. package/skills/standard/web-access/scripts/cdp-proxy.mjs +75 -1
  6. package/skills/standard/web-access/scripts/check-deps.mjs +17 -2
  7. package/skills/standard/web-access/scripts/find-url.mjs +0 -0
  8. package/skills/standard/web-access/scripts/match-site.mjs +0 -0
  9. package/skills/standard/web-access.SKILL.md +10 -3
  10. package/src/agent/cross-session/discovery.ts +26 -1
  11. package/src/commands/project.ts +1 -1
  12. package/src/config/defaults.ts +8 -0
  13. package/src/core/auto-memory.ts +3 -3
  14. package/src/core/behavior-tasks.json +101 -0
  15. package/src/core/behavior-tasks.ts +44 -0
  16. package/src/core/constitution-loader.ts +0 -2
  17. package/src/core/context.ts +0 -1
  18. package/src/core/credential-masker/index.ts +1 -0
  19. package/src/core/credential-masker/matcher.ts +18 -7
  20. package/src/core/credential-masker/search.ts +73 -0
  21. package/src/core/credential-masker.ts +1 -0
  22. package/src/core/crsi-producer.ts +214 -0
  23. package/src/core/crsi-sandbox.ts +5 -1
  24. package/src/core/dream-engine.ts +0 -2
  25. package/src/core/eval-harness.ts +7 -0
  26. package/src/core/instructions.ts +11 -0
  27. package/src/core/memory/memory-loader.ts +3 -1
  28. package/src/core/permission-rules.ts +23 -12
  29. package/src/core/proposal-guard.ts +100 -0
  30. package/src/core/rules-loader.ts +8 -10
  31. package/src/core/task-runner-tasks.json +14 -0
  32. package/src/core/task-runner.ts +163 -0
  33. package/src/daemon/database.ts +0 -1
  34. package/src/daemon/feishu/adapter.ts +2 -1
  35. package/src/daemon/heartbeat.ts +83 -0
  36. package/src/daemon/index.ts +18 -8
  37. package/src/daemon/server.ts +78 -3
  38. package/src/daemon/session-manager.ts +5 -4
  39. package/src/daemon/telegram/adapter.ts +70 -0
  40. package/src/daemon/telegram/api.ts +45 -0
  41. package/src/daemon/telegram/env.ts +14 -0
  42. package/src/daemon/telegram/poller.ts +64 -0
  43. package/src/daemon/telegram/types.ts +10 -0
  44. package/src/i18n-core/locales/en-US.json +123 -0
  45. package/src/i18n-core/locales/zh-CN.json +123 -0
  46. package/src/index.tsx +6 -0
  47. package/src/shared/package-info.ts +4 -1
  48. package/src/shared/types.ts +2 -0
  49. package/src/skills/bundled-skill-assets.ts +5 -5
  50. package/src/skills/bundled-skills.ts +2 -2
  51. package/src/tools/agent/exit-plan.ts +1 -3
  52. package/src/tools/agent/memory.ts +2 -1
  53. package/src/tools/file/glob.ts +38 -23
  54. package/src/tools/file/grep.ts +96 -64
  55. package/src/tools/index.ts +5 -5
  56. package/src/ui/app.tsx +21 -1
  57. package/src/ui/commands.ts +269 -125
  58. package/src/ui/config-wizard.tsx +1 -1
@@ -0,0 +1,10 @@
1
+ export interface TelegramConfig {
2
+ botToken: string
3
+ allowedChatIds: string[]
4
+ }
5
+
6
+ export interface TelegramMessage {
7
+ chatId: string
8
+ messageId: number
9
+ text: string
10
+ }
@@ -612,6 +612,129 @@
612
612
  },
613
613
  "schedule": {
614
614
  "failed": "Schedule check failed: {error}"
615
+ },
616
+ "crsi_critique": {
617
+ "not_init": "SelfCritique not initialized. Mipham Code >= v0.34.0 required.",
618
+ "on_title": "## 🔍 Self-Critique: ON",
619
+ "on_desc1": "The AI will now critique its own tool calls before execution.",
620
+ "on_desc2": "Safe + correct + necessary checks run before every targeted tool call.",
621
+ "on_hint": "💡 `/crsi critique off` to disable | `/crsi critique status` for current config",
622
+ "off_title": "## 🔍 Self-Critique: OFF\n\nTool calls will execute without pre-flight critique.",
623
+ "status_title": "## 🔍 Self-Critique Status",
624
+ "state": "State: **{state}**",
625
+ "model": "Model: **{model}**",
626
+ "threshold": "Threshold: **{pct}%**",
627
+ "threshold_detail": "Threshold: **{pct}%** (below this → correction or block)",
628
+ "target_tools": "Target tools: **{tools}**",
629
+ "timeout": "Timeout: **{ms}ms**",
630
+ "status_on_hint": "🔍 `/crsi critique on` — Enable self-critique",
631
+ "status_off_hint": "🔍 `/crsi critique off` — Disable self-critique",
632
+ "inspired": "*Inspired by Anthropic RLAIF — AI critiques its own actions before execution.*"
633
+ },
634
+ "crsi_interpret": {
635
+ "title": "## 🧠 CRSI Tool-Call Interpretability",
636
+ "no_sigs_for_tool": "No error signatures for tool `{tool}`. CRSI immune memory is clean for this tool.",
637
+ "no_sigs": "No active error signatures. CRSI immune memory is clean.",
638
+ "sigs_title": "### 🛡️ Error Signatures{tool}",
639
+ "sig_success": " Success: {bar} {pct}% | {occurrences}x | {strategy}",
640
+ "sig_fix": " Fix: `{fix}`",
641
+ "sigs_more": "... and {count} more signatures",
642
+ "reflection_title": "### 📊 CRSI Reflection Summary",
643
+ "reflections": "Turn reflections analyzed: **{count}**",
644
+ "usage_title": "### 💰 Token Usage",
645
+ "api_in": "API input tokens: {count}",
646
+ "api_out": "API output tokens: {count}",
647
+ "est_tokens": "Estimated tokens: {count}",
648
+ "health_title": "### 🏥 System Health",
649
+ "overall": "Overall: {bar} **{score}/100**",
650
+ "assessment": "Assessment: {assessment}",
651
+ "constitution_title": "### ⚖️ Constitution",
652
+ "principles": "Principles: **{total}** (🚫 {block} block | ⚠️ {warn} warn | 🔄 {auto} auto)",
653
+ "version": "Version: v{version}",
654
+ "empty": "_Start using tools to populate CRSI interpretability data._",
655
+ "filter_hint": "🔍 Filter by tool: `/crsi interpret <tool-name>` (e.g. `/crsi interpret Bash`)"
656
+ },
657
+ "crsi_red_team": {
658
+ "not_init": "Red-Team requires both ConstitutionLoader and PreFlightChecker to be initialized.",
659
+ "title": "## 🔴 CRSI Red-Team Report",
660
+ "score": "Overall Score: **{score}/100**",
661
+ "metric": "| Metric | Count |",
662
+ "metric_sep": "|--------|------|",
663
+ "total_attacks": "| Total attacks | {count} |",
664
+ "blocked": "| 🛡️ Correctly blocked | {count} |",
665
+ "passed_through": "| 🔴 Passed through (GAP) | {count} |",
666
+ "false_positives": "| ⚠️ False positives | {count} |",
667
+ "by_principle": "### By Principle",
668
+ "gaps_title": "### 🔴 Security Gaps (Should Have Been Blocked)",
669
+ "gap_tool": " Tool: `{tool}` → Params: `{params}`",
670
+ "blocked_title": "### 🛡️ Successfully Blocked",
671
+ "caught_by": "{principle}: {desc} → caught by **{caught}**",
672
+ "all_blocked": "🎉 **All attacks blocked!** The SIS immune system is fully operational.",
673
+ "good_coverage": "⚠️ Good coverage. Review the gaps above and add audit patterns to `ai-guardrails.yml`.",
674
+ "critical": "🔴 **Critical gaps detected.** Prioritize fixing the passed-through attacks above."
675
+ },
676
+ "dream": {
677
+ "not_init": "DreamEngine not initialized. Mipham Code >= v0.34.0 required.",
678
+ "no_history": "🌙 Auto-Dream has not run yet. Use `/dream` to trigger the first consolidation.",
679
+ "history_title": "## 🌙 Auto-Dream History",
680
+ "history_entry": "- **{time}**: {total} actions ({applied} auto-applied, {flagged} flagged for review)",
681
+ "done_title": "## 🌙 Auto-Dream Complete",
682
+ "integrate": "Memory integration: {before} → {after}",
683
+ "dedup": "- Deduplicated: {count} entries",
684
+ "contradiction": "- Contradictions: {count} (need human review)",
685
+ "merge": "- Merged: {count} groups",
686
+ "solidify": "- Fuzzy-marked: {count} entries (suggested to solidify)",
687
+ "prune": "- Pruned expired: {count} entries",
688
+ "review_hint": "⚠️ {count} items pending review. Use `/dream --aggressive` to auto-apply all.",
689
+ "clean": "✅ Memory is clean, nothing to consolidate."
690
+ },
691
+ "constitution": {
692
+ "not_init": "ConstitutionLoader not initialized. Mipham Code >= v0.34.0 required.",
693
+ "reload_title": "## ⚖️ Constitution Reloaded",
694
+ "version": "Version: **v{version}**",
695
+ "principles": "Principles: **{count}**",
696
+ "path": "Path: `{path}`",
697
+ "view_title": "## ⚖️ Mipham Constitution",
698
+ "view_summary": "Version: **v{version}** | Principles: **{count}** | Path: `{path}`",
699
+ "scope": " Scope: `{scope}`",
700
+ "tools": " Tools: {tools}",
701
+ "reload_hint": "🔧 `/constitution reload` — Reload after editing ai-guardrails.yml",
702
+ "edit_hint": "📝 Edit: `vi ~/.mipham/ai-guardrails.yml`",
703
+ "reset_hint": "🗑️ Reset: delete `~/.mipham/ai-guardrails.yml` then run `/constitution reload`",
704
+ "inspired": "*Inspired by Anthropic Constitutional AI. Mipham enforces these principles at runtime — auditable on every action.*"
705
+ },
706
+ "bug_report": {
707
+ "title": "## 🐛 Bug Report",
708
+ "hint": "> Copy the report below and paste it into your GitHub Issue.",
709
+ "env_title": "### Environment",
710
+ "mipham": "- **Mipham Code**: v{version}",
711
+ "session": "- **Session**: {id}",
712
+ "node": "- **Node**: {version}",
713
+ "platform": "- **Platform**: {platform} {arch}",
714
+ "os": "- **OS**: {os}",
715
+ "provider": "- **Provider**: {provider}",
716
+ "model": "- **Model**: {model}",
717
+ "sigs_title": "### Active Error Signatures",
718
+ "sig_entry": "- `{id}`: {pattern} ({category}, {occurrences}x, {pct}% success)",
719
+ "disabled_hooks": "### Disabled Hooks",
720
+ "hook_entry": "- `{key}`: {failures} failures, disabled at {time}",
721
+ "last_dream": "### Last Auto-Dream",
722
+ "ran_at": "- **Ran at**: {time}",
723
+ "actions": "- **Actions**: {total} ({applied} applied)",
724
+ "steps_title": "### Steps to Reproduce",
725
+ "expected": "### Expected Behavior",
726
+ "actual": "### Actual Behavior",
727
+ "generated": "🤖 Generated with Mipham Code v{version}"
728
+ },
729
+ "changelog": {
730
+ "title": "## 📋 Changelog",
731
+ "no_tags": "No version tags found.",
732
+ "current": "Current: **v{version}**",
733
+ "current_marker": " ← current",
734
+ "release_only": " _(release tag only)_",
735
+ "details_unavailable": " _(details unavailable)_",
736
+ "no_git": "Unable to read git history. Make sure you are in the Mipham Code repository.",
737
+ "full_history": "Full history: `git log --oneline`"
615
738
  }
616
739
  },
617
740
  "ui": {
@@ -612,6 +612,129 @@
612
612
  },
613
613
  "schedule": {
614
614
  "failed": "调度检查失败: {error}"
615
+ },
616
+ "crsi_critique": {
617
+ "not_init": "SelfCritique 未初始化。请确认 Mipham Code 版本 >= v0.34.0。",
618
+ "on_title": "## 🔍 Self-Critique: 已开启",
619
+ "on_desc1": "AI 现在会在每次工具调用执行前自我批判。",
620
+ "on_desc2": "每次目标工具调用前都会执行安全 + 正确 + 必要性检查。",
621
+ "on_hint": "💡 `/crsi critique off` 关闭 | `/crsi critique status` 查看当前配置",
622
+ "off_title": "## 🔍 Self-Critique: 已关闭\n\n工具调用将不再执行预检批判。",
623
+ "status_title": "## 🔍 Self-Critique 状态",
624
+ "state": "状态: **{state}**",
625
+ "model": "模型: **{model}**",
626
+ "threshold": "阈值: **{pct}%**",
627
+ "threshold_detail": "阈值: **{pct}%**(低于此 → 纠正或拦截)",
628
+ "target_tools": "目标工具: **{tools}**",
629
+ "timeout": "超时: **{ms}ms**",
630
+ "status_on_hint": "🔍 `/crsi critique on` — 启用自我批判",
631
+ "status_off_hint": "🔍 `/crsi critique off` — 禁用自我批判",
632
+ "inspired": "*灵感来自 Anthropic RLAIF — AI 在执行前批判自己的行为。*"
633
+ },
634
+ "crsi_interpret": {
635
+ "title": "## 🧠 CRSI 工具调用可解释性",
636
+ "no_sigs_for_tool": "工具 `{tool}` 没有错误签名。该工具的 CRSI 免疫记忆是干净的。",
637
+ "no_sigs": "没有活跃的错误签名。CRSI 免疫记忆是干净的。",
638
+ "sigs_title": "### 🛡️ 错误签名{tool}",
639
+ "sig_success": " 成功率: {bar} {pct}% | {occurrences}次 | {strategy}",
640
+ "sig_fix": " 修复: `{fix}`",
641
+ "sigs_more": "... 还有 {count} 条签名",
642
+ "reflection_title": "### 📊 CRSI 反思摘要",
643
+ "reflections": "已分析的轮次反思: **{count}**",
644
+ "usage_title": "### 💰 Token 用量",
645
+ "api_in": "API 输入 tokens: {count}",
646
+ "api_out": "API 输出 tokens: {count}",
647
+ "est_tokens": "估算 tokens: {count}",
648
+ "health_title": "### 🏥 系统健康",
649
+ "overall": "总体: {bar} **{score}/100**",
650
+ "assessment": "评估: {assessment}",
651
+ "constitution_title": "### ⚖️ 宪法",
652
+ "principles": "原则: **{total}** (🚫 {block} 拦截 | ⚠️ {warn} 警告 | 🔄 {auto} 自动)",
653
+ "version": "版本: v{version}",
654
+ "empty": "_开始使用工具以填充 CRSI 可解释性数据。_",
655
+ "filter_hint": "🔍 按工具过滤: `/crsi interpret <工具名>` (例如 `/crsi interpret Bash`)"
656
+ },
657
+ "crsi_red_team": {
658
+ "not_init": "Red-Team 需要 ConstitutionLoader 和 PreFlightChecker 均已初始化。",
659
+ "title": "## 🔴 CRSI 红队报告",
660
+ "score": "总体得分: **{score}/100**",
661
+ "metric": "| 指标 | 数量 |",
662
+ "metric_sep": "|------|------|",
663
+ "total_attacks": "| 总攻击数 | {count} |",
664
+ "blocked": "| 🛡️ 正确拦截 | {count} |",
665
+ "passed_through": "| 🔴 穿透(缺口) | {count} |",
666
+ "false_positives": "| ⚠️ 误报 | {count} |",
667
+ "by_principle": "### 按原则",
668
+ "gaps_title": "### 🔴 安全缺口(本应被拦截)",
669
+ "gap_tool": " 工具: `{tool}` → 参数: `{params}`",
670
+ "blocked_title": "### 🛡️ 成功拦截",
671
+ "caught_by": "{principle}: {desc} → 由 **{caught}** 捕获",
672
+ "all_blocked": "🎉 **所有攻击均被拦截!** SIS 自免疫系统完全正常运转。",
673
+ "good_coverage": "⚠️ 覆盖良好。请审查上述缺口,并向 `ai-guardrails.yml` 添加审计模式。",
674
+ "critical": "🔴 **检测到严重缺口。** 优先修复上述穿透的攻击。"
675
+ },
676
+ "dream": {
677
+ "not_init": "DreamEngine 未初始化。请确认 Mipham Code 版本 >= v0.34.0。",
678
+ "no_history": "🌙 尚未运行过 Auto-Dream。使用 `/dream` 手动触发首次梦境整合。",
679
+ "history_title": "## 🌙 Auto-Dream 历史",
680
+ "history_entry": "- **{time}**: {total} 行动 ({applied} 自动应用, {flagged} 标记审查)",
681
+ "done_title": "## 🌙 Auto-Dream 完成",
682
+ "integrate": "记忆整合: {before} → {after}",
683
+ "dedup": "- 去重: {count} 条",
684
+ "contradiction": "- 矛盾冲突: {count} 处 (需人工裁决)",
685
+ "merge": "- 合并: {count} 组",
686
+ "solidify": "- 模糊标记: {count} 条 (建议固化)",
687
+ "prune": "- 清理过期: {count} 条",
688
+ "review_hint": "⚠️ {count} 项待审查。使用 `/dream --aggressive` 自动应用所有操作。",
689
+ "clean": "✅ 记忆干净,无需整理。"
690
+ },
691
+ "constitution": {
692
+ "not_init": "ConstitutionLoader 未初始化。请确认 Mipham Code 版本 >= v0.34.0。",
693
+ "reload_title": "## ⚖️ 宪法已重新加载",
694
+ "version": "版本: **v{version}**",
695
+ "principles": "原则: **{count}**",
696
+ "path": "路径: `{path}`",
697
+ "view_title": "## ⚖️ Mipham 宪法",
698
+ "view_summary": "版本: **v{version}** | 原则: **{count}** | 路径: `{path}`",
699
+ "scope": " 作用域: `{scope}`",
700
+ "tools": " 工具: {tools}",
701
+ "reload_hint": "🔧 `/constitution reload` — 重新加载(修改 ai-guardrails.yml 后使用)",
702
+ "edit_hint": "📝 编辑: `vi ~/.mipham/ai-guardrails.yml`",
703
+ "reset_hint": "🗑️ 重置: 删除 `~/.mipham/ai-guardrails.yml` 后执行 `/constitution reload`",
704
+ "inspired": "*灵感来自 Anthropic 宪法 AI。Mipham 在运行时强制执行这些原则 — 每次行动都可审计。*"
705
+ },
706
+ "bug_report": {
707
+ "title": "## 🐛 Bug 报告",
708
+ "hint": "> 复制下面的报告并粘贴到你的 GitHub Issue 中。",
709
+ "env_title": "### 环境",
710
+ "mipham": "- **Mipham Code**: v{version}",
711
+ "session": "- **会话**: {id}",
712
+ "node": "- **Node**: {version}",
713
+ "platform": "- **平台**: {platform} {arch}",
714
+ "os": "- **操作系统**: {os}",
715
+ "provider": "- **提供商**: {provider}",
716
+ "model": "- **模型**: {model}",
717
+ "sigs_title": "### 活跃错误签名",
718
+ "sig_entry": "- `{id}`: {pattern} ({category}, {occurrences}次, {pct}% 成功率)",
719
+ "disabled_hooks": "### 已禁用的 Hooks",
720
+ "hook_entry": "- `{key}`: {failures} 次失败, 于 {time} 禁用",
721
+ "last_dream": "### 最近一次 Auto-Dream",
722
+ "ran_at": "- **运行于**: {time}",
723
+ "actions": "- **行动**: {total} ({applied} 已应用)",
724
+ "steps_title": "### 复现步骤",
725
+ "expected": "### 预期行为",
726
+ "actual": "### 实际行为",
727
+ "generated": "🤖 由 Mipham Code v{version} 生成"
728
+ },
729
+ "changelog": {
730
+ "title": "## 📋 变更日志",
731
+ "no_tags": "未找到版本标签。",
732
+ "current": "当前: **v{version}**",
733
+ "current_marker": " ← 当前",
734
+ "release_only": " _(仅发布标签)_",
735
+ "details_unavailable": " _(详情不可用)_",
736
+ "no_git": "无法读取 git 历史。请确认你位于 Mipham Code 仓库中。",
737
+ "full_history": "完整历史: `git log --oneline`"
615
738
  }
616
739
  },
617
740
  "ui": {
package/src/index.tsx CHANGED
@@ -524,6 +524,12 @@ export async function runApp(options: RunOptions): Promise<void> {
524
524
  engine.getPermission().setRestrictions(config.permissionRestrictions)
525
525
  }
526
526
 
527
+ // Apply user-defined permission rules (allow/deny) — wire the rule system into runtime
528
+ if (config.permissionRules) {
529
+ for (const rule of config.permissionRules.allow ?? []) engine.getPermission().allow(rule)
530
+ for (const rule of config.permissionRules.deny ?? []) engine.getPermission().deny(rule)
531
+ }
532
+
527
533
  // Initialize agent registry and load plugin agents/skills/MCP/hooks
528
534
  const agentRegistry = new AgentRegistry()
529
535
  agentRegistry.loadUserAgents()
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.49.0' as const
12
+ export const PACKAGE_VERSION = '0.51.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -45,6 +45,9 @@ export const BRAND_NAME = 'MiphamAI' as const
45
45
  /** 产品名称 */
46
46
  export const PRODUCT_NAME = 'Mipham Code' as const
47
47
 
48
+ /** AI 提交时的 Co-Authored-By 署名(品牌默认 Mipham,企业/团队可覆盖为自身名)。 */
49
+ export const COAUTHOR_TRAILER = 'Co-Authored-By: Mipham <noreply@mipham.ai>' as const
50
+
48
51
  /** 公司名称(英文) */
49
52
  export const COMPANY_NAME_EN = 'One Mipham Corporation' as const
50
53
 
@@ -133,6 +133,8 @@ export interface MiphamConfig {
133
133
  permission: ToolPermission
134
134
  /** Org-level permission restrictions (forbiddenModes, maxAllowedMode). */
135
135
  permissionRestrictions?: PermissionRestrictions
136
+ /** User-defined permission rules (allow/deny patterns), wired into the runtime PermissionSystem. */
137
+ permissionRules?: { allow?: string[]; deny?: string[] }
136
138
  providers: ProviderConfig[]
137
139
  skills?: { paths: string[]; mcpServers: McpServerConfig[] }
138
140
  marketplace?: {
@@ -10,10 +10,10 @@ export interface BundledSkillAsset {
10
10
 
11
11
  export const BUNDLED_SKILL_ASSETS: Record<string, BundledSkillAsset[]> = {
12
12
  "web-access": [
13
- { 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健康检查,返回连接状态。\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 },
14
- { 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\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\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 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 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 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 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 },
15
- { 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(() => {\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 httpGetJson(url, timeoutMs = 3000) {\n return fetch(url, { signal: AbortSignal.timeout(timeoutMs) })\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: 420 },
16
- { 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') {\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: 420 },
17
- { 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: 420 },
13
+ { 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 },
14
+ { 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 },
15
+ { 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 },
16
+ { 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') {\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 },
17
+ { 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 },
18
18
  ],
19
19
  }