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/cookies.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from email.utils import parsedate_to_datetime
|
|
8
|
+
from http.cookies import Morsel
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from agent_webview.files import ensure_private_directory, write_private_json
|
|
14
|
+
from agent_webview.models import CookieRecord
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _truthy_attribute(value: Any) -> bool:
|
|
18
|
+
return bool(value) and str(value).lower() not in {"false", "0", "none"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize_expires(value: Any) -> str | int | float | None:
|
|
22
|
+
if not value:
|
|
23
|
+
return None
|
|
24
|
+
if isinstance(value, (int, float)):
|
|
25
|
+
return value if value > 0 else None
|
|
26
|
+
text = str(value)
|
|
27
|
+
year = re.search(r"\b(\d{4})\b", text)
|
|
28
|
+
if year and int(year.group(1)) <= 1970:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
parsed = parsedate_to_datetime(text)
|
|
32
|
+
except (TypeError, ValueError, OverflowError):
|
|
33
|
+
return text
|
|
34
|
+
return text if parsed.year > 1970 else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_pywebview_cookies(raw_cookies: Iterable[Any]) -> list[CookieRecord]:
|
|
38
|
+
normalized: list[CookieRecord] = []
|
|
39
|
+
for container in raw_cookies:
|
|
40
|
+
if isinstance(container, Morsel):
|
|
41
|
+
items = [(container.key, container)]
|
|
42
|
+
elif hasattr(container, "items"):
|
|
43
|
+
items = list(container.items())
|
|
44
|
+
else:
|
|
45
|
+
continue
|
|
46
|
+
for name, morsel in items:
|
|
47
|
+
if isinstance(morsel, Morsel):
|
|
48
|
+
same_site = morsel["samesite"] or None
|
|
49
|
+
if same_site:
|
|
50
|
+
same_site = same_site.title()
|
|
51
|
+
normalized.append(
|
|
52
|
+
CookieRecord(
|
|
53
|
+
name=str(name),
|
|
54
|
+
value=morsel.value,
|
|
55
|
+
domain=morsel["domain"] or None,
|
|
56
|
+
path=morsel["path"] or "/",
|
|
57
|
+
expires=_normalize_expires(morsel["expires"]),
|
|
58
|
+
secure=_truthy_attribute(morsel["secure"]),
|
|
59
|
+
http_only=_truthy_attribute(morsel["httponly"]),
|
|
60
|
+
same_site=same_site
|
|
61
|
+
if same_site in {"Strict", "Lax", "None"}
|
|
62
|
+
else None,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
normalized.append(CookieRecord(name=str(name), value=str(morsel)))
|
|
67
|
+
return normalized
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SnapshotStore:
|
|
71
|
+
def __init__(self, directory: Path) -> None:
|
|
72
|
+
self.directory = ensure_private_directory(directory)
|
|
73
|
+
|
|
74
|
+
def save(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
session_id: str,
|
|
78
|
+
url: str | None,
|
|
79
|
+
cookies: list[dict[str, Any]],
|
|
80
|
+
name: str | None,
|
|
81
|
+
) -> dict[str, Any]:
|
|
82
|
+
snapshot_id = uuid4().hex
|
|
83
|
+
document = {
|
|
84
|
+
"snapshot_id": snapshot_id,
|
|
85
|
+
"name": name,
|
|
86
|
+
"session_id": session_id,
|
|
87
|
+
"url": url,
|
|
88
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
89
|
+
"cookies": cookies,
|
|
90
|
+
"limitations": [
|
|
91
|
+
"通过 document.cookie 恢复,无法恢复 HttpOnly Cookie。",
|
|
92
|
+
"浏览器策略可能拒绝部分 Cookie 属性。",
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
target = self.directory / f"{snapshot_id}.json"
|
|
96
|
+
write_private_json(target, document)
|
|
97
|
+
return document
|
|
98
|
+
|
|
99
|
+
def get(self, snapshot_id: str) -> dict[str, Any]:
|
|
100
|
+
path = self._path(snapshot_id)
|
|
101
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
102
|
+
|
|
103
|
+
def list(self) -> list[dict[str, Any]]:
|
|
104
|
+
snapshots = []
|
|
105
|
+
for path in sorted(
|
|
106
|
+
self.directory.glob("*.json"),
|
|
107
|
+
key=lambda item: item.stat().st_mtime,
|
|
108
|
+
reverse=True,
|
|
109
|
+
):
|
|
110
|
+
try:
|
|
111
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
112
|
+
except (OSError, json.JSONDecodeError):
|
|
113
|
+
continue
|
|
114
|
+
snapshots.append(
|
|
115
|
+
{
|
|
116
|
+
key: document.get(key)
|
|
117
|
+
for key in ("snapshot_id", "name", "session_id", "url", "created_at")
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
return snapshots
|
|
121
|
+
|
|
122
|
+
def delete(self, snapshot_id: str) -> None:
|
|
123
|
+
self._path(snapshot_id).unlink()
|
|
124
|
+
|
|
125
|
+
def _path(self, snapshot_id: str) -> Path:
|
|
126
|
+
if not snapshot_id or any(char not in "0123456789abcdef" for char in snapshot_id):
|
|
127
|
+
raise FileNotFoundError(snapshot_id)
|
|
128
|
+
path = self.directory / f"{snapshot_id}.json"
|
|
129
|
+
if not path.is_file():
|
|
130
|
+
raise FileNotFoundError(snapshot_id)
|
|
131
|
+
return path
|
agent_webview/errors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class BrowserNotReadyError(RuntimeError):
|
|
2
|
+
"""页面尚未就绪。"""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BrowserCommandError(RuntimeError):
|
|
6
|
+
"""页面脚本执行失败。"""
|
|
7
|
+
|
|
8
|
+
def __init__(self, message: str, details: dict | None = None) -> None:
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.details = details or {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BrowserCommandTimeoutError(TimeoutError):
|
|
14
|
+
"""页面脚本执行超时。"""
|
agent_webview/events.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from threading import Condition
|
|
8
|
+
from time import monotonic
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class EventPage:
|
|
14
|
+
events: list[dict[str, Any]]
|
|
15
|
+
latest_sequence: int
|
|
16
|
+
oldest_sequence: int
|
|
17
|
+
dropped_before: int | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EventBuffer:
|
|
21
|
+
def __init__(self, capacity: int = 5000) -> None:
|
|
22
|
+
if capacity < 1:
|
|
23
|
+
raise ValueError("容量必须大于零")
|
|
24
|
+
self._events: deque[dict[str, Any]] = deque(maxlen=capacity)
|
|
25
|
+
self._sequence = 0
|
|
26
|
+
self._condition = Condition()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def latest_sequence(self) -> int:
|
|
30
|
+
with self._condition:
|
|
31
|
+
return self._sequence
|
|
32
|
+
|
|
33
|
+
def append(self, kind: str, payload: Any) -> dict[str, Any]:
|
|
34
|
+
with self._condition:
|
|
35
|
+
self._sequence += 1
|
|
36
|
+
event = {
|
|
37
|
+
"sequence": self._sequence,
|
|
38
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
39
|
+
"kind": kind,
|
|
40
|
+
"payload": payload,
|
|
41
|
+
}
|
|
42
|
+
self._events.append(event)
|
|
43
|
+
self._condition.notify_all()
|
|
44
|
+
return event
|
|
45
|
+
|
|
46
|
+
def clear(self) -> None:
|
|
47
|
+
with self._condition:
|
|
48
|
+
self._events.clear()
|
|
49
|
+
|
|
50
|
+
def get(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
after: int = 0,
|
|
54
|
+
limit: int = 200,
|
|
55
|
+
kinds: Iterable[str] | None = None,
|
|
56
|
+
timeout: float = 0,
|
|
57
|
+
) -> EventPage:
|
|
58
|
+
accepted_kinds = set(kinds or [])
|
|
59
|
+
deadline = monotonic() + max(0, timeout)
|
|
60
|
+
|
|
61
|
+
with self._condition:
|
|
62
|
+
while True:
|
|
63
|
+
matching = [
|
|
64
|
+
event
|
|
65
|
+
for event in self._events
|
|
66
|
+
if event["sequence"] > after
|
|
67
|
+
and (not accepted_kinds or event["kind"] in accepted_kinds)
|
|
68
|
+
]
|
|
69
|
+
if matching or timeout <= 0:
|
|
70
|
+
break
|
|
71
|
+
remaining = deadline - monotonic()
|
|
72
|
+
if remaining <= 0:
|
|
73
|
+
break
|
|
74
|
+
self._condition.wait(remaining)
|
|
75
|
+
|
|
76
|
+
oldest = self._events[0]["sequence"] if self._events else self._sequence + 1
|
|
77
|
+
dropped_before = oldest if after < oldest - 1 else None
|
|
78
|
+
return EventPage(
|
|
79
|
+
events=matching[: max(1, min(limit, 1000))],
|
|
80
|
+
latest_sequence=self._sequence,
|
|
81
|
+
oldest_sequence=oldest,
|
|
82
|
+
dropped_before=dropped_before,
|
|
83
|
+
)
|
agent_webview/files.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_private_directory(path: Path) -> Path:
|
|
11
|
+
"""创建仅当前用户可访问的目录。"""
|
|
12
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
13
|
+
_chmod_private(path, 0o700)
|
|
14
|
+
return path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def ensure_private_parent(path: Path) -> Path:
|
|
18
|
+
"""只在父目录缺失时创建私有目录。"""
|
|
19
|
+
if path.is_dir():
|
|
20
|
+
return path
|
|
21
|
+
path.mkdir(parents=True, mode=0o700)
|
|
22
|
+
_chmod_private(path, 0o700)
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def write_private_text(path: Path, content: str) -> None:
|
|
27
|
+
"""原子写入仅当前用户可读写的文本文件。"""
|
|
28
|
+
ensure_private_parent(path.parent)
|
|
29
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
30
|
+
dir=path.parent,
|
|
31
|
+
prefix=f".{path.name}.",
|
|
32
|
+
suffix=".tmp",
|
|
33
|
+
text=True,
|
|
34
|
+
)
|
|
35
|
+
temporary = Path(temporary_name)
|
|
36
|
+
try:
|
|
37
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
38
|
+
stream.write(content)
|
|
39
|
+
stream.flush()
|
|
40
|
+
os.fsync(stream.fileno())
|
|
41
|
+
_chmod_private(temporary, 0o600)
|
|
42
|
+
try:
|
|
43
|
+
os.replace(temporary, path)
|
|
44
|
+
except PermissionError:
|
|
45
|
+
# 部分 Windows 策略禁止替换已存在文件。
|
|
46
|
+
path.write_text(content, encoding="utf-8")
|
|
47
|
+
_chmod_private(path, 0o600)
|
|
48
|
+
finally:
|
|
49
|
+
temporary.unlink(missing_ok=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write_private_json(path: Path, value: dict[str, Any]) -> None:
|
|
53
|
+
write_private_text(
|
|
54
|
+
path,
|
|
55
|
+
json.dumps(value, ensure_ascii=False, indent=2),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def restrict_file_permissions(path: Path) -> None:
|
|
60
|
+
_chmod_private(path, 0o600)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _chmod_private(path: Path, mode: int) -> None:
|
|
64
|
+
try:
|
|
65
|
+
path.chmod(mode)
|
|
66
|
+
except OSError:
|
|
67
|
+
if os.name != "nt":
|
|
68
|
+
raise
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI, Request
|
|
4
|
+
from fastapi.exceptions import RequestValidationError
|
|
5
|
+
from fastapi.responses import JSONResponse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def install_validation_handler(app: FastAPI) -> None:
|
|
9
|
+
@app.exception_handler(RequestValidationError)
|
|
10
|
+
async def validation_handler(
|
|
11
|
+
_: Request,
|
|
12
|
+
error: RequestValidationError,
|
|
13
|
+
) -> JSONResponse:
|
|
14
|
+
errors = [
|
|
15
|
+
{
|
|
16
|
+
"field": ".".join(str(item) for item in detail["loc"]),
|
|
17
|
+
"type": detail["type"],
|
|
18
|
+
}
|
|
19
|
+
for detail in error.errors()
|
|
20
|
+
]
|
|
21
|
+
return JSONResponse(
|
|
22
|
+
status_code=422,
|
|
23
|
+
content={"detail": "请求参数无效", "errors": errors},
|
|
24
|
+
)
|