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.
- package/README.md +213 -0
- package/browser-op/backend/browserd.cjs +1004 -0
- package/browser-op/backend/rpc-client.cjs +64 -0
- package/browser-op/backend/state.cjs +51 -0
- package/browser-op/cdp/capture-inject.js +426 -0
- package/browser-op/cdp/capture-inject.ts +426 -0
- package/browser-op/cdp/capture-service.cjs +172 -0
- package/browser-op/cdp/chrome-launcher.cjs +370 -0
- package/browser-op/cdp/chrome-path.cjs +57 -0
- package/browser-op/cdp/state.cjs +89 -0
- package/browser-op/extension/extension-detect.cjs +228 -0
- package/browser-op/extension/server.cjs +197 -0
- package/browser-op/extension/service.cjs +228 -0
- package/browser-op/extension/state.cjs +78 -0
- package/browser-op/index.cjs +389 -0
- package/browser-op/package.json +17 -0
- package/browser-op/py/behavior.py +138 -0
- package/browser-op/py/browser.py +340 -0
- package/browser-op/py/captcha.py +115 -0
- package/browser-op/py/crawler.py +125 -0
- package/browser-op/py/examples/01_open_and_probe.py +48 -0
- package/browser-op/py/examples/02_reuse_and_probe.py +66 -0
- package/browser-op/py/examples/03_interact.py +66 -0
- package/browser-op/py/find.py +150 -0
- package/browser-op/py/honeypot.py +73 -0
- package/browser-op/py/humanize.py +392 -0
- package/browser-op/py/image.py +186 -0
- package/browser-op/py/interact.py +193 -0
- package/browser-op/py/markdown.py +38 -0
- package/browser-op/py/pyproject.toml +32 -0
- package/browser-op/py/ready.py +208 -0
- package/browser-op/py/scroll.py +180 -0
- package/browser-op/py/upload.py +103 -0
- package/browser-op/py/visual_target.py +47 -0
- package/browser-op/py/visualize.py +91 -0
- package/browser-op/state.cjs +63 -0
- package/browser-op/web/behavior.js +153 -0
- package/browser-op/web/browser.js +231 -0
- package/browser-op/web/captcha.js +85 -0
- package/browser-op/web/crawler.js +109 -0
- package/browser-op/web/find.js +147 -0
- package/browser-op/web/honeypot.js +68 -0
- package/browser-op/web/humanize.js +522 -0
- package/browser-op/web/image.js +177 -0
- package/browser-op/web/interact.js +169 -0
- package/browser-op/web/markdown.js +80 -0
- package/browser-op/web/ready.js +295 -0
- package/browser-op/web/scroll.js +167 -0
- package/browser-op/web/upload.js +116 -0
- package/browser-op/web/visual-runtime.inject.cjs +6 -0
- package/browser-op/webplater/.env.example +7 -0
- package/browser-op/webplater/ARCHITECTURE.md +102 -0
- package/browser-op/webplater/dist/chrome-mv3/assets/popup-BUZEUmsx.css +1 -0
- package/browser-op/webplater/dist/chrome-mv3/background.js +2 -0
- package/browser-op/webplater/dist/chrome-mv3/capture.js +310 -0
- package/browser-op/webplater/dist/chrome-mv3/chunks/_virtual_wxt-html-plugins-DPbbfBKe.js +1 -0
- package/browser-op/webplater/dist/chrome-mv3/chunks/offscreen-CFXYw9Mo.js +1 -0
- package/browser-op/webplater/dist/chrome-mv3/chunks/popup-C-lpxZZO.js +1 -0
- package/browser-op/webplater/dist/chrome-mv3/content-scripts/content.js +7 -0
- package/browser-op/webplater/dist/chrome-mv3/manifest.json +1 -0
- package/browser-op/webplater/dist/chrome-mv3/offscreen.html +16 -0
- package/browser-op/webplater/dist/chrome-mv3/popup.html +31 -0
- package/browser-op/webplater/entrypoints/background.ts +938 -0
- package/browser-op/webplater/entrypoints/content.ts +1150 -0
- package/browser-op/webplater/entrypoints/offscreen/index.html +15 -0
- package/browser-op/webplater/entrypoints/offscreen/main.ts +161 -0
- package/browser-op/webplater/entrypoints/popup/index.html +29 -0
- package/browser-op/webplater/entrypoints/popup/main.ts +61 -0
- package/browser-op/webplater/entrypoints/popup/style.css +100 -0
- package/browser-op/webplater/lib/snapshot.ts +352 -0
- package/browser-op/webplater/package.json +29 -0
- package/browser-op/webplater/pnpm-lock.yaml +3411 -0
- package/browser-op/webplater/public/capture.js +310 -0
- package/browser-op/webplater/scripts/publish-extension.mjs +176 -0
- package/browser-op/webplater/tsconfig.json +19 -0
- package/browser-op/webplater/wxt.config.ts +34 -0
- package/dist/actions.md +102 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +278 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +94 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +277 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +61 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +119 -0
- package/dist/config.js.map +1 -0
- package/dist/protocol.d.ts +195 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +11 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server.d.ts +66 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +259 -0
- package/dist/server.js.map +1 -0
- package/package.json +78 -0
- package/schemas/browser.clearCookies.schema.json +13 -0
- package/schemas/browser.close.schema.json +9 -0
- package/schemas/browser.getCookies.schema.json +13 -0
- package/schemas/browser.getDownload.schema.json +15 -0
- package/schemas/browser.health.schema.json +9 -0
- package/schemas/browser.listDownloads.schema.json +16 -0
- package/schemas/browser.listTabs.schema.json +9 -0
- package/schemas/browser.newTab.schema.json +15 -0
- package/schemas/browser.open.schema.json +15 -0
- package/schemas/browser.operate.schema.json +15 -0
- package/schemas/browser.reuseTab.schema.json +15 -0
- package/schemas/browser.setCookies.schema.json +15 -0
- package/schemas/browser.waitFor.schema.json +15 -0
- package/schemas/browser.waitForDownload.schema.json +15 -0
- package/skills/browser/SKILL.md +110 -0
- package/skills/browser/references/collect.md +163 -0
- package/skills/browser/references/high-risk.md +161 -0
- package/skills/browser/references/operate-actions.md +92 -0
- 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 }
|