mooncat-browser 0.1.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 (117) hide show
  1. package/README.md +213 -0
  2. package/browser-op/backend/browserd.cjs +1004 -0
  3. package/browser-op/backend/rpc-client.cjs +64 -0
  4. package/browser-op/backend/state.cjs +51 -0
  5. package/browser-op/cdp/capture-inject.js +426 -0
  6. package/browser-op/cdp/capture-inject.ts +426 -0
  7. package/browser-op/cdp/capture-service.cjs +172 -0
  8. package/browser-op/cdp/chrome-launcher.cjs +370 -0
  9. package/browser-op/cdp/chrome-path.cjs +57 -0
  10. package/browser-op/cdp/state.cjs +89 -0
  11. package/browser-op/extension/extension-detect.cjs +228 -0
  12. package/browser-op/extension/server.cjs +197 -0
  13. package/browser-op/extension/service.cjs +228 -0
  14. package/browser-op/extension/state.cjs +78 -0
  15. package/browser-op/index.cjs +389 -0
  16. package/browser-op/package.json +17 -0
  17. package/browser-op/py/behavior.py +138 -0
  18. package/browser-op/py/browser.py +340 -0
  19. package/browser-op/py/captcha.py +115 -0
  20. package/browser-op/py/crawler.py +125 -0
  21. package/browser-op/py/examples/01_open_and_probe.py +48 -0
  22. package/browser-op/py/examples/02_reuse_and_probe.py +66 -0
  23. package/browser-op/py/examples/03_interact.py +66 -0
  24. package/browser-op/py/find.py +150 -0
  25. package/browser-op/py/honeypot.py +73 -0
  26. package/browser-op/py/humanize.py +392 -0
  27. package/browser-op/py/image.py +186 -0
  28. package/browser-op/py/interact.py +193 -0
  29. package/browser-op/py/markdown.py +38 -0
  30. package/browser-op/py/pyproject.toml +32 -0
  31. package/browser-op/py/ready.py +208 -0
  32. package/browser-op/py/scroll.py +180 -0
  33. package/browser-op/py/upload.py +103 -0
  34. package/browser-op/py/visual_target.py +47 -0
  35. package/browser-op/py/visualize.py +91 -0
  36. package/browser-op/state.cjs +63 -0
  37. package/browser-op/web/behavior.js +153 -0
  38. package/browser-op/web/browser.js +231 -0
  39. package/browser-op/web/captcha.js +85 -0
  40. package/browser-op/web/crawler.js +109 -0
  41. package/browser-op/web/find.js +147 -0
  42. package/browser-op/web/honeypot.js +68 -0
  43. package/browser-op/web/humanize.js +522 -0
  44. package/browser-op/web/image.js +177 -0
  45. package/browser-op/web/interact.js +169 -0
  46. package/browser-op/web/markdown.js +80 -0
  47. package/browser-op/web/ready.js +295 -0
  48. package/browser-op/web/scroll.js +167 -0
  49. package/browser-op/web/upload.js +116 -0
  50. package/browser-op/web/visual-runtime.inject.cjs +6 -0
  51. package/browser-op/webplater/.env.example +7 -0
  52. package/browser-op/webplater/ARCHITECTURE.md +102 -0
  53. package/browser-op/webplater/dist/chrome-mv3/assets/popup-BUZEUmsx.css +1 -0
  54. package/browser-op/webplater/dist/chrome-mv3/background.js +2 -0
  55. package/browser-op/webplater/dist/chrome-mv3/capture.js +310 -0
  56. package/browser-op/webplater/dist/chrome-mv3/chunks/_virtual_wxt-html-plugins-DPbbfBKe.js +1 -0
  57. package/browser-op/webplater/dist/chrome-mv3/chunks/offscreen-CFXYw9Mo.js +1 -0
  58. package/browser-op/webplater/dist/chrome-mv3/chunks/popup-C-lpxZZO.js +1 -0
  59. package/browser-op/webplater/dist/chrome-mv3/content-scripts/content.js +7 -0
  60. package/browser-op/webplater/dist/chrome-mv3/manifest.json +1 -0
  61. package/browser-op/webplater/dist/chrome-mv3/offscreen.html +16 -0
  62. package/browser-op/webplater/dist/chrome-mv3/popup.html +31 -0
  63. package/browser-op/webplater/entrypoints/background.ts +938 -0
  64. package/browser-op/webplater/entrypoints/content.ts +1150 -0
  65. package/browser-op/webplater/entrypoints/offscreen/index.html +15 -0
  66. package/browser-op/webplater/entrypoints/offscreen/main.ts +161 -0
  67. package/browser-op/webplater/entrypoints/popup/index.html +29 -0
  68. package/browser-op/webplater/entrypoints/popup/main.ts +61 -0
  69. package/browser-op/webplater/entrypoints/popup/style.css +100 -0
  70. package/browser-op/webplater/lib/snapshot.ts +352 -0
  71. package/browser-op/webplater/package.json +29 -0
  72. package/browser-op/webplater/pnpm-lock.yaml +3411 -0
  73. package/browser-op/webplater/public/capture.js +310 -0
  74. package/browser-op/webplater/scripts/publish-extension.mjs +176 -0
  75. package/browser-op/webplater/tsconfig.json +19 -0
  76. package/browser-op/webplater/wxt.config.ts +34 -0
  77. package/dist/actions.md +102 -0
  78. package/dist/cli.d.ts +2 -0
  79. package/dist/cli.d.ts.map +1 -0
  80. package/dist/cli.js +278 -0
  81. package/dist/cli.js.map +1 -0
  82. package/dist/client.d.ts +94 -0
  83. package/dist/client.d.ts.map +1 -0
  84. package/dist/client.js +277 -0
  85. package/dist/client.js.map +1 -0
  86. package/dist/config.d.ts +61 -0
  87. package/dist/config.d.ts.map +1 -0
  88. package/dist/config.js +119 -0
  89. package/dist/config.js.map +1 -0
  90. package/dist/protocol.d.ts +195 -0
  91. package/dist/protocol.d.ts.map +1 -0
  92. package/dist/protocol.js +11 -0
  93. package/dist/protocol.js.map +1 -0
  94. package/dist/server.d.ts +66 -0
  95. package/dist/server.d.ts.map +1 -0
  96. package/dist/server.js +259 -0
  97. package/dist/server.js.map +1 -0
  98. package/package.json +78 -0
  99. package/schemas/browser.clearCookies.schema.json +13 -0
  100. package/schemas/browser.close.schema.json +9 -0
  101. package/schemas/browser.getCookies.schema.json +13 -0
  102. package/schemas/browser.getDownload.schema.json +15 -0
  103. package/schemas/browser.health.schema.json +9 -0
  104. package/schemas/browser.listDownloads.schema.json +16 -0
  105. package/schemas/browser.listTabs.schema.json +9 -0
  106. package/schemas/browser.newTab.schema.json +15 -0
  107. package/schemas/browser.open.schema.json +15 -0
  108. package/schemas/browser.operate.schema.json +15 -0
  109. package/schemas/browser.reuseTab.schema.json +15 -0
  110. package/schemas/browser.setCookies.schema.json +15 -0
  111. package/schemas/browser.waitFor.schema.json +15 -0
  112. package/schemas/browser.waitForDownload.schema.json +15 -0
  113. package/skills/browser/SKILL.md +110 -0
  114. package/skills/browser/references/collect.md +163 -0
  115. package/skills/browser/references/high-risk.md +161 -0
  116. package/skills/browser/references/operate-actions.md +92 -0
  117. package/skills/browser/references/probing.md +302 -0
@@ -0,0 +1,180 @@
1
+ """web/scroll — 唯一滚动入口 ScrollController (路由无关).
2
+
3
+ 忠实复刻 lib/web/scroll.js。
4
+ 原则: 所有改变 viewport 的动作必须经过这里。
5
+ - scroll_to_y: 启动读一次 startY, clamp target, 单次定方向, 单调 easing, 不越过, 不反向
6
+ - scroll_by: = scroll_to_y(currentY + delta)
7
+ - scroll_to_element: 算目标→scroll_to_y→重测→最多一次同向修正, 禁 scrollIntoView
8
+ - scroll_to_end: 读 maxScrollY→scroll_to_y, 只单调
9
+
10
+ 依赖: browser (operate)
11
+ """
12
+ from __future__ import annotations
13
+ import time
14
+ import math
15
+ import random
16
+ from typing import Any
17
+
18
+ import sys
19
+ sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else __file__.rsplit("/", 1)[0])
20
+ from browser import operate # noqa: E402
21
+
22
+
23
+ def _ease(t: float) -> float:
24
+ """单调缓动 (easeInOutCubic): t∈[0,1] → [0,1], 单调递增."""
25
+ if t < 0.5:
26
+ return 4 * t * t * t
27
+ return 1 - math.pow(-2 * t + 2, 3) / 2
28
+
29
+
30
+ def read_scroll_y(ph: dict) -> int:
31
+ """读当前 scrollY."""
32
+ y = operate(ph, {"action": "evaluate", "source": "() => window.scrollY || window.pageYOffset || 0", "_skipVisualize": True})
33
+ if y is None:
34
+ return 0
35
+ return int(y) if not isinstance(y, (int, float)) else int(y)
36
+
37
+
38
+ def read_max_scroll_y(ph: dict) -> int:
39
+ """读最大可滚距离."""
40
+ m = operate(ph, {"action": "evaluate", "source": "() => Math.max(0, (document.body.scrollHeight || 0) - (window.innerHeight || 0))", "_skipVisualize": True})
41
+ if m is None:
42
+ return 0
43
+ return int(m) if not isinstance(m, (int, float)) else int(m)
44
+
45
+
46
+ def _set_scroll_y(ph: dict, y: int) -> None:
47
+ """设置绝对 scrollY (用 scrollTo 设精确位置)."""
48
+ operate(ph, {"action": "evaluate", "source": "(y) => { window.scrollTo(0, y); return window.scrollY || 0 }", "args": y, "_skipVisualize": True})
49
+
50
+
51
+ def scroll_to_y(page_handle: dict, target_y: int, opts: dict | None = None) -> dict:
52
+ """滚动到指定 scrollY (绝对坐标)。
53
+
54
+ 规则:
55
+ - 启动读一次 startY, 之后不靠实时 scrollY 反向修正
56
+ - clamp targetY 到 [0, maxScrollY]
57
+ - 方向一次确定, 单调 easing, 每步 clamp 到目标内, 不越过不反向
58
+
59
+ 返回: {ok, startY, targetY, positions, maxDelta, reversed}
60
+ reversed=true 表示页面外部干扰导致实际滚动反向 (异常)
61
+ """
62
+ opts = opts or {}
63
+ step_px = opts.get("stepPx", 40)
64
+ step_ms = opts.get("stepMs", 16)
65
+
66
+ start_y = read_scroll_y(page_handle)
67
+ max_y = read_max_scroll_y(page_handle)
68
+ target = max(0, min(target_y, max_y))
69
+ distance = abs(target - start_y)
70
+ dir_flag = 1 if target >= start_y else -1
71
+
72
+ steps = max(1, math.ceil(distance / step_px))
73
+ positions = [start_y]
74
+ max_delta = 0
75
+ reversed_flag = False
76
+
77
+ for i in range(1, steps + 1):
78
+ t = i / steps
79
+ eased = _ease(t)
80
+ pos = start_y + dir_flag * distance * eased
81
+ if dir_flag > 0:
82
+ pos = min(pos, target)
83
+ else:
84
+ pos = max(pos, target)
85
+ _set_scroll_y(page_handle, int(pos))
86
+
87
+ real = read_scroll_y(page_handle)
88
+ positions.append(real)
89
+ delta = abs(real - positions[-2])
90
+ if delta > max_delta:
91
+ max_delta = delta
92
+ if len(positions) >= 2:
93
+ prev = positions[-2]
94
+ if dir_flag > 0 and real < prev - 1:
95
+ reversed_flag = True
96
+ if dir_flag < 0 and real > prev + 1:
97
+ reversed_flag = True
98
+ time.sleep(step_ms / 1000)
99
+
100
+ _set_scroll_y(page_handle, target)
101
+ final = read_scroll_y(page_handle)
102
+ positions.append(final)
103
+
104
+ return {"ok": True, "startY": start_y, "targetY": target, "positions": positions, "maxDelta": max_delta, "reversed": reversed_flag}
105
+
106
+
107
+ def scroll_by(page_handle: dict, delta_px: int, opts: dict | None = None) -> dict:
108
+ """增量滚动 = 读当前 Y + scroll_to_y."""
109
+ cur = read_scroll_y(page_handle)
110
+ return scroll_to_y(page_handle, cur + delta_px, opts)
111
+
112
+
113
+ def scroll_to_element(page_handle: dict, selector: str, opts: dict | None = None) -> dict:
114
+ """滚到元素 (禁 scrollIntoView).
115
+
116
+ 算目标 → scroll_to_y → 重测 → 容差内即停, 否则最多一次同向单调修正.
117
+ opts.block='center'|'start'|'end', opts.tolerance=20
118
+ """
119
+ opts = opts or {}
120
+ block = opts.get("block", "center")
121
+ tolerance = opts.get("tolerance", 20)
122
+ import json
123
+
124
+ def compute_target():
125
+ r = operate(page_handle, {
126
+ "action": "evaluate",
127
+ "source": """(() => { const el = document.querySelector(""" + json.dumps(selector) + """);
128
+ if (!el) return null;
129
+ const r = el.getBoundingClientRect();
130
+ const vh = window.innerHeight;
131
+ let off = vh/2;
132
+ if (""" + json.dumps(block) + """==='start') off=50;
133
+ else if (""" + json.dumps(block) + """==='end') off=vh-50;
134
+ return { targetY: Math.max(0, r.top + (window.scrollY||0) - off), top: Math.round(r.top), vh };
135
+ })()""",
136
+ "_skipVisualize": True
137
+ })
138
+ return r
139
+
140
+ first = compute_target()
141
+ if not first:
142
+ return {"ok": False, "error": "ELEMENT_NOT_FOUND", "selector": selector}
143
+ trace = scroll_to_y(page_handle, first.get("targetY", 0), opts)
144
+
145
+ remeasure = compute_target()
146
+ if not remeasure:
147
+ return {"ok": False, "error": "ELEMENT_NOT_FOUND_AFTER_SCROLL", "selector": selector}
148
+ vh = first.get("vh", 800)
149
+ center = 0 if block == "start" else (vh if block == "end" else vh / 2)
150
+ in_view = abs(remeasure.get("top", 0) - center) < tolerance + 50
151
+ if in_view:
152
+ return {"ok": True, "targetY": first.get("targetY"), "inViewport": True, **trace}
153
+
154
+ fix = compute_target()
155
+ if not fix:
156
+ return {"ok": False, "error": "ELEMENT_NOT_FOUND", "selector": selector, "inViewport": False, **trace}
157
+ trace2 = scroll_to_y(page_handle, fix.get("targetY", 0), opts)
158
+ remeasure2 = compute_target()
159
+ in_view2 = False
160
+ if remeasure2:
161
+ in_view2 = abs(remeasure2.get("top", 0) - center) < tolerance + 50
162
+ return {"ok": in_view2, "targetY": fix.get("targetY"), "inViewport": in_view2, **trace, "fixTrace": trace2}
163
+
164
+
165
+ def scroll_to_end(page_handle: dict, to: str = "top", opts: dict | None = None) -> dict:
166
+ """滚到页顶/页底 (只单调).
167
+
168
+ bottom: 读 maxScrollY → scroll_to_y → 若 scrollHeight 变化则重复 (最多 5 轮).
169
+ """
170
+ if to == "top":
171
+ return scroll_to_y(page_handle, 0, opts)
172
+ last_max = -1
173
+ trace = None
174
+ for _ in range(5):
175
+ max_y = read_max_scroll_y(page_handle)
176
+ if max_y == last_max:
177
+ break
178
+ last_max = max_y
179
+ trace = scroll_to_y(page_handle, max_y, opts)
180
+ return trace or {"ok": True, "startY": 0, "targetY": 0, "positions": [], "maxDelta": 0, "reversed": False}
@@ -0,0 +1,103 @@
1
+ """web/upload — 通用文件 / 文件夹上传 (路由无关, 全走 operate).
2
+
3
+ 忠实复刻 lib/web/upload.js。
4
+ 统一 operate(pageHandle, {action:'setInputFiles'}), operate 内部按路由分流:
5
+ cdp 吃本地 paths (playwright 自动展开目录), extension 吃 base64 files。
6
+ upload 只负责把调用方意图整理成 operate 能懂的参数:
7
+ 总是同时提供 paths (给 cdp) + files (给 extension, base64 展开后)。
8
+
9
+ 依赖: browser (operate), os + pathlib
10
+ """
11
+ from __future__ import annotations
12
+ import base64
13
+ import mimetypes
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import sys
19
+ sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else __file__.rsplit("/", 1)[0])
20
+ from browser import operate # noqa: E402
21
+
22
+ MIME_MAP = {
23
+ "png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "gif": "image/gif",
24
+ "webp": "image/webp", "svg": "image/svg+xml", "ico": "image/x-icon", "bmp": "image/bmp",
25
+ "pdf": "application/pdf", "txt": "text/plain", "json": "application/json", "csv": "text/csv",
26
+ "html": "text/html", "htm": "text/html", "xml": "application/xml", "zip": "application/zip",
27
+ "gz": "application/gzip", "tar": "application/x-tar",
28
+ "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
29
+ "xls": "application/vnd.ms-excel",
30
+ "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
31
+ "doc": "application/msword", "mp4": "video/mp4", "mp3": "audio/mpeg",
32
+ "js": "text/javascript", "ts": "text/plain", "css": "text/css", "md": "text/markdown",
33
+ }
34
+
35
+
36
+ def _guess_mime(name: str) -> str:
37
+ ext = name.lower().split(".")[-1] if "." in name else ""
38
+ return MIME_MAP.get(ext, "application/octet-stream")
39
+
40
+
41
+ def _read_file_entry(full_path: str, webkit_relative_path: str | None = None) -> dict:
42
+ p = Path(full_path)
43
+ data = base64.b64encode(p.read_bytes()).decode("ascii")
44
+ return {
45
+ "name": p.name,
46
+ "mime": _guess_mime(p.name),
47
+ "data": data,
48
+ "webkitRelativePath": webkit_relative_path or p.name,
49
+ }
50
+
51
+
52
+ def expand_paths(paths: list[str]) -> list[dict]:
53
+ """展开本地路径数组 (文件/目录混合) → base64 文件 entry 数组.
54
+
55
+ 插件分支用 (content 读不到本地文件, 必须转 base64).
56
+ CDP 分支不需要 (直传路径给 playwright).
57
+ """
58
+ out = []
59
+ for root in paths:
60
+ p = Path(root)
61
+ if not p.exists():
62
+ raise RuntimeError(f"upload: path not found: {root}")
63
+ if p.is_file():
64
+ out.append(_read_file_entry(str(p), p.name))
65
+ elif p.is_dir():
66
+ for f in sorted(p.rglob("*")):
67
+ if f.is_file():
68
+ rel = str(f.relative_to(p.parent))
69
+ out.append(_read_file_entry(str(f), rel))
70
+ else:
71
+ raise RuntimeError(f"upload: not a file or directory: {root}")
72
+ return out
73
+
74
+
75
+ def upload_files(page_handle: dict, options: dict | None = None) -> dict:
76
+ """上传文件 / 文件夹到 <input type=file>.
77
+
78
+ options:
79
+ selector: 文件输入框 selector (必需)
80
+ paths: 本地路径数组, 文件或目录, 可混合 (必需)
81
+ 返回: {ok, selector, requested, built, totalBytes}
82
+ """
83
+ if not page_handle:
84
+ raise ValueError("upload_files: page_handle required")
85
+ opts = options or {}
86
+ selector = opts.get("selector")
87
+ if not selector:
88
+ raise ValueError("upload_files: selector required")
89
+ paths = opts.get("paths", [])
90
+ if not paths:
91
+ raise ValueError("upload_files: paths required")
92
+
93
+ files = expand_paths(paths)
94
+ total_bytes = sum(len(f["data"]) * 3 // 4 for f in files)
95
+
96
+ r = operate(page_handle, {"action": "setInputFiles", "selector": selector, "paths": paths, "files": files})
97
+ return {
98
+ "ok": r.get("ok") is not False if isinstance(r, dict) else False,
99
+ "selector": selector,
100
+ "requested": len(paths),
101
+ "built": len(files),
102
+ "totalBytes": total_bytes,
103
+ }
@@ -0,0 +1,47 @@
1
+ """web/visual-target — 从 action 参数生成可视化目标声明.
2
+
3
+ 忠实复刻 lib/web/visual-target.js。
4
+ 唯一职责: 根据 operate 的 action 类型, 推断出"这一步在操作什么目标"。
5
+ 返回 { kind, ... } 声明, 供 interact.highlight 解析画框。
6
+
7
+ 依赖: 无 (纯函数, 不调 operate).
8
+ """
9
+ from __future__ import annotations
10
+ from typing import Any
11
+
12
+
13
+ def visual_target_from_action(action: str, rest: dict | None = None) -> dict | None:
14
+ """从 action + rest 参数推断可视化目标.
15
+
16
+ kind 类型:
17
+ selector -> { kind:'selector', selector, required }
18
+ text -> { kind:'text', text, required }
19
+ point -> { kind:'point', x, y, required }
20
+ rect -> { kind:'rect', x, y, w, h, required }
21
+ pair -> { kind:'pair', source, target, required }
22
+ """
23
+ rest = rest or {}
24
+
25
+ # 交互类: 有 selector 目标
26
+ if action in ("click", "fill", "type", "hover", "focus", "check", "uncheck",
27
+ "selectOption", "dblclick", "boundingBox", "waitForSelector", "setInputFiles"):
28
+ if rest.get("selector"):
29
+ return {"kind": "selector", "selector": rest["selector"], "required": True}
30
+ return None
31
+
32
+ # 拖拽: 双目标
33
+ if action == "dragTo":
34
+ return {"kind": "pair", "source": rest.get("source"), "target": rest.get("target"), "required": True}
35
+
36
+ # evaluate: 看 rest 是否传了 visualTarget
37
+ if action == "evaluate":
38
+ return rest.get("visualTarget") or None
39
+
40
+ # 鼠标坐标: 有坐标但无元素
41
+ if action == "mouseMove":
42
+ if rest.get("x") is not None and rest.get("y") is not None:
43
+ return {"kind": "point", "x": rest["x"], "y": rest["y"], "required": False}
44
+ return None
45
+
46
+ # 无目标类
47
+ return None
@@ -0,0 +1,91 @@
1
+ """web/visualize — operate 可视化注入层 (路由无关).
2
+
3
+ 忠实复刻 lib/web/visualize.js。
4
+ 通过 open({visualize:True}) 开启。标记挂到 browserHandle→pageHandle,
5
+ operate 自动在执行前/后注入高亮 + toast,调用方零改动。
6
+
7
+ 原则:高亮目标 + 知道在干什么,不显示提取到的内容。
8
+ 高亮失败不静默: required 目标高亮失败 → 抛错, 不执行主 action。
9
+
10
+ 依赖: browser (operate), interact (highlight/toast), visual_target
11
+ """
12
+ from __future__ import annotations
13
+ from typing import Any
14
+
15
+ import sys
16
+ sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else __file__.rsplit("/", 1)[0])
17
+ from interact import toast, highlight # noqa: E402
18
+ from visual_target import visual_target_from_action # noqa: E402
19
+
20
+
21
+ def _desc(sel: Any) -> str:
22
+ if sel is None:
23
+ return "?"
24
+ s = str(sel)
25
+ if len(s) > 24:
26
+ return s[:24] + chr(0x2026)
27
+ return s
28
+
29
+
30
+ def _before_text(action: str, rest: dict) -> str | None:
31
+ texts = {
32
+ "click": "点击 ",
33
+ "fill": "填写 ",
34
+ "type": "输入 ",
35
+ "press": "按键 " + str(rest.get("key", "")),
36
+ "hover": "悬停 ",
37
+ "focus": "聚焦 ",
38
+ "check": "勾选 ",
39
+ "uncheck": "取消勾选 ",
40
+ "selectOption": "选择 ",
41
+ "dblclick": "双击 ",
42
+ "goto": "导航到 ",
43
+ "reload": "刷新页面",
44
+ "goBack": "后退",
45
+ "goForward": "前进",
46
+ "evaluate": rest.get("label") or "执行 evaluate",
47
+ "waitForSelector": "等待 ",
48
+ "setInputFiles": "上传文件 ",
49
+ }
50
+ if action in ("click", "fill", "type", "hover", "focus", "check", "uncheck", "selectOption", "dblclick", "waitForSelector", "setInputFiles"):
51
+ return (texts.get(action, "") + _desc(rest.get("selector"))).strip()
52
+ if action == "press":
53
+ return "按键 " + str(rest.get("key", ""))
54
+ if action == "goto":
55
+ return "导航到 " + str(rest.get("url", ""))[:40]
56
+ if action == "dragTo":
57
+ return "拖拽 " + _desc(rest.get("source")) + " \u2192 " + _desc(rest.get("target"))
58
+ if action in ("waitForURL", "waitForLoadState"):
59
+ return None
60
+ return texts.get(action)
61
+
62
+
63
+ def create_visualizer(opts: dict | None = None) -> dict:
64
+ """创建可视化器。
65
+
66
+ opts:
67
+ toastMs: toast 显示时间 (默认 2000)
68
+ 返回: {beforeAction, afterAction, toastMs}
69
+ """
70
+ opts = opts or {}
71
+ toast_ms = opts.get("toastMs", 2000)
72
+
73
+ async def do_before_action(page_handle: dict, action: str, rest: dict) -> None:
74
+ text = _before_text(action, rest)
75
+ if text:
76
+ try:
77
+ await toast(page_handle, text, {"level": "info", "ms": toast_ms})
78
+ except Exception:
79
+ pass
80
+ target = visual_target_from_action(action, rest)
81
+ if target:
82
+ r = await highlight(page_handle, target)
83
+ if not r.get("ok") and target.get("required", False):
84
+ reason = r.get("reason", "unknown")
85
+ raise RuntimeError(f"highlight failed (required): {reason} - action \"{action}\" aborted")
86
+
87
+ return {
88
+ "toastMs": toast_ms,
89
+ "beforeAction": do_before_action,
90
+ "afterAction": None,
91
+ }
@@ -0,0 +1,63 @@
1
+ // -*- coding: utf-8 -*-
2
+ //
3
+ // state.cjs — 全局运行时状态文件 (跨路由公共数据)。
4
+ //
5
+ // 分两层:
6
+ // - 本文件 (lib/state.cjs): 跨路由公共运行时数据, 不属于任何单条路由。
7
+ // 如: 当前活跃 profile、主控进程 pid、启动时间。
8
+ // - 分路由 state: lib/cdp/state.cjs (wsEndpoint/port)、
9
+ // lib/extension/state.cjs (WS 端口/心跳)。各管各的复用判据。
10
+ //
11
+ // 文件位置: <userDataDir>/.browser-op-state.json
12
+
13
+ 'use strict'
14
+
15
+ const fs = require('node:fs')
16
+ const path = require('node:path')
17
+
18
+ const STATE_FILENAME = '.browser-op-state.json'
19
+
20
+ function statePath (userDataDir) {
21
+ return path.join(userDataDir, STATE_FILENAME)
22
+ }
23
+
24
+ /** 读全局状态。返回 { profile, mainPid, startedAt, ... } 或 null。 */
25
+ function readGlobal (userDataDir) {
26
+ try {
27
+ const p = statePath(userDataDir)
28
+ if (!fs.existsSync(p)) return null
29
+ return JSON.parse(fs.readFileSync(p, 'utf8'))
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /** 写全局状态 (主进程 open 成功后调)。 */
36
+ function writeGlobal (userDataDir, info) {
37
+ try {
38
+ const p = statePath(userDataDir)
39
+ fs.mkdirSync(path.dirname(p), { recursive: true })
40
+ fs.writeFileSync(p, JSON.stringify({
41
+ profile: info.profile || null,
42
+ mainPid: info.mainPid || process.pid,
43
+ startedAt: info.startedAt || Date.now(),
44
+ updatedAt: Date.now()
45
+ }, null, 2))
46
+ } catch { /* 写失败不阻塞主流程 */ }
47
+ }
48
+
49
+ /** 清全局状态 (close 后调)。 */
50
+ function clearGlobal (userDataDir) {
51
+ try {
52
+ const p = statePath(userDataDir)
53
+ if (fs.existsSync(p)) fs.unlinkSync(p)
54
+ } catch { /* ignore */ }
55
+ }
56
+
57
+ module.exports = {
58
+ STATE_FILENAME,
59
+ statePath,
60
+ readGlobal,
61
+ writeGlobal,
62
+ clearGlobal
63
+ }
@@ -0,0 +1,153 @@
1
+ // -*- coding: utf-8 -*-
2
+ //
3
+ // web/behavior — 人类行为模拟(路由无关,全走 operate)
4
+ //
5
+ // 完整复刻自 bee/resources/sandbox/builtin-libs/web/behavior.js。
6
+ // 贝塞尔鼠标轨迹 / 随机点击偏移 / 打字节奏 / 平滑滚动。
7
+ // 所有页面动作统一走 operate(pageHandle, {action}),本模块只编排动作序列。
8
+ //
9
+ // 依赖:./browser(operate)
10
+
11
+ 'use strict'
12
+
13
+ const { operate } = require('./browser')
14
+
15
+ // ─── 数学工具 ───
16
+ function bezier (p0, p1, p2, p3, t) {
17
+ const u = 1 - t
18
+ return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
19
+ }
20
+
21
+ // 生成贝塞尔轨迹点(含随机抖动)
22
+ function trajectory (from, to, steps) {
23
+ const pts = []
24
+ const mx = (from.x + to.x) / 2 + (Math.random() - 0.5) * Math.abs(to.x - from.x) * 0.6
25
+ const my = (from.y + to.y) / 2 + (Math.random() - 0.5) * Math.abs(to.y - from.y) * 0.6
26
+ const c1 = { x: from.x + (mx - from.x) * 0.3, y: from.y + (my - from.y) * 0.3 }
27
+ const c2 = { x: to.x + (mx - to.x) * 0.3, y: to.y + (my - to.y) * 0.3 }
28
+ for (let i = 0; i <= steps; i++) {
29
+ const t = i / steps
30
+ pts.push({
31
+ x: bezier(from.x, c1.x, c2.x, to.x, t) + (Math.random() - 0.5) * 2,
32
+ y: bezier(from.y, c1.y, c2.y, to.y, t) + (Math.random() - 0.5) * 2
33
+ })
34
+ }
35
+ return pts
36
+ }
37
+
38
+ function randomPointInRect (rect) {
39
+ return {
40
+ x: rect.x + rect.width * (0.3 + Math.random() * 0.4),
41
+ y: rect.y + rect.height * (0.3 + Math.random() * 0.4)
42
+ }
43
+ }
44
+
45
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
46
+
47
+ // 沿贝塞尔轨迹多点 mouseMove 注入(两条路由都支持 operate mouseMove)
48
+ async function moveTrajectory (pageHandle, from, to) {
49
+ const pts = trajectory(from, to, 10 + Math.floor(Math.random() * 6))
50
+ for (const pt of pts) {
51
+ await operate(pageHandle, { action: 'mouseMove', x: pt.x, y: pt.y })
52
+ await sleep(8 + Math.random() * 16)
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 创建绑定到 pageHandle 的行为模拟器。
58
+ * @param {object} pageHandle - 单个 page 句柄
59
+ * @returns {object} moveTo/click/clickElement/typeText/typeInElement/scrollDown/scrollToBottom/scrollToElement
60
+ */
61
+ function createBehavior (pageHandle) {
62
+ if (!pageHandle) throw new Error('createBehavior: pageHandle required')
63
+ let lastPos = { x: 100, y: 100 }
64
+
65
+ async function moveTo (x, y) {
66
+ await moveTrajectory(pageHandle, lastPos, { x, y })
67
+ lastPos = { x, y }
68
+ }
69
+
70
+ async function click (x, y, options = {}) {
71
+ await moveTo(x, y)
72
+ await sleep(50 + Math.random() * 100)
73
+ await operate(pageHandle, { action: 'click', x, y })
74
+ await sleep(options.delay || (50 + Math.random() * 100))
75
+ }
76
+
77
+ async function clickElement (selector, timeout = 10000) {
78
+ await operate(pageHandle, { action: 'waitForSelector', selector, timeout })
79
+ const boxR = await operate(pageHandle, { action: 'boundingBox', selector })
80
+ const box = (boxR && (boxR.value || boxR)) || null
81
+ if (!box || box.width == null) {
82
+ // 拿不到 bbox(某些元素/路由回退):直接 selector 点击
83
+ await operate(pageHandle, { action: 'click', selector })
84
+ await sleep(50 + Math.random() * 100)
85
+ return
86
+ }
87
+ const pt = randomPointInRect({ x: box.x, y: box.y, width: box.width, height: box.height })
88
+ await click(pt.x, pt.y)
89
+ }
90
+
91
+ async function typeText (text, delay = 100) {
92
+ for (const ch of text) {
93
+ await operate(pageHandle, { action: 'press', selector: 'body', key: ch })
94
+ let d = delay * (0.5 + Math.random())
95
+ if (Math.random() < 0.1) d += 200 + Math.random() * 400
96
+ await sleep(d)
97
+ }
98
+ }
99
+
100
+ async function typeInElement (selector, text, options = {}) {
101
+ const delay = options.delay != null ? options.delay : 100
102
+ const timeout = options.timeout != null ? options.timeout : 10000
103
+ await operate(pageHandle, { action: 'waitForSelector', selector, timeout })
104
+ await operate(pageHandle, { action: 'click', selector })
105
+ await operate(pageHandle, { action: 'type', selector, value: text, delay })
106
+ }
107
+
108
+ async function scrollDown (pixels = 400) {
109
+ const step = 30 + Math.floor(Math.random() * 30)
110
+ await operate(pageHandle, {
111
+ action: 'evaluate',
112
+ source: '(p) => new Promise((r) => {\n' +
113
+ ' let done = 0\n' +
114
+ ' const t = setInterval(() => {\n' +
115
+ ' window.scrollBy(0, ' + step + ')\n' +
116
+ ' done += ' + step + '\n' +
117
+ ' if (done >= p) { clearInterval(t); r() }\n' +
118
+ ' }, 30 + Math.random() * 30)\n' +
119
+ '})',
120
+ args: pixels
121
+ })
122
+ }
123
+
124
+ async function scrollToBottom () {
125
+ await operate(pageHandle, {
126
+ action: 'evaluate',
127
+ source: '() => new Promise((resolve) => {\n' +
128
+ ' let last = -1\n' +
129
+ ' const t = setInterval(() => {\n' +
130
+ ' window.scrollBy(0, 100 + Math.random() * 100)\n' +
131
+ ' const h = document.body.scrollHeight\n' +
132
+ ' if (h === last) { clearInterval(t); resolve() }\n' +
133
+ ' last = h\n' +
134
+ ' }, 150)\n' +
135
+ '})'
136
+ })
137
+ }
138
+
139
+ async function scrollToElement (selector, timeout = 10000) {
140
+ await operate(pageHandle, {
141
+ action: 'evaluate',
142
+ source: '(s) => { const el = document.querySelector(s); if (el) el.scrollIntoView({ behavior: "smooth", block: "center" }) }',
143
+ args: selector
144
+ })
145
+ }
146
+
147
+ return {
148
+ moveTo, click, clickElement, typeText, typeInElement,
149
+ scrollDown, scrollToBottom, scrollToElement
150
+ }
151
+ }
152
+
153
+ module.exports = { createBehavior }