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.
@@ -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
@@ -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
+ """页面脚本执行超时。"""
@@ -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
+ )