rpa-skill-runtime 0.1.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.
- rpa_skill_runtime-0.1.1/PKG-INFO +33 -0
- rpa_skill_runtime-0.1.1/README.md +25 -0
- rpa_skill_runtime-0.1.1/pyproject.toml +14 -0
- rpa_skill_runtime-0.1.1/setup.cfg +4 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/__init__.py +22 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/action.py +79 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/browser.py +44 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/context.py +48 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/errors.py +48 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/lifecycle.py +36 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/session.py +105 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/spec.py +273 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/state.py +103 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/task_client.py +161 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/task_process.py +203 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/ui/__init__.py +4 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/ui/conditions.py +40 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/ui/models.py +64 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime/ui/tools.py +491 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime.egg-info/PKG-INFO +33 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime.egg-info/SOURCES.txt +22 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime.egg-info/dependency_links.txt +1 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime.egg-info/requires.txt +1 -0
- rpa_skill_runtime-0.1.1/src/rpa_skill_runtime.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rpa-skill-runtime
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: 面向独立 RPA Skill 的轻量 Playwright 运行时
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: playwright>=1.61.0
|
|
8
|
+
|
|
9
|
+
# rpa-skill-runtime
|
|
10
|
+
|
|
11
|
+
独立 RPA Skill 共用的轻量 Playwright 运行时,提供任务进程、浏览器会话、JSON 动作协议和 DOM UI 工具。登录和业务动作仍由各系统 Skill 自己实现。
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
Python 最低版本为 3.10:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python3.12 -m pip install "rpa-skill-runtime>=0.1.1"
|
|
19
|
+
python3.12 -m playwright install chromium
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
安装后,Skill 脚本直接导入 `rpa_skill_runtime`,不需要携带 `runtime/` 源码目录。
|
|
23
|
+
|
|
24
|
+
## 发布
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
./publish.sh --dry-run
|
|
28
|
+
./publish.sh
|
|
29
|
+
./publish.sh --repository internal
|
|
30
|
+
TWINE_REPOSITORY_URL=https://pypi.example.com/legacy/ ./publish.sh
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
默认发布到公开 PyPI。认证信息只从 `~/.pypirc` 或 Twine 标准环境变量读取。
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# rpa-skill-runtime
|
|
2
|
+
|
|
3
|
+
独立 RPA Skill 共用的轻量 Playwright 运行时,提供任务进程、浏览器会话、JSON 动作协议和 DOM UI 工具。登录和业务动作仍由各系统 Skill 自己实现。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
Python 最低版本为 3.10:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python3.12 -m pip install "rpa-skill-runtime>=0.1.1"
|
|
11
|
+
python3.12 -m playwright install chromium
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
安装后,Skill 脚本直接导入 `rpa_skill_runtime`,不需要携带 `runtime/` 源码目录。
|
|
15
|
+
|
|
16
|
+
## 发布
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
./publish.sh --dry-run
|
|
20
|
+
./publish.sh
|
|
21
|
+
./publish.sh --repository internal
|
|
22
|
+
TWINE_REPOSITORY_URL=https://pypi.example.com/legacy/ ./publish.sh
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
默认发布到公开 PyPI。认证信息只从 `~/.pypirc` 或 Twine 标准环境变量读取。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rpa-skill-runtime"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "面向独立 RPA Skill 的轻量 Playwright 运行时"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["playwright>=1.61.0"]
|
|
12
|
+
|
|
13
|
+
[tool.setuptools.packages.find]
|
|
14
|
+
where = ["src"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""独立 RPA Skill 的轻量运行时。"""
|
|
2
|
+
|
|
3
|
+
from .action import action_main, run_action
|
|
4
|
+
from .context import ActionContext
|
|
5
|
+
from .errors import InvalidPageState, LoginTimeout, RpaSkillError, TaskLost
|
|
6
|
+
from .lifecycle import lifecycle_main
|
|
7
|
+
from .session import TaskSession
|
|
8
|
+
from .ui import UiTools, ui
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ActionContext",
|
|
12
|
+
"InvalidPageState",
|
|
13
|
+
"LoginTimeout",
|
|
14
|
+
"RpaSkillError",
|
|
15
|
+
"TaskLost",
|
|
16
|
+
"TaskSession",
|
|
17
|
+
"UiTools",
|
|
18
|
+
"action_main",
|
|
19
|
+
"lifecycle_main",
|
|
20
|
+
"run_action",
|
|
21
|
+
"ui",
|
|
22
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""独立动作脚本的 stdin/stdout JSON 入口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib.util
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .errors import InvalidInput, error_payload
|
|
15
|
+
from .task_client import call_action
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_action_module(action_file: Path) -> ModuleType:
|
|
19
|
+
name = f"rpa_skill_action_{action_file.stem}_{id(action_file)}"
|
|
20
|
+
spec = importlib.util.spec_from_file_location(name, action_file)
|
|
21
|
+
if spec is None or spec.loader is None:
|
|
22
|
+
raise InvalidInput("无法加载动作脚本")
|
|
23
|
+
module = importlib.util.module_from_spec(spec)
|
|
24
|
+
scripts_dir = str(action_file.parent)
|
|
25
|
+
inserted = scripts_dir not in sys.path
|
|
26
|
+
if inserted:
|
|
27
|
+
sys.path.insert(0, scripts_dir)
|
|
28
|
+
sys.modules[name] = module
|
|
29
|
+
try:
|
|
30
|
+
spec.loader.exec_module(module)
|
|
31
|
+
except BaseException:
|
|
32
|
+
sys.modules.pop(name, None)
|
|
33
|
+
raise
|
|
34
|
+
finally:
|
|
35
|
+
if inserted:
|
|
36
|
+
sys.path.remove(scripts_dir)
|
|
37
|
+
return module
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def execute_loaded_action(action_file: Path, session: Any, raw: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
module = load_action_module(action_file)
|
|
42
|
+
explicit = getattr(module, "run", None)
|
|
43
|
+
if not inspect.iscoroutinefunction(explicit):
|
|
44
|
+
raise InvalidInput("动作脚本必须实现 async def run(session, params)")
|
|
45
|
+
result = await explicit(session, raw)
|
|
46
|
+
if not isinstance(result, dict):
|
|
47
|
+
raise InvalidInput("动作必须返回 JSON 对象")
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def run_action(action_file: str | Path, payload: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
action_file = Path(action_file).resolve()
|
|
53
|
+
task_id = str(payload.get("task_id") or "")
|
|
54
|
+
if not task_id:
|
|
55
|
+
raise InvalidInput("请提供 task_id")
|
|
56
|
+
page_ref = payload.get("page_ref")
|
|
57
|
+
params = {key: value for key, value in payload.items() if key not in {"task_id", "page_ref"}}
|
|
58
|
+
return await call_action(action_file, task_id, params, page_ref=page_ref)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _json_main(handler: Any) -> None:
|
|
62
|
+
payload: dict[str, Any] = {}
|
|
63
|
+
try:
|
|
64
|
+
raw = sys.stdin.read()
|
|
65
|
+
payload = json.loads(raw or "{}")
|
|
66
|
+
if not isinstance(payload, dict):
|
|
67
|
+
raise InvalidInput("stdin 必须是一个 JSON 对象")
|
|
68
|
+
result = asyncio.run(handler(payload))
|
|
69
|
+
except BaseException as exc:
|
|
70
|
+
runtime = getattr(exc, "runtime", None)
|
|
71
|
+
if runtime is None and payload.get("task_id"):
|
|
72
|
+
runtime = {"task_id": payload["task_id"], "page_ref": payload.get("page_ref")}
|
|
73
|
+
result = error_payload(exc, runtime)
|
|
74
|
+
sys.stdout.write(json.dumps(result, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def action_main(action_file: str | Path) -> None:
|
|
78
|
+
action_file = Path(action_file).resolve()
|
|
79
|
+
_json_main(lambda payload: run_action(action_file, payload))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""任务运行时与工厂工具共用的浏览器默认环境。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
BROWSER_VIEWPORT = {"width": 1920, "height": 1080}
|
|
9
|
+
BROWSER_SCREEN = {"width": 1920, "height": 1080}
|
|
10
|
+
BROWSER_LOCALE = "zh-CN"
|
|
11
|
+
BROWSER_TIMEZONE = "Asia/Shanghai"
|
|
12
|
+
BROWSER_ARGS = (
|
|
13
|
+
"--disable-dev-shm-usage",
|
|
14
|
+
"--disable-gpu",
|
|
15
|
+
"--window-position=0,0",
|
|
16
|
+
# 临时绕过本地 IPv6 解析问题,确认网络环境修复前保留。
|
|
17
|
+
"--host-resolver-rules=MAP img.yeepay.com 221.194.141.203,EXCLUDE localhost",
|
|
18
|
+
"--proxy-bypass-list=img.yeepay.com;*.yeepay.com;221.194.141.203;<local>",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def merge_browser_args(extra: Iterable[str] | None = None) -> list[str]:
|
|
23
|
+
"""合并启动参数;调用方最后提供的同名参数优先。"""
|
|
24
|
+
merged: list[str] = []
|
|
25
|
+
seen: set[str] = set()
|
|
26
|
+
for argument in reversed([*BROWSER_ARGS, *(str(item) for item in extra or ())]):
|
|
27
|
+
key = argument.partition("=")[0]
|
|
28
|
+
if key not in seen:
|
|
29
|
+
seen.add(key)
|
|
30
|
+
merged.append(argument)
|
|
31
|
+
return list(reversed(merged))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def browser_context_options(storage_state: str | None = None) -> dict[str, Any]:
|
|
35
|
+
"""返回每个 BrowserContext 独立持有的默认参数。"""
|
|
36
|
+
options: dict[str, Any] = {
|
|
37
|
+
"viewport": dict(BROWSER_VIEWPORT),
|
|
38
|
+
"screen": dict(BROWSER_SCREEN),
|
|
39
|
+
"locale": BROWSER_LOCALE,
|
|
40
|
+
"timezone_id": BROWSER_TIMEZONE,
|
|
41
|
+
}
|
|
42
|
+
if storage_state:
|
|
43
|
+
options["storage_state"] = storage_state
|
|
44
|
+
return options
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""迁移后业务动作使用的页面上下文。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from .errors import LoginRequired
|
|
8
|
+
from .ui import UiTools
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ParamsT = TypeVar("ParamsT")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ActionContext(Generic[ParamsT]):
|
|
15
|
+
def __init__(self, session: Any, params: ParamsT):
|
|
16
|
+
self.session = session
|
|
17
|
+
self.page = session.page
|
|
18
|
+
self.params = params
|
|
19
|
+
self.tools = UiTools(self.page)
|
|
20
|
+
|
|
21
|
+
async def open(
|
|
22
|
+
self,
|
|
23
|
+
url: str,
|
|
24
|
+
*,
|
|
25
|
+
ready_button: str | None = None,
|
|
26
|
+
ready_text: str | None = None,
|
|
27
|
+
wait_until: str = "domcontentloaded",
|
|
28
|
+
timeout: int = 60000,
|
|
29
|
+
) -> None:
|
|
30
|
+
await self.page.goto(url, wait_until=wait_until, timeout=timeout)
|
|
31
|
+
await self._raise_if_login_page()
|
|
32
|
+
if ready_button:
|
|
33
|
+
await self.page.get_by_role("button", name=ready_button, exact=True).wait_for(state="visible", timeout=timeout)
|
|
34
|
+
if ready_text:
|
|
35
|
+
await self.page.get_by_text(ready_text, exact=True).wait_for(state="visible", timeout=timeout)
|
|
36
|
+
|
|
37
|
+
def rows(self, rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
38
|
+
return {"rows": rows, "count": len(rows)}
|
|
39
|
+
|
|
40
|
+
def records(self, records: list[dict[str, Any]]) -> dict[str, Any]:
|
|
41
|
+
return {"records": records, "count": len(records)}
|
|
42
|
+
|
|
43
|
+
async def _raise_if_login_page(self) -> None:
|
|
44
|
+
url = self.page.url.lower()
|
|
45
|
+
if any(marker in url for marker in ("login", "signin", "sign-in", "sso")):
|
|
46
|
+
password = self.page.locator("input[type=password],input[name=password]")
|
|
47
|
+
if await password.count():
|
|
48
|
+
raise LoginRequired("登录态已失效,请先执行当前 Skill 的 login.py")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""稳定的运行时错误协议。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RpaSkillError(RuntimeError):
|
|
9
|
+
code = "RuntimeError"
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, *, details: dict[str, Any] | None = None):
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.details = details or {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidInput(RpaSkillError):
|
|
17
|
+
code = "InvalidInput"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TaskLost(RpaSkillError):
|
|
21
|
+
code = "TaskLost"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidPageState(RpaSkillError):
|
|
25
|
+
code = "InvalidPageState"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LoginRequired(RpaSkillError):
|
|
29
|
+
code = "LoginRequired"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LoginTimeout(RpaSkillError):
|
|
33
|
+
code = "LoginTimeout"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ActionNotAllowed(RpaSkillError):
|
|
37
|
+
code = "ActionNotAllowed"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def error_payload(exc: BaseException, runtime: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
41
|
+
if isinstance(exc, RpaSkillError):
|
|
42
|
+
code, details = exc.code, exc.details
|
|
43
|
+
else:
|
|
44
|
+
code, details = type(exc).__name__, {}
|
|
45
|
+
payload: dict[str, Any] = {"error": code, "message": str(exc), "details": details}
|
|
46
|
+
if runtime:
|
|
47
|
+
payload["_runtime"] = runtime
|
|
48
|
+
return payload
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""系统 Skill 的统一任务生命周期入口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .action import _json_main
|
|
10
|
+
from .errors import InvalidInput
|
|
11
|
+
from .task_client import close_page, close_task, start_task, task_status
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _run(skill_dir: Path, operation: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
15
|
+
if operation == "start":
|
|
16
|
+
return await start_task(
|
|
17
|
+
skill_dir,
|
|
18
|
+
str(payload.get("account") or ""),
|
|
19
|
+
headless=bool(payload.get("headless", False)),
|
|
20
|
+
idle_timeout=int(payload.get("idle_timeout", 900)),
|
|
21
|
+
browser_args=list(payload.get("browser_args") or []),
|
|
22
|
+
)
|
|
23
|
+
task_id = str(payload.get("task_id") or "")
|
|
24
|
+
if operation == "status":
|
|
25
|
+
return await task_status(skill_dir, task_id)
|
|
26
|
+
if operation == "page-close":
|
|
27
|
+
return await close_page(skill_dir, task_id, str(payload.get("page_ref") or ""))
|
|
28
|
+
if operation == "close":
|
|
29
|
+
return await close_task(skill_dir, task_id)
|
|
30
|
+
raise InvalidInput("生命周期操作必须是 start、status、page-close 或 close")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def lifecycle_main(script_file: str | Path) -> None:
|
|
34
|
+
operation = sys.argv[1] if len(sys.argv) == 2 else ""
|
|
35
|
+
skill_dir = Path(script_file).resolve().parent.parent
|
|
36
|
+
_json_main(lambda payload: _run(skill_dir, operation, payload))
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""任务进程内的多页面会话。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import ActionNotAllowed, InvalidPageState
|
|
9
|
+
from .state import atomic_write_json, new_page_ref
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskSession:
|
|
13
|
+
"""一个任务独占的 BrowserContext 及其 Page 集合。"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, context: Any, auth_file: Path):
|
|
16
|
+
self.context = context
|
|
17
|
+
self.auth_file = auth_file
|
|
18
|
+
self._pages: dict[str, Any] = {}
|
|
19
|
+
self._refs: dict[int, str] = {}
|
|
20
|
+
self.active_page_ref: str | None = None
|
|
21
|
+
self.allow_auth_write = False
|
|
22
|
+
context.on("page", self._on_page)
|
|
23
|
+
|
|
24
|
+
def _on_page(self, page: Any) -> None:
|
|
25
|
+
ref = self.register_page(page, activate=True)
|
|
26
|
+
|
|
27
|
+
def closed() -> None:
|
|
28
|
+
self._pages.pop(ref, None)
|
|
29
|
+
self._refs.pop(id(page), None)
|
|
30
|
+
if self.active_page_ref == ref:
|
|
31
|
+
self.active_page_ref = next(reversed(self._pages), None)
|
|
32
|
+
|
|
33
|
+
page.on("close", closed)
|
|
34
|
+
|
|
35
|
+
def register_page(self, page: Any, *, activate: bool = False) -> str:
|
|
36
|
+
ref = self._refs.get(id(page))
|
|
37
|
+
if ref is None:
|
|
38
|
+
ref = new_page_ref()
|
|
39
|
+
self._refs[id(page)] = ref
|
|
40
|
+
self._pages[ref] = page
|
|
41
|
+
if activate or self.active_page_ref is None:
|
|
42
|
+
self.active_page_ref = ref
|
|
43
|
+
return ref
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def page(self) -> Any:
|
|
47
|
+
if not self.active_page_ref or self.active_page_ref not in self._pages:
|
|
48
|
+
raise InvalidPageState("任务中没有可用页面")
|
|
49
|
+
return self._pages[self.active_page_ref]
|
|
50
|
+
|
|
51
|
+
def select_page(self, page_ref: str | None) -> Any:
|
|
52
|
+
if page_ref:
|
|
53
|
+
page = self._pages.get(page_ref)
|
|
54
|
+
if page is None:
|
|
55
|
+
raise InvalidPageState("page_ref 不存在或页面已关闭", details={"page_ref": page_ref})
|
|
56
|
+
self.active_page_ref = page_ref
|
|
57
|
+
return self.page
|
|
58
|
+
|
|
59
|
+
async def new_page(self) -> tuple[str, Any]:
|
|
60
|
+
page = await self.context.new_page()
|
|
61
|
+
ref = self.register_page(page, activate=True)
|
|
62
|
+
return ref, page
|
|
63
|
+
|
|
64
|
+
async def activate_page(self, page_ref: str) -> None:
|
|
65
|
+
page = self.select_page(page_ref)
|
|
66
|
+
await page.bring_to_front()
|
|
67
|
+
|
|
68
|
+
async def close_page(self, page_ref: str) -> None:
|
|
69
|
+
page = self._pages.get(page_ref)
|
|
70
|
+
if page is None:
|
|
71
|
+
raise InvalidPageState("page_ref 不存在或页面已关闭", details={"page_ref": page_ref})
|
|
72
|
+
await page.close()
|
|
73
|
+
|
|
74
|
+
async def save_auth(self) -> None:
|
|
75
|
+
if not self.allow_auth_write:
|
|
76
|
+
raise ActionNotAllowed("只有 login.py 可以更新 canonical 登录态")
|
|
77
|
+
state = await self.context.storage_state(indexed_db=True)
|
|
78
|
+
atomic_write_json(self.auth_file, state)
|
|
79
|
+
|
|
80
|
+
async def pages(self) -> list[dict[str, Any]]:
|
|
81
|
+
result = []
|
|
82
|
+
for ref, page in list(self._pages.items()):
|
|
83
|
+
if page.is_closed():
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
title = await page.title()
|
|
87
|
+
except Exception:
|
|
88
|
+
title = ""
|
|
89
|
+
result.append(
|
|
90
|
+
{
|
|
91
|
+
"page_ref": ref,
|
|
92
|
+
"url": page.url,
|
|
93
|
+
"title": title,
|
|
94
|
+
"active": ref == self.active_page_ref,
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
async def runtime(self, task_id: str) -> dict[str, Any]:
|
|
100
|
+
return {
|
|
101
|
+
"task_id": task_id,
|
|
102
|
+
"page_ref": self.active_page_ref,
|
|
103
|
+
"active_page_ref": self.active_page_ref,
|
|
104
|
+
"pages": await self.pages(),
|
|
105
|
+
}
|