agent-webview 0.1.0__py3-none-any.whl
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.
- agent_webview/__init__.py +3 -0
- agent_webview/__main__.py +4 -0
- agent_webview/auth.py +19 -0
- agent_webview/bridge.py +94 -0
- agent_webview/cli.py +211 -0
- agent_webview/controller_api.py +319 -0
- agent_webview/cookies.py +131 -0
- agent_webview/errors.py +14 -0
- agent_webview/events.py +83 -0
- agent_webview/files.py +68 -0
- agent_webview/http_errors.py +24 -0
- agent_webview/js.py +618 -0
- agent_webview/logging.py +34 -0
- agent_webview/models.py +224 -0
- agent_webview/runtime.py +290 -0
- agent_webview/sessions.py +293 -0
- agent_webview/worker.py +141 -0
- agent_webview/worker_api.py +171 -0
- agent_webview-0.1.0.dist-info/METADATA +397 -0
- agent_webview-0.1.0.dist-info/RECORD +23 -0
- agent_webview-0.1.0.dist-info/WHEEL +4 -0
- agent_webview-0.1.0.dist-info/entry_points.txt +2 -0
- agent_webview-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_webview/auth.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from fastapi import Header, HTTPException, status
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def bearer_auth(expected_token: str) -> Callable[..., None]:
|
|
10
|
+
def authenticate(authorization: str | None = Header(default=None)) -> None:
|
|
11
|
+
scheme, _, token = (authorization or "").partition(" ")
|
|
12
|
+
if scheme.lower() != "bearer" or not secrets.compare_digest(token, expected_token):
|
|
13
|
+
raise HTTPException(
|
|
14
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
15
|
+
detail="令牌缺失或无效",
|
|
16
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
return authenticate
|
agent_webview/bridge.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from threading import Event, Lock
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from agent_webview.errors import (
|
|
9
|
+
BrowserCommandError,
|
|
10
|
+
BrowserCommandTimeoutError,
|
|
11
|
+
BrowserNotReadyError,
|
|
12
|
+
)
|
|
13
|
+
from agent_webview.events import EventBuffer
|
|
14
|
+
from agent_webview.js import command_script
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class _PendingResult:
|
|
19
|
+
event: Event
|
|
20
|
+
payload: dict[str, Any] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AgentBridge:
|
|
24
|
+
def __init__(self, events: EventBuffer) -> None:
|
|
25
|
+
self.events = events
|
|
26
|
+
self.window: Any = None
|
|
27
|
+
self.ready = Event()
|
|
28
|
+
self._command_lock = Lock()
|
|
29
|
+
self._pending_lock = Lock()
|
|
30
|
+
self._pending: dict[str, _PendingResult] = {}
|
|
31
|
+
|
|
32
|
+
def attach_window(self, window: Any) -> None:
|
|
33
|
+
self.window = window
|
|
34
|
+
|
|
35
|
+
def execute(self, code: str, *, expression: bool, timeout: float) -> Any:
|
|
36
|
+
if not self.window or not self.ready.wait(min(timeout, 5)):
|
|
37
|
+
raise BrowserNotReadyError("页面尚未就绪")
|
|
38
|
+
|
|
39
|
+
request_id = uuid4().hex
|
|
40
|
+
pending = _PendingResult(event=Event())
|
|
41
|
+
with self._pending_lock:
|
|
42
|
+
self._pending[request_id] = pending
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
with self._command_lock:
|
|
46
|
+
try:
|
|
47
|
+
self.window.run_js(
|
|
48
|
+
command_script(request_id, code, expression=expression)
|
|
49
|
+
)
|
|
50
|
+
except Exception as error:
|
|
51
|
+
raise BrowserCommandError(
|
|
52
|
+
"脚本发送失败",
|
|
53
|
+
details={"name": type(error).__name__},
|
|
54
|
+
) from error
|
|
55
|
+
if not pending.event.wait(timeout):
|
|
56
|
+
raise BrowserCommandTimeoutError(
|
|
57
|
+
f"脚本执行超过 {timeout:g} 秒"
|
|
58
|
+
)
|
|
59
|
+
payload = pending.payload or {}
|
|
60
|
+
if not payload.get("ok"):
|
|
61
|
+
error_details = payload.get("error") or {}
|
|
62
|
+
raise BrowserCommandError(
|
|
63
|
+
error_details.get("message", "脚本执行失败"),
|
|
64
|
+
details=error_details,
|
|
65
|
+
)
|
|
66
|
+
return payload.get("value")
|
|
67
|
+
finally:
|
|
68
|
+
with self._pending_lock:
|
|
69
|
+
self._pending.pop(request_id, None)
|
|
70
|
+
|
|
71
|
+
def agent_result(self, request_id: str, payload: dict[str, Any]) -> bool:
|
|
72
|
+
with self._pending_lock:
|
|
73
|
+
pending = self._pending.get(request_id)
|
|
74
|
+
if not pending:
|
|
75
|
+
return False
|
|
76
|
+
pending.payload = payload
|
|
77
|
+
pending.event.set()
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
def agent_event(self, payload: dict[str, Any]) -> bool:
|
|
81
|
+
kind = str(payload.get("kind") or "page")
|
|
82
|
+
self.events.append(kind, payload)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AgentApi:
|
|
87
|
+
def __init__(self, bridge: AgentBridge) -> None:
|
|
88
|
+
self._bridge = bridge
|
|
89
|
+
|
|
90
|
+
def agent_result(self, request_id: str, payload: dict[str, Any]) -> bool:
|
|
91
|
+
return self._bridge.agent_result(request_id, payload)
|
|
92
|
+
|
|
93
|
+
def agent_event(self, payload: dict[str, Any]) -> bool:
|
|
94
|
+
return self._bridge.agent_event(payload)
|
agent_webview/cli.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import ipaddress
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import signal
|
|
8
|
+
from collections.abc import Iterator, Sequence
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
import uvicorn
|
|
15
|
+
from filelock import FileLock, Timeout
|
|
16
|
+
from platformdirs import PlatformDirs
|
|
17
|
+
|
|
18
|
+
from agent_webview.controller_api import create_controller_app
|
|
19
|
+
from agent_webview.cookies import SnapshotStore
|
|
20
|
+
from agent_webview.files import (
|
|
21
|
+
ensure_private_parent,
|
|
22
|
+
restrict_file_permissions,
|
|
23
|
+
write_private_json,
|
|
24
|
+
)
|
|
25
|
+
from agent_webview.logging import configure_logging
|
|
26
|
+
from agent_webview.sessions import SessionManager
|
|
27
|
+
|
|
28
|
+
log = structlog.get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RuntimeFileInUseError(RuntimeError):
|
|
32
|
+
"""运行信息文件已被其他控制器占用。"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _port(value: str) -> int:
|
|
36
|
+
try:
|
|
37
|
+
port = int(value)
|
|
38
|
+
except ValueError as error:
|
|
39
|
+
raise argparse.ArgumentTypeError("端口必须是整数") from error
|
|
40
|
+
if not 1 <= port <= 65535:
|
|
41
|
+
raise argparse.ArgumentTypeError("端口必须在 1 到 65535 之间")
|
|
42
|
+
return port
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_loopback_host(host: str) -> bool:
|
|
46
|
+
normalized = host.strip().strip("[]").rstrip(".").lower()
|
|
47
|
+
if normalized == "localhost":
|
|
48
|
+
return True
|
|
49
|
+
try:
|
|
50
|
+
return ipaddress.ip_address(normalized).is_loopback
|
|
51
|
+
except ValueError:
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _base_url(host: str, port: int) -> str:
|
|
56
|
+
normalized = host.strip().strip("[]")
|
|
57
|
+
try:
|
|
58
|
+
address = ipaddress.ip_address(normalized)
|
|
59
|
+
formatted = f"[{normalized}]" if address.version == 6 else normalized
|
|
60
|
+
except ValueError:
|
|
61
|
+
formatted = normalized
|
|
62
|
+
return f"http://{formatted}:{port}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@contextmanager
|
|
66
|
+
def _claim_runtime_file(path: Path) -> Iterator[None]:
|
|
67
|
+
ensure_private_parent(path.parent)
|
|
68
|
+
lock = FileLock(f"{path}.lock")
|
|
69
|
+
try:
|
|
70
|
+
lock.acquire(timeout=0)
|
|
71
|
+
except Timeout as error:
|
|
72
|
+
raise RuntimeFileInUseError("运行信息文件已被其他服务占用") from error
|
|
73
|
+
restrict_file_permissions(Path(lock.lock_file))
|
|
74
|
+
try:
|
|
75
|
+
yield
|
|
76
|
+
finally:
|
|
77
|
+
lock.release()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
81
|
+
directories = PlatformDirs("agent-webview", appauthor=False)
|
|
82
|
+
runtime_path = directories.user_runtime_path
|
|
83
|
+
parser = argparse.ArgumentParser(description="Agent Webview 调试服务")
|
|
84
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
85
|
+
parser.add_argument("--port", type=_port, default=8765)
|
|
86
|
+
parser.add_argument("--token", default=os.getenv("AGENT_WEBVIEW_TOKEN"))
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--data-dir",
|
|
89
|
+
type=Path,
|
|
90
|
+
default=directories.user_data_path,
|
|
91
|
+
)
|
|
92
|
+
parser.add_argument(
|
|
93
|
+
"--runtime-dir",
|
|
94
|
+
type=Path,
|
|
95
|
+
default=runtime_path / "sessions",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--runtime-file",
|
|
99
|
+
type=Path,
|
|
100
|
+
default=runtime_path / "controller.json",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--print-runtime-file",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="输出运行信息文件路径后退出",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--allow-remote",
|
|
109
|
+
action="store_true",
|
|
110
|
+
help="允许监听非本机地址",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--log-level", default="INFO")
|
|
113
|
+
parser.add_argument("--json-logs", action="store_true")
|
|
114
|
+
args = parser.parse_args(argv)
|
|
115
|
+
if not _is_loopback_host(args.host) and not args.allow_remote:
|
|
116
|
+
parser.error("监听非本机地址时必须显式指定 --allow-remote")
|
|
117
|
+
return args
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _write_runtime_file(
|
|
121
|
+
path: Path,
|
|
122
|
+
*,
|
|
123
|
+
host: str,
|
|
124
|
+
port: int,
|
|
125
|
+
token: str,
|
|
126
|
+
) -> None:
|
|
127
|
+
write_private_json(
|
|
128
|
+
path,
|
|
129
|
+
{
|
|
130
|
+
"pid": os.getpid(),
|
|
131
|
+
"base_url": _base_url(host, port),
|
|
132
|
+
"token": token,
|
|
133
|
+
"docs_url": f"{_base_url(host, port)}/docs",
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _serve(args: argparse.Namespace, token: str, runtime_file: Path) -> None:
|
|
139
|
+
data_dir = args.data_dir.resolve()
|
|
140
|
+
manager = SessionManager(
|
|
141
|
+
runtime_dir=args.runtime_dir.resolve(),
|
|
142
|
+
snapshot_store=SnapshotStore(data_dir / "cookie-snapshots"),
|
|
143
|
+
log_level=args.log_level,
|
|
144
|
+
json_logs=args.json_logs,
|
|
145
|
+
)
|
|
146
|
+
original_handlers: dict[int, Any] = {}
|
|
147
|
+
try:
|
|
148
|
+
app = create_controller_app(manager, token)
|
|
149
|
+
_write_runtime_file(
|
|
150
|
+
runtime_file,
|
|
151
|
+
host=args.host,
|
|
152
|
+
port=args.port,
|
|
153
|
+
token=token,
|
|
154
|
+
)
|
|
155
|
+
log.info(
|
|
156
|
+
"服务已启动",
|
|
157
|
+
url=_base_url(args.host, args.port),
|
|
158
|
+
runtime_file=str(runtime_file),
|
|
159
|
+
)
|
|
160
|
+
server = uvicorn.Server(
|
|
161
|
+
uvicorn.Config(
|
|
162
|
+
app,
|
|
163
|
+
host=args.host,
|
|
164
|
+
port=args.port,
|
|
165
|
+
log_config=None,
|
|
166
|
+
log_level="critical",
|
|
167
|
+
access_log=False,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def defer_exit(signum: int, _: Any) -> None:
|
|
172
|
+
# 让 Uvicorn 返回后再清理敏感运行文件。
|
|
173
|
+
server.should_exit = True
|
|
174
|
+
|
|
175
|
+
handled_signals: list[int] = [int(signal.SIGINT), int(signal.SIGTERM)]
|
|
176
|
+
if hasattr(signal, "SIGBREAK"):
|
|
177
|
+
handled_signals.append(int(signal.SIGBREAK))
|
|
178
|
+
for handled_signal in handled_signals:
|
|
179
|
+
original_handlers[handled_signal] = signal.signal(
|
|
180
|
+
handled_signal,
|
|
181
|
+
defer_exit,
|
|
182
|
+
)
|
|
183
|
+
server.run()
|
|
184
|
+
finally:
|
|
185
|
+
try:
|
|
186
|
+
manager.close()
|
|
187
|
+
finally:
|
|
188
|
+
runtime_file.unlink(missing_ok=True)
|
|
189
|
+
for handled_signal, handler in original_handlers.items():
|
|
190
|
+
signal.signal(handled_signal, handler)
|
|
191
|
+
log.info("服务已停止")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main() -> None:
|
|
195
|
+
args = _parse_args()
|
|
196
|
+
if args.print_runtime_file:
|
|
197
|
+
print(args.runtime_file.resolve())
|
|
198
|
+
return
|
|
199
|
+
configure_logging(args.log_level, json_logs=args.json_logs)
|
|
200
|
+
token = args.token or secrets.token_urlsafe(32)
|
|
201
|
+
runtime_file = args.runtime_file.resolve()
|
|
202
|
+
try:
|
|
203
|
+
with _claim_runtime_file(runtime_file):
|
|
204
|
+
_serve(args, token, runtime_file)
|
|
205
|
+
except RuntimeFileInUseError:
|
|
206
|
+
log.error("运行信息文件已被其他服务占用", runtime_file=str(runtime_file))
|
|
207
|
+
raise SystemExit(2) from None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
main()
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import structlog
|
|
8
|
+
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
|
|
11
|
+
from agent_webview import __version__
|
|
12
|
+
from agent_webview.auth import bearer_auth
|
|
13
|
+
from agent_webview.http_errors import install_validation_handler
|
|
14
|
+
from agent_webview.models import (
|
|
15
|
+
CookieRestoreRequest,
|
|
16
|
+
CookieSnapshot,
|
|
17
|
+
CookieSnapshotCreateRequest,
|
|
18
|
+
CookieSnapshotDeleteResponse,
|
|
19
|
+
CookieSnapshotListResponse,
|
|
20
|
+
DomClickRequest,
|
|
21
|
+
DomDispatchRequest,
|
|
22
|
+
DomInputRequest,
|
|
23
|
+
DomListenerRequest,
|
|
24
|
+
DomQueryRequest,
|
|
25
|
+
HealthResponse,
|
|
26
|
+
InstrumentationRequest,
|
|
27
|
+
JavaScriptRequest,
|
|
28
|
+
NavigateRequest,
|
|
29
|
+
SessionCreateRequest,
|
|
30
|
+
SessionDescription,
|
|
31
|
+
SessionDestroyResponse,
|
|
32
|
+
SessionListResponse,
|
|
33
|
+
WindowStatus,
|
|
34
|
+
)
|
|
35
|
+
from agent_webview.sessions import SessionManager, WorkerResponseError
|
|
36
|
+
|
|
37
|
+
log = structlog.get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_controller_app(manager: SessionManager, token: str) -> FastAPI:
|
|
41
|
+
@asynccontextmanager
|
|
42
|
+
async def lifespan(_: FastAPI):
|
|
43
|
+
yield
|
|
44
|
+
manager.close()
|
|
45
|
+
|
|
46
|
+
app = FastAPI(
|
|
47
|
+
title="Agent Webview",
|
|
48
|
+
version=__version__,
|
|
49
|
+
description="为 Agent 提供隔离的 pywebview 调试会话。",
|
|
50
|
+
lifespan=lifespan,
|
|
51
|
+
)
|
|
52
|
+
install_validation_handler(app)
|
|
53
|
+
router = APIRouter(prefix="/v1", dependencies=[Depends(bearer_auth(token))])
|
|
54
|
+
|
|
55
|
+
@app.get("/health", response_model=HealthResponse)
|
|
56
|
+
def health() -> dict[str, bool]:
|
|
57
|
+
return {"ok": True}
|
|
58
|
+
|
|
59
|
+
@app.exception_handler(KeyError)
|
|
60
|
+
async def missing_handler(_, __: KeyError) -> JSONResponse:
|
|
61
|
+
return JSONResponse(status_code=404, content={"detail": "会话不存在"})
|
|
62
|
+
|
|
63
|
+
@app.exception_handler(FileNotFoundError)
|
|
64
|
+
async def snapshot_handler(_, __: FileNotFoundError) -> JSONResponse:
|
|
65
|
+
return JSONResponse(status_code=404, content={"detail": "快照不存在"})
|
|
66
|
+
|
|
67
|
+
@app.exception_handler(ProcessLookupError)
|
|
68
|
+
async def process_handler(_, error: ProcessLookupError) -> JSONResponse:
|
|
69
|
+
return JSONResponse(status_code=410, content={"detail": str(error)})
|
|
70
|
+
|
|
71
|
+
@app.exception_handler(WorkerResponseError)
|
|
72
|
+
async def worker_handler(_, error: WorkerResponseError) -> JSONResponse:
|
|
73
|
+
return JSONResponse(status_code=error.status_code, content=error.detail)
|
|
74
|
+
|
|
75
|
+
@app.exception_handler(httpx.HTTPError)
|
|
76
|
+
async def network_handler(_, __: httpx.HTTPError) -> JSONResponse:
|
|
77
|
+
return JSONResponse(status_code=502, content={"detail": "子进程接口不可用"})
|
|
78
|
+
|
|
79
|
+
@app.exception_handler(Exception)
|
|
80
|
+
async def unknown_handler(_, error: Exception) -> JSONResponse:
|
|
81
|
+
log.error("接口执行失败", error_type=type(error).__name__)
|
|
82
|
+
return JSONResponse(status_code=500, content={"detail": "接口执行失败"})
|
|
83
|
+
|
|
84
|
+
def call(
|
|
85
|
+
session_id: str,
|
|
86
|
+
method: str,
|
|
87
|
+
path: str,
|
|
88
|
+
*,
|
|
89
|
+
params: Any = None,
|
|
90
|
+
body: Any = None,
|
|
91
|
+
timeout: float | None = None,
|
|
92
|
+
) -> Any:
|
|
93
|
+
return manager.request(
|
|
94
|
+
manager.get(session_id),
|
|
95
|
+
method,
|
|
96
|
+
path,
|
|
97
|
+
params=params,
|
|
98
|
+
json_body=body,
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@router.get("/sessions", response_model=SessionListResponse)
|
|
103
|
+
def list_sessions() -> dict[str, Any]:
|
|
104
|
+
return {"sessions": manager.list()}
|
|
105
|
+
|
|
106
|
+
@router.post("/sessions", status_code=201, response_model=SessionDescription)
|
|
107
|
+
def create_session(request: SessionCreateRequest) -> dict[str, Any]:
|
|
108
|
+
return manager.create(request)
|
|
109
|
+
|
|
110
|
+
@router.get("/sessions/{session_id}", response_model=SessionDescription)
|
|
111
|
+
def get_session(session_id: str) -> dict[str, Any]:
|
|
112
|
+
return manager.describe(manager.get(session_id))
|
|
113
|
+
|
|
114
|
+
@router.delete("/sessions/{session_id}", response_model=SessionDestroyResponse)
|
|
115
|
+
def destroy_session(session_id: str) -> dict[str, Any]:
|
|
116
|
+
return manager.destroy(session_id)
|
|
117
|
+
|
|
118
|
+
@router.post("/sessions/{session_id}/window/show", response_model=WindowStatus)
|
|
119
|
+
def show_window(session_id: str) -> dict[str, Any]:
|
|
120
|
+
return call(session_id, "POST", "/v1/window/show")
|
|
121
|
+
|
|
122
|
+
@router.post("/sessions/{session_id}/window/hide", response_model=WindowStatus)
|
|
123
|
+
def hide_window(session_id: str) -> dict[str, Any]:
|
|
124
|
+
return call(session_id, "POST", "/v1/window/hide")
|
|
125
|
+
|
|
126
|
+
@router.post("/sessions/{session_id}/navigate")
|
|
127
|
+
def navigate(session_id: str, request: NavigateRequest) -> dict[str, Any]:
|
|
128
|
+
return call(session_id, "POST", "/v1/navigate", body=request.model_dump())
|
|
129
|
+
|
|
130
|
+
@router.post("/sessions/{session_id}/javascript/evaluate")
|
|
131
|
+
def evaluate(
|
|
132
|
+
session_id: str,
|
|
133
|
+
request: JavaScriptRequest,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
return call(
|
|
136
|
+
session_id,
|
|
137
|
+
"POST",
|
|
138
|
+
"/v1/javascript/evaluate",
|
|
139
|
+
body=request.model_dump(),
|
|
140
|
+
timeout=request.timeout + 5,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@router.post("/sessions/{session_id}/javascript/execute")
|
|
144
|
+
def execute(
|
|
145
|
+
session_id: str,
|
|
146
|
+
request: JavaScriptRequest,
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
return call(
|
|
149
|
+
session_id,
|
|
150
|
+
"POST",
|
|
151
|
+
"/v1/javascript/execute",
|
|
152
|
+
body=request.model_dump(),
|
|
153
|
+
timeout=request.timeout + 5,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
@router.post("/sessions/{session_id}/dom/query")
|
|
157
|
+
def dom_query(session_id: str, request: DomQueryRequest) -> dict[str, Any]:
|
|
158
|
+
return call(
|
|
159
|
+
session_id,
|
|
160
|
+
"POST",
|
|
161
|
+
"/v1/dom/query",
|
|
162
|
+
body=request.model_dump(),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
@router.post("/sessions/{session_id}/dom/click")
|
|
166
|
+
def dom_click(session_id: str, request: DomClickRequest) -> dict[str, Any]:
|
|
167
|
+
return call(
|
|
168
|
+
session_id,
|
|
169
|
+
"POST",
|
|
170
|
+
"/v1/dom/click",
|
|
171
|
+
body=request.model_dump(),
|
|
172
|
+
timeout=request.timeout + 5,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@router.post("/sessions/{session_id}/dom/input")
|
|
176
|
+
def dom_input(session_id: str, request: DomInputRequest) -> dict[str, Any]:
|
|
177
|
+
return call(
|
|
178
|
+
session_id,
|
|
179
|
+
"POST",
|
|
180
|
+
"/v1/dom/input",
|
|
181
|
+
body=request.model_dump(),
|
|
182
|
+
timeout=request.timeout + 5,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@router.post("/sessions/{session_id}/dom/dispatch")
|
|
186
|
+
def dom_dispatch(
|
|
187
|
+
session_id: str,
|
|
188
|
+
request: DomDispatchRequest,
|
|
189
|
+
) -> dict[str, Any]:
|
|
190
|
+
return call(
|
|
191
|
+
session_id,
|
|
192
|
+
"POST",
|
|
193
|
+
"/v1/dom/dispatch",
|
|
194
|
+
body=request.model_dump(),
|
|
195
|
+
timeout=request.timeout + 5,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
@router.post("/sessions/{session_id}/dom/listeners")
|
|
199
|
+
def add_listener(
|
|
200
|
+
session_id: str,
|
|
201
|
+
request: DomListenerRequest,
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
return call(
|
|
204
|
+
session_id,
|
|
205
|
+
"POST",
|
|
206
|
+
"/v1/dom/listeners",
|
|
207
|
+
body=request.model_dump(),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
@router.delete("/sessions/{session_id}/dom/listeners/{listener_id}")
|
|
211
|
+
def remove_listener(session_id: str, listener_id: str) -> dict[str, Any]:
|
|
212
|
+
return call(
|
|
213
|
+
session_id,
|
|
214
|
+
"DELETE",
|
|
215
|
+
f"/v1/dom/listeners/{listener_id}",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
@router.put("/sessions/{session_id}/instrumentation")
|
|
219
|
+
def instrumentation(
|
|
220
|
+
session_id: str,
|
|
221
|
+
request: InstrumentationRequest,
|
|
222
|
+
) -> dict[str, Any]:
|
|
223
|
+
return call(
|
|
224
|
+
session_id,
|
|
225
|
+
"PUT",
|
|
226
|
+
"/v1/instrumentation",
|
|
227
|
+
body=request.model_dump(),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@router.get("/sessions/{session_id}/events")
|
|
231
|
+
def events(
|
|
232
|
+
session_id: str,
|
|
233
|
+
after: int = Query(default=0, ge=0),
|
|
234
|
+
limit: int = Query(default=200, ge=1, le=1000),
|
|
235
|
+
kinds: str | None = None,
|
|
236
|
+
timeout: float = Query(default=0, ge=0, le=30),
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
return call(
|
|
239
|
+
session_id,
|
|
240
|
+
"GET",
|
|
241
|
+
"/v1/events",
|
|
242
|
+
params={
|
|
243
|
+
"after": after,
|
|
244
|
+
"limit": limit,
|
|
245
|
+
"kinds": kinds,
|
|
246
|
+
"timeout": timeout,
|
|
247
|
+
},
|
|
248
|
+
timeout=timeout + 5,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@router.delete("/sessions/{session_id}/events")
|
|
252
|
+
def clear_events(session_id: str) -> dict[str, Any]:
|
|
253
|
+
return call(session_id, "DELETE", "/v1/events")
|
|
254
|
+
|
|
255
|
+
@router.get("/sessions/{session_id}/cookies")
|
|
256
|
+
def get_cookies(session_id: str) -> dict[str, Any]:
|
|
257
|
+
return call(session_id, "GET", "/v1/cookies")
|
|
258
|
+
|
|
259
|
+
@router.delete("/sessions/{session_id}/cookies")
|
|
260
|
+
def clear_cookies(session_id: str) -> dict[str, Any]:
|
|
261
|
+
return call(session_id, "DELETE", "/v1/cookies")
|
|
262
|
+
|
|
263
|
+
@router.post("/sessions/{session_id}/cookies/restore")
|
|
264
|
+
def restore_cookies(
|
|
265
|
+
session_id: str,
|
|
266
|
+
request: CookieRestoreRequest,
|
|
267
|
+
) -> dict[str, Any]:
|
|
268
|
+
return call(
|
|
269
|
+
session_id,
|
|
270
|
+
"POST",
|
|
271
|
+
"/v1/cookies/restore",
|
|
272
|
+
body=request.model_dump(),
|
|
273
|
+
timeout=request.timeout + 5,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
@router.post(
|
|
277
|
+
"/sessions/{session_id}/cookie-snapshots",
|
|
278
|
+
status_code=201,
|
|
279
|
+
response_model=CookieSnapshot,
|
|
280
|
+
)
|
|
281
|
+
def create_snapshot(
|
|
282
|
+
session_id: str,
|
|
283
|
+
request: CookieSnapshotCreateRequest,
|
|
284
|
+
) -> dict[str, Any]:
|
|
285
|
+
result = call(session_id, "GET", "/v1/cookies")
|
|
286
|
+
return manager.snapshot_store.save(
|
|
287
|
+
session_id=session_id,
|
|
288
|
+
url=result.get("url"),
|
|
289
|
+
cookies=result.get("cookies", []),
|
|
290
|
+
name=request.name,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@router.get("/cookie-snapshots", response_model=CookieSnapshotListResponse)
|
|
294
|
+
def list_snapshots() -> dict[str, Any]:
|
|
295
|
+
return {"snapshots": manager.snapshot_store.list()}
|
|
296
|
+
|
|
297
|
+
@router.get(
|
|
298
|
+
"/cookie-snapshots/{snapshot_id}",
|
|
299
|
+
response_model=CookieSnapshot,
|
|
300
|
+
)
|
|
301
|
+
def get_snapshot(snapshot_id: str) -> dict[str, Any]:
|
|
302
|
+
try:
|
|
303
|
+
return manager.snapshot_store.get(snapshot_id)
|
|
304
|
+
except FileNotFoundError as error:
|
|
305
|
+
raise HTTPException(status_code=404, detail="快照不存在") from error
|
|
306
|
+
|
|
307
|
+
@router.delete(
|
|
308
|
+
"/cookie-snapshots/{snapshot_id}",
|
|
309
|
+
response_model=CookieSnapshotDeleteResponse,
|
|
310
|
+
)
|
|
311
|
+
def delete_snapshot(snapshot_id: str) -> dict[str, Any]:
|
|
312
|
+
try:
|
|
313
|
+
manager.snapshot_store.delete(snapshot_id)
|
|
314
|
+
except FileNotFoundError as error:
|
|
315
|
+
raise HTTPException(status_code=404, detail="快照不存在") from error
|
|
316
|
+
return {"deleted": True, "snapshot_id": snapshot_id}
|
|
317
|
+
|
|
318
|
+
app.include_router(router)
|
|
319
|
+
return app
|