jvlib 0.0.1__tar.gz
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.
- jvlib-0.0.1/PKG-INFO +7 -0
- jvlib-0.0.1/README.md +0 -0
- jvlib-0.0.1/pyproject.toml +24 -0
- jvlib-0.0.1/src/jvlib/__init__.py +0 -0
- jvlib-0.0.1/src/jvlib/cli.py +18 -0
- jvlib-0.0.1/src/jvlib/commands/web/__init__.py +425 -0
- jvlib-0.0.1/src/jvlib/commands/web/client.py +110 -0
- jvlib-0.0.1/src/jvlib/commands/web/session.py +31 -0
- jvlib-0.0.1/src/jvlib/commands/web/utils.py +28 -0
jvlib-0.0.1/PKG-INFO
ADDED
jvlib-0.0.1/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jvlib"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "AI-native browser automation toolkit: screenshot, PDF, element interaction"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"typer>=0.26.3",
|
|
9
|
+
"playwright>=1.40.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
jvlib = "jvlib.cli:app"
|
|
14
|
+
|
|
15
|
+
[pypi]
|
|
16
|
+
username = "__token__"
|
|
17
|
+
password = "pypi-AgEIcHlwaS5vcmcCJDNhZjhhZjhmLWYyOWUtNGRlYi1hNGQ5LTA2OTgwMzkwZTNmMwACKlszLCI0ZmFhNTgzYS04YjViLTQ4NTctOWU3ZC03NDk5OGQ3MDc4NDEiXQAABiASR65E2MG5ZZ7zSEP7WVZovZ5L7v-UGr8F1X2uo-Y0BA"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/jvlib"]
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""jv-ai CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from jvlib.commands.web import web_app
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="jv-ai: AI-native browser automation toolkit")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.callback()
|
|
11
|
+
def main():
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
app.add_typer(web_app)
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
app()
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""jv web 子命令:浏览器自动化工具."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from jvlib.commands.web import client, utils
|
|
9
|
+
from jvlib.commands.web.session import clear_session, get_session, set_session
|
|
10
|
+
|
|
11
|
+
web_app = typer.Typer(name="web", help="浏览器自动化(WebBridge)")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@web_app.callback()
|
|
15
|
+
def web_main():
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@web_app.command(name="status")
|
|
20
|
+
def web_status():
|
|
21
|
+
"""检查 WebBridge daemon 和扩展状态."""
|
|
22
|
+
st = client.check_status()
|
|
23
|
+
running = st.get("running", False)
|
|
24
|
+
connected = st.get("extension_connected", False)
|
|
25
|
+
|
|
26
|
+
if running and connected:
|
|
27
|
+
typer.echo(f"[OK] WebBridge 就绪")
|
|
28
|
+
typer.echo(f" version: {st.get('version', 'unknown')}")
|
|
29
|
+
typer.echo(f" extension: {st.get('extension_version', 'unknown')}")
|
|
30
|
+
typer.echo(f" port: {st.get('port', 10086)}")
|
|
31
|
+
typer.echo(f" session: {get_session()}")
|
|
32
|
+
elif running and not connected:
|
|
33
|
+
typer.echo("[WARN] Daemon 运行中,但浏览器扩展未连接")
|
|
34
|
+
typer.echo(" 请检查浏览器扩展是否已启用并刷新页面")
|
|
35
|
+
else:
|
|
36
|
+
typer.echo("[ERR] WebBridge 未运行")
|
|
37
|
+
typer.echo(" 请运行: ~/.kimi-webbridge/bin/kimi-webbridge start")
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@web_app.command(name="open")
|
|
42
|
+
def web_open(
|
|
43
|
+
url: str = typer.Argument(..., help="要打开的 URL"),
|
|
44
|
+
name: str = typer.Option(None, "--name", "-n", help="自定义 session 名称(默认 jv-web)"),
|
|
45
|
+
):
|
|
46
|
+
"""新标签页打开网页."""
|
|
47
|
+
if name:
|
|
48
|
+
set_session(name)
|
|
49
|
+
resp = client.navigate(url, new_tab=True, group_title="jv web")
|
|
50
|
+
data = resp.get("data", {})
|
|
51
|
+
if data.get("success"):
|
|
52
|
+
typer.echo(f"[OK] 已打开: {data.get('url')}")
|
|
53
|
+
typer.echo(f" tabId: {data.get('tabId')}")
|
|
54
|
+
typer.echo(f" session: {get_session()}")
|
|
55
|
+
else:
|
|
56
|
+
typer.echo(f"[ERR] 打开失败: {utils.fmt_json(data)}", err=True)
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@web_app.command(name="nav")
|
|
61
|
+
def web_nav(
|
|
62
|
+
url: str = typer.Argument(..., help="要导航到的 URL"),
|
|
63
|
+
):
|
|
64
|
+
"""当前标签页导航到指定 URL."""
|
|
65
|
+
resp = client.navigate(url, new_tab=False)
|
|
66
|
+
data = resp.get("data", {})
|
|
67
|
+
if data.get("success"):
|
|
68
|
+
typer.echo(f"[OK] 已导航: {data.get('url')}")
|
|
69
|
+
else:
|
|
70
|
+
typer.echo(f"[ERR] 导航失败: {utils.fmt_json(data)}", err=True)
|
|
71
|
+
raise typer.Exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@web_app.command(name="snap")
|
|
75
|
+
def web_snap(
|
|
76
|
+
raw: bool = typer.Option(False, "--raw", "-r", help="输出原始 JSON"),
|
|
77
|
+
):
|
|
78
|
+
"""获取当前页面结构快照(accessibility tree)."""
|
|
79
|
+
resp = client.snapshot()
|
|
80
|
+
data = resp.get("data", {})
|
|
81
|
+
url = data.get("url", "")
|
|
82
|
+
title = data.get("title", "")
|
|
83
|
+
tree = data.get("tree", "")
|
|
84
|
+
|
|
85
|
+
if raw:
|
|
86
|
+
typer.echo(utils.fmt_json(data))
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
typer.echo(f"URL: {url}")
|
|
90
|
+
typer.echo(f"Title: {title}")
|
|
91
|
+
if tree:
|
|
92
|
+
# tree 可能非常长,截断显示
|
|
93
|
+
tree_str = str(tree)
|
|
94
|
+
if len(tree_str) > 3000:
|
|
95
|
+
typer.echo(f"Tree: {tree_str[:3000]}... [truncated, {len(tree_str)} chars total]")
|
|
96
|
+
else:
|
|
97
|
+
typer.echo(f"Tree: {tree_str}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@web_app.command(name="click")
|
|
101
|
+
def web_click(
|
|
102
|
+
selector: str = typer.Argument(..., help="CSS 选择器或 @e 引用"),
|
|
103
|
+
):
|
|
104
|
+
"""点击页面元素."""
|
|
105
|
+
resp = client.click(selector)
|
|
106
|
+
data = resp.get("data", {})
|
|
107
|
+
if data.get("success"):
|
|
108
|
+
typer.echo(f"[OK] 已点击: <{data.get('tag', 'unknown')}>")
|
|
109
|
+
else:
|
|
110
|
+
typer.echo(f"[ERR] 点击失败: {utils.fmt_json(data)}", err=True)
|
|
111
|
+
raise typer.Exit(1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@web_app.command(name="fill")
|
|
115
|
+
def web_fill(
|
|
116
|
+
selector: str = typer.Argument(..., help="CSS 选择器或 @e 引用"),
|
|
117
|
+
text: str = typer.Argument(..., help="要填入的文本"),
|
|
118
|
+
):
|
|
119
|
+
"""在输入框/文本域填入内容."""
|
|
120
|
+
resp = client.fill(selector, text)
|
|
121
|
+
data = resp.get("data", {})
|
|
122
|
+
if data.get("success"):
|
|
123
|
+
typer.echo(f"[OK] 已填充: mode={data.get('mode', 'unknown')}")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# 失败时诊断原因
|
|
127
|
+
err_detail = _diagnose_selector(selector)
|
|
128
|
+
typer.echo(f"[ERR] 填充失败: {err_detail}", err=True)
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _diagnose_selector(selector: str) -> str:
|
|
133
|
+
"""诊断 selector 失败原因,返回具体错误信息."""
|
|
134
|
+
import json
|
|
135
|
+
|
|
136
|
+
# 1. 检查是否是 @e ref(WebBridge fill 对 @e 支持不稳定)
|
|
137
|
+
if selector.startswith("@e"):
|
|
138
|
+
# 尝试用 snapshot 看看这个 ref 是否存在
|
|
139
|
+
resp = client.snapshot()
|
|
140
|
+
snap_data = resp.get("data", {})
|
|
141
|
+
tree_str = str(snap_data.get("tree", ""))
|
|
142
|
+
if selector in tree_str:
|
|
143
|
+
return f"ref {selector} 存在于页面快照中,但 fill API 可能不支持 @e 引用。建议用 CSS 选择器替代,如: jv web eval \"document.querySelector('input').id\" 获取真实 id"
|
|
144
|
+
return f"ref {selector} 在当前页面快照中未找到"
|
|
145
|
+
|
|
146
|
+
# 2. 用 evaluate 探测元素
|
|
147
|
+
code = f"(()=>{{ const el=document.querySelector('{selector}'); if(!el) return 'not_found'; const tag=el.tagName.toLowerCase(); const editable=el.tagName==='INPUT'||el.tagName==='TEXTAREA'||el.isContentEditable; return {{tag, editable, type: el.type||null, id: el.id||null}}; }})()"
|
|
148
|
+
try:
|
|
149
|
+
resp = client.evaluate(code)
|
|
150
|
+
data = resp.get("data", {})
|
|
151
|
+
value = data.get("value")
|
|
152
|
+
if value == "not_found":
|
|
153
|
+
return f"元素未找到: {selector}"
|
|
154
|
+
if isinstance(value, dict):
|
|
155
|
+
if not value.get("editable"):
|
|
156
|
+
return f"元素存在但不可编辑: <{value.get('tag')}> (id={value.get('id')}, type={value.get('type')})"
|
|
157
|
+
return f"元素可编辑但 fill 失败: <{value.get('tag')}> (id={value.get('id')}, type={value.get('type')})"
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
return f"未知错误,selector: {selector}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@web_app.command(name="eval")
|
|
165
|
+
def web_eval(
|
|
166
|
+
code: str = typer.Argument(..., help="要执行的 JS 代码"),
|
|
167
|
+
raw: bool = typer.Option(False, "--raw", "-r", help="输出原始 JSON"),
|
|
168
|
+
):
|
|
169
|
+
"""在当前页面执行 JavaScript."""
|
|
170
|
+
resp = client.evaluate(code)
|
|
171
|
+
data = resp.get("data", {})
|
|
172
|
+
if raw:
|
|
173
|
+
typer.echo(utils.fmt_json(data))
|
|
174
|
+
return
|
|
175
|
+
typer.echo(f"type: {data.get('type', 'unknown')}")
|
|
176
|
+
value = data.get("value")
|
|
177
|
+
if isinstance(value, (dict, list)):
|
|
178
|
+
typer.echo(json.dumps(value, ensure_ascii=False, indent=2))
|
|
179
|
+
else:
|
|
180
|
+
typer.echo(f"value: {value}")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@web_app.command(name="shot")
|
|
184
|
+
def web_shot(
|
|
185
|
+
output: str = typer.Option(None, "--output", "-o", help="输出文件路径(默认桌面)"),
|
|
186
|
+
fmt: str = typer.Option("png", "--format", "-f", help="格式: png | jpeg"),
|
|
187
|
+
quality: int = typer.Option(None, "--quality", "-q", help="JPEG 质量 0-100"),
|
|
188
|
+
selector: str = typer.Option(None, "--selector", "-s", help="仅截取指定元素"),
|
|
189
|
+
):
|
|
190
|
+
"""截图并自动保存为文件(解决 base64 痛点)."""
|
|
191
|
+
from socket import timeout as SocketTimeout
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
resp = client.screenshot(format_=fmt, quality=quality, selector=selector)
|
|
195
|
+
except SocketTimeout:
|
|
196
|
+
typer.echo("[ERR] 截图超时 (15s)", err=True)
|
|
197
|
+
typer.echo(" 提示: 浏览器窗口可能在后台或被最小化,尝试将其切换到前台后重试", err=True)
|
|
198
|
+
typer.echo(" workaround: 使用 --selector 截取特定元素通常更稳定", err=True)
|
|
199
|
+
raise typer.Exit(1)
|
|
200
|
+
|
|
201
|
+
data = resp.get("data", {})
|
|
202
|
+
|
|
203
|
+
if "data" in data:
|
|
204
|
+
# base64 数据 → 自动解码保存
|
|
205
|
+
out_path = utils.pick_output_path(
|
|
206
|
+
Path(output) if output else None,
|
|
207
|
+
suffix=fmt,
|
|
208
|
+
default_name="screenshot",
|
|
209
|
+
)
|
|
210
|
+
utils.decode_base64_to_file(data["data"], out_path)
|
|
211
|
+
typer.echo(f"[OK] 截图已保存: {out_path}")
|
|
212
|
+
typer.echo(f" size: {len(data['data'])} base64 chars")
|
|
213
|
+
elif "path" in data and data["path"]:
|
|
214
|
+
# daemon 已保存到路径
|
|
215
|
+
typer.echo(f"[OK] 截图已保存: {data['path']}")
|
|
216
|
+
else:
|
|
217
|
+
typer.echo(f"[ERR] 截图失败: {utils.fmt_json(data)}", err=True)
|
|
218
|
+
raise typer.Exit(1)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@web_app.command(name="pdf")
|
|
222
|
+
def web_pdf(
|
|
223
|
+
output: str = typer.Option(None, "--output", "-o", help="输出文件路径(默认桌面)"),
|
|
224
|
+
paper: str = typer.Option("a4", "--paper", "-p", help="纸张格式: a4 | letter | legal | a3 | tabloid"),
|
|
225
|
+
landscape: bool = typer.Option(False, "--landscape", "-l", help="横向模式"),
|
|
226
|
+
):
|
|
227
|
+
"""将当前页面保存为 PDF(自动解码 base64)."""
|
|
228
|
+
from socket import timeout as SocketTimeout
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
resp = client.save_pdf(paper_format=paper, landscape=landscape)
|
|
232
|
+
except SocketTimeout:
|
|
233
|
+
typer.echo("[ERR] PDF 导出超时 (15s)", err=True)
|
|
234
|
+
typer.echo(" 提示: 浏览器窗口可能在后台或被最小化,尝试将其切换到前台后重试", err=True)
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
|
|
237
|
+
data = resp.get("data", {})
|
|
238
|
+
|
|
239
|
+
if "data" in data:
|
|
240
|
+
out_path = utils.pick_output_path(
|
|
241
|
+
Path(output) if output else None,
|
|
242
|
+
suffix="pdf",
|
|
243
|
+
default_name="page",
|
|
244
|
+
)
|
|
245
|
+
utils.decode_base64_to_file(data["data"], out_path)
|
|
246
|
+
typer.echo(f"[OK] PDF 已保存: {out_path}")
|
|
247
|
+
typer.echo(f" title: {data.get('pageTitle', 'unknown')}")
|
|
248
|
+
elif "path" in data and data["path"]:
|
|
249
|
+
typer.echo(f"[OK] PDF 已保存: {data['path']}")
|
|
250
|
+
else:
|
|
251
|
+
typer.echo(f"[ERR] PDF 保存失败: {utils.fmt_json(data)}", err=True)
|
|
252
|
+
raise typer.Exit(1)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@web_app.command(name="tabs")
|
|
256
|
+
def web_tabs(
|
|
257
|
+
raw: bool = typer.Option(False, "--raw", "-r", help="输出原始 JSON"),
|
|
258
|
+
):
|
|
259
|
+
"""列出当前 session 的所有标签页."""
|
|
260
|
+
resp = client.list_tabs()
|
|
261
|
+
data = resp.get("data", {})
|
|
262
|
+
tabs = data.get("tabs", [])
|
|
263
|
+
|
|
264
|
+
if raw:
|
|
265
|
+
typer.echo(utils.fmt_json(data))
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if not tabs:
|
|
269
|
+
typer.echo("[empty] 没有打开的标签页")
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
typer.echo(f"Session: {get_session()} | 共 {len(tabs)} 个标签页")
|
|
273
|
+
for i, t in enumerate(tabs, 1):
|
|
274
|
+
active = "*" if t.get("active") else " "
|
|
275
|
+
typer.echo(f" [{active}] {i}. {t.get('title', 'untitled')[:40]} ({t.get('url', '')[:60]})")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@web_app.command(name="close")
|
|
279
|
+
def web_close(
|
|
280
|
+
others: bool = typer.Option(False, "--others", "-o", help="关闭其他标签页,只保留当前页"),
|
|
281
|
+
):
|
|
282
|
+
"""关闭当前标签页(或关闭其他标签页)."""
|
|
283
|
+
if others:
|
|
284
|
+
resp = client.list_tabs()
|
|
285
|
+
data = resp.get("data", {})
|
|
286
|
+
tabs = data.get("tabs", [])
|
|
287
|
+
if len(tabs) <= 1:
|
|
288
|
+
typer.echo("[OK] 只有 1 个标签页,无需关闭")
|
|
289
|
+
return
|
|
290
|
+
# 找 active tab,没有就保留第一个
|
|
291
|
+
current_idx = 0
|
|
292
|
+
for i, t in enumerate(tabs):
|
|
293
|
+
if t.get("active"):
|
|
294
|
+
current_idx = i
|
|
295
|
+
break
|
|
296
|
+
current = tabs[current_idx]
|
|
297
|
+
closed = 0
|
|
298
|
+
for i, t in enumerate(tabs):
|
|
299
|
+
if i != current_idx:
|
|
300
|
+
client.find_tab(t["url"])
|
|
301
|
+
client.close_tab()
|
|
302
|
+
closed += 1
|
|
303
|
+
# 切回保留的标签页
|
|
304
|
+
client.find_tab(current["url"])
|
|
305
|
+
typer.echo(f"[OK] 已关闭 {closed} 个其他标签页")
|
|
306
|
+
return
|
|
307
|
+
|
|
308
|
+
resp = client.close_tab()
|
|
309
|
+
data = resp.get("data", {})
|
|
310
|
+
if data.get("success"):
|
|
311
|
+
typer.echo("[OK] 标签页已关闭")
|
|
312
|
+
else:
|
|
313
|
+
typer.echo(f"[ERR] 关闭失败: {utils.fmt_json(data)}", err=True)
|
|
314
|
+
raise typer.Exit(1)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@web_app.command(name="wait")
|
|
318
|
+
def web_wait(
|
|
319
|
+
selector: str = typer.Argument(None, help="要等待的 CSS 选择器(与 --navigation 二选一)"),
|
|
320
|
+
navigation: bool = typer.Option(False, "--navigation", "-n", help="等待页面导航完成(URL 变化)"),
|
|
321
|
+
timeout: float = typer.Option(10.0, "--timeout", "-t", help="最长等待秒数"),
|
|
322
|
+
interval: float = typer.Option(0.5, "--interval", "-i", help="轮询间隔秒数"),
|
|
323
|
+
):
|
|
324
|
+
"""等待元素出现或页面导航完成(替代玄学 sleep)."""
|
|
325
|
+
import time
|
|
326
|
+
|
|
327
|
+
if not selector and not navigation:
|
|
328
|
+
typer.echo("[ERR] 请指定 selector 或 --navigation", err=True)
|
|
329
|
+
raise typer.Exit(1)
|
|
330
|
+
|
|
331
|
+
if selector:
|
|
332
|
+
# 等待元素出现
|
|
333
|
+
code = f"(()=>{{ return !!document.querySelector('{selector}'); }})()"
|
|
334
|
+
t0 = time.time()
|
|
335
|
+
while time.time() - t0 < timeout:
|
|
336
|
+
resp = client.evaluate(code)
|
|
337
|
+
data = resp.get("data", {})
|
|
338
|
+
if data.get("value"):
|
|
339
|
+
elapsed = time.time() - t0
|
|
340
|
+
typer.echo(f"[OK] 元素已出现: {selector} ({elapsed:.2f}s)")
|
|
341
|
+
return
|
|
342
|
+
time.sleep(interval)
|
|
343
|
+
typer.echo(f"[TIMEOUT] 元素未出现: {selector} ( waited {timeout}s )", err=True)
|
|
344
|
+
raise typer.Exit(1)
|
|
345
|
+
|
|
346
|
+
if navigation:
|
|
347
|
+
# 等待页面加载完成 (readyState === 'complete')
|
|
348
|
+
code = "document.readyState"
|
|
349
|
+
t0 = time.time()
|
|
350
|
+
while time.time() - t0 < timeout:
|
|
351
|
+
resp = client.evaluate(code)
|
|
352
|
+
data = resp.get("data", {})
|
|
353
|
+
if data.get("value") == "complete":
|
|
354
|
+
elapsed = time.time() - t0
|
|
355
|
+
typer.echo(f"[OK] 页面加载完成 ({elapsed:.2f}s)")
|
|
356
|
+
return
|
|
357
|
+
time.sleep(interval)
|
|
358
|
+
typer.echo(f"[TIMEOUT] 页面未加载完成 ( waited {timeout}s )", err=True)
|
|
359
|
+
raise typer.Exit(1)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@web_app.command(name="scroll")
|
|
363
|
+
def web_scroll(
|
|
364
|
+
pixels: int = typer.Argument(..., help="滚动的像素数(正数向下,负数向上)"),
|
|
365
|
+
):
|
|
366
|
+
"""滚动页面指定像素."""
|
|
367
|
+
resp = client.evaluate(f"window.scrollBy(0, {pixels})")
|
|
368
|
+
data = resp.get("data", {})
|
|
369
|
+
if data.get("type") == "undefined":
|
|
370
|
+
direction = "向下" if pixels > 0 else "向上"
|
|
371
|
+
typer.echo(f"[OK] 已{direction}滚动 {abs(pixels)}px")
|
|
372
|
+
else:
|
|
373
|
+
typer.echo(f"[ERR] 滚动失败: {utils.fmt_json(data)}", err=True)
|
|
374
|
+
raise typer.Exit(1)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@web_app.command(name="scroll-to")
|
|
378
|
+
def web_scroll_to(
|
|
379
|
+
selector: str = typer.Argument(..., help="要滚动到的元素 CSS 选择器(不支持 @e ref,可用 click 替代)"),
|
|
380
|
+
):
|
|
381
|
+
"""滚动到指定元素位置(仅支持 CSS 选择器)."""
|
|
382
|
+
if selector.startswith("@e"):
|
|
383
|
+
typer.echo("[ERR] scroll-to 不支持 @e 引用,建议先用 'jv web click @eN' 点击元素(会自动滚动到视口)", err=True)
|
|
384
|
+
raise typer.Exit(1)
|
|
385
|
+
code = f"(()=>{{ const el=document.querySelector('{selector}'); if(el){{ el.scrollIntoView({{behavior:'smooth',block:'center'}}); return true; }} return false; }})()"
|
|
386
|
+
resp = client.evaluate(code)
|
|
387
|
+
data = resp.get("data", {})
|
|
388
|
+
if data.get("value"):
|
|
389
|
+
typer.echo(f"[OK] 已滚动到: {selector}")
|
|
390
|
+
else:
|
|
391
|
+
typer.echo(f"[ERR] 元素未找到: {selector}", err=True)
|
|
392
|
+
raise typer.Exit(1)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@web_app.command(name="switch")
|
|
396
|
+
def web_switch(
|
|
397
|
+
url: str = typer.Argument(..., help="要切换到的标签页 URL(完整或部分匹配)"),
|
|
398
|
+
):
|
|
399
|
+
"""切换到已打开的标签页."""
|
|
400
|
+
resp = client.find_tab(url)
|
|
401
|
+
data = resp.get("data", {})
|
|
402
|
+
if data.get("success"):
|
|
403
|
+
typer.echo(f"[OK] 已切换: {data.get('url', '')}")
|
|
404
|
+
else:
|
|
405
|
+
typer.echo(f"[ERR] 未找到标签页: {url}", err=True)
|
|
406
|
+
raise typer.Exit(1)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@web_app.command(name="reset")
|
|
410
|
+
def web_reset(
|
|
411
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="跳过确认"),
|
|
412
|
+
):
|
|
413
|
+
"""关闭整个 session 的所有标签页并清除 session."""
|
|
414
|
+
if not yes:
|
|
415
|
+
confirm = typer.confirm(f"确定关闭 session '{get_session()}' 的所有标签页?")
|
|
416
|
+
if not confirm:
|
|
417
|
+
typer.echo("[cancel] 已取消")
|
|
418
|
+
raise typer.Exit(0)
|
|
419
|
+
|
|
420
|
+
resp = client.close_session()
|
|
421
|
+
data = resp.get("data", {})
|
|
422
|
+
closed = data.get("closed", 0)
|
|
423
|
+
typer.echo(f"[OK] 已关闭 {closed} 个标签页")
|
|
424
|
+
clear_session()
|
|
425
|
+
typer.echo("[OK] session 已重置")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""jv web WebBridge HTTP 客户端 — 纯 Python,无 curl 依赖."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.request import Request, urlopen
|
|
7
|
+
from urllib.error import URLError
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from jvlib.commands.web.session import get_session
|
|
12
|
+
|
|
13
|
+
_DAEMON_URL = "http://127.0.0.1:10086/command"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _request(payload: dict, timeout: float = 30) -> dict:
|
|
17
|
+
"""发送 HTTP 请求到 WebBridge daemon(纯 Python urllib)."""
|
|
18
|
+
from socket import timeout as SocketTimeout
|
|
19
|
+
|
|
20
|
+
payload["session"] = get_session()
|
|
21
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
22
|
+
req = Request(
|
|
23
|
+
_DAEMON_URL,
|
|
24
|
+
data=data,
|
|
25
|
+
headers={"Content-Type": "application/json"},
|
|
26
|
+
method="POST",
|
|
27
|
+
)
|
|
28
|
+
try:
|
|
29
|
+
with urlopen(req, timeout=timeout) as resp:
|
|
30
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
31
|
+
except SocketTimeout:
|
|
32
|
+
typer.echo("[ERR] 请求超时,WebBridge 响应过慢", err=True)
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
except URLError as e:
|
|
35
|
+
typer.echo(f"[ERR] 连接 WebBridge 失败: {e}", err=True)
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
except json.JSONDecodeError as e:
|
|
38
|
+
typer.echo(f"[ERR] JSON 解析失败: {e}", err=True)
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_status() -> dict:
|
|
43
|
+
"""检查 WebBridge 状态(通过 CLI 工具)."""
|
|
44
|
+
import os
|
|
45
|
+
bin_path = os.path.expanduser("~/.kimi-webbridge/bin/kimi-webbridge")
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
[bin_path, "status"],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
encoding="utf-8",
|
|
51
|
+
errors="replace",
|
|
52
|
+
)
|
|
53
|
+
if result.returncode != 0:
|
|
54
|
+
return {"running": False, "error": result.stderr}
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(result.stdout)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
return {"running": False, "error": result.stdout}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def navigate(url: str, new_tab: bool = False, group_title: str | None = None) -> dict:
|
|
62
|
+
args = {"url": url, "newTab": new_tab}
|
|
63
|
+
if group_title:
|
|
64
|
+
args["group_title"] = group_title
|
|
65
|
+
return _request({"action": "navigate", "args": args})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def find_tab(url: str, active: bool = False) -> dict:
|
|
69
|
+
return _request({"action": "find_tab", "args": {"url": url, "active": active}})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def snapshot() -> dict:
|
|
73
|
+
return _request({"action": "snapshot", "args": {}})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def click(selector: str) -> dict:
|
|
77
|
+
return _request({"action": "click", "args": {"selector": selector}})
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def fill(selector: str, value: str) -> dict:
|
|
81
|
+
return _request({"action": "fill", "args": {"selector": selector, "value": value}})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def evaluate(code: str) -> dict:
|
|
85
|
+
return _request({"action": "evaluate", "args": {"code": code}})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def screenshot(format_: str = "png", quality: int | None = None, selector: str | None = None) -> dict:
|
|
89
|
+
args: dict[str, Any] = {"format": format_}
|
|
90
|
+
if quality is not None:
|
|
91
|
+
args["quality"] = quality
|
|
92
|
+
if selector:
|
|
93
|
+
args["selector"] = selector
|
|
94
|
+
return _request({"action": "screenshot", "args": args}, timeout=15)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def save_pdf(paper_format: str = "a4", landscape: bool = False) -> dict:
|
|
98
|
+
return _request({"action": "save_as_pdf", "args": {"paper_format": paper_format, "landscape": landscape}}, timeout=15)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def list_tabs() -> dict:
|
|
102
|
+
return _request({"action": "list_tabs", "args": {}})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def close_tab() -> dict:
|
|
106
|
+
return _request({"action": "close_tab", "args": {}})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def close_session() -> dict:
|
|
110
|
+
return _request({"action": "close_session", "args": {}})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""jv web session 管理."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
_STATE_FILE = Path.home() / ".jv" / "web_session"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _ensure_dir() -> None:
|
|
9
|
+
_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_session() -> str:
|
|
13
|
+
"""获取当前 session 名称(默认 jv-web)."""
|
|
14
|
+
_ensure_dir()
|
|
15
|
+
if _STATE_FILE.exists():
|
|
16
|
+
text = _STATE_FILE.read_text(encoding="utf-8").strip()
|
|
17
|
+
if text:
|
|
18
|
+
return text
|
|
19
|
+
return "jv-web"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def set_session(name: str) -> None:
|
|
23
|
+
"""设置当前 session 名称."""
|
|
24
|
+
_ensure_dir()
|
|
25
|
+
_STATE_FILE.write_text(name, encoding="utf-8")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def clear_session() -> None:
|
|
29
|
+
"""清除 session 状态."""
|
|
30
|
+
if _STATE_FILE.exists():
|
|
31
|
+
_STATE_FILE.unlink()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""jv web 工具函数."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def decode_base64_to_file(data: str, path: Path) -> Path:
|
|
9
|
+
"""解码 base64 数据并写入文件."""
|
|
10
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
path.write_bytes(base64.b64decode(data))
|
|
12
|
+
return path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fmt_json(data: dict) -> str:
|
|
16
|
+
"""紧凑 JSON 字符串(避免换行膨胀)."""
|
|
17
|
+
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def pick_output_path(suggested: Path | None, suffix: str, default_name: str = "webbridge") -> Path:
|
|
21
|
+
"""确定输出文件路径."""
|
|
22
|
+
if suggested:
|
|
23
|
+
return suggested
|
|
24
|
+
# 默认保存到桌面
|
|
25
|
+
desktop = Path.home() / "Desktop"
|
|
26
|
+
ts = __import__("time").time()
|
|
27
|
+
name = f"{default_name}_{int(ts)}.{suffix}"
|
|
28
|
+
return desktop / name
|