browser-control-and-automation-cli 0.1.1.dev3__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.
- browser_cli/__init__.py +7 -0
- browser_cli/actions/__init__.py +5 -0
- browser_cli/actions/cli_specs.py +868 -0
- browser_cli/agent_scope/__init__.py +14 -0
- browser_cli/automation/__init__.py +11 -0
- browser_cli/automation/api/__init__.py +5 -0
- browser_cli/automation/api/server.py +321 -0
- browser_cli/automation/hooks.py +42 -0
- browser_cli/automation/loader.py +134 -0
- browser_cli/automation/models.py +127 -0
- browser_cli/automation/persistence/__init__.py +5 -0
- browser_cli/automation/persistence/store.py +682 -0
- browser_cli/automation/projections.py +279 -0
- browser_cli/automation/publisher.py +153 -0
- browser_cli/automation/scheduler/__init__.py +5 -0
- browser_cli/automation/scheduler/schedule.py +113 -0
- browser_cli/automation/service/__init__.py +8 -0
- browser_cli/automation/service/__main__.py +64 -0
- browser_cli/automation/service/client.py +232 -0
- browser_cli/automation/service/runtime.py +388 -0
- browser_cli/automation/toml.py +35 -0
- browser_cli/automation/web/__init__.py +5 -0
- browser_cli/automation/web/assets.py +377 -0
- browser_cli/browser/__init__.py +1 -0
- browser_cli/browser/models.py +44 -0
- browser_cli/browser/network_capture.py +392 -0
- browser_cli/browser/service.py +1519 -0
- browser_cli/browser/session.py +158 -0
- browser_cli/browser/snapshot.py +35 -0
- browser_cli/browser/stealth.py +450 -0
- browser_cli/cli/__init__.py +1 -0
- browser_cli/cli/error_hints.py +26 -0
- browser_cli/cli/main.py +298 -0
- browser_cli/commands/__init__.py +1 -0
- browser_cli/commands/action.py +25 -0
- browser_cli/commands/automation.py +304 -0
- browser_cli/commands/doctor.py +336 -0
- browser_cli/commands/install_skills.py +138 -0
- browser_cli/commands/paths.py +31 -0
- browser_cli/commands/read.py +33 -0
- browser_cli/commands/reload.py +51 -0
- browser_cli/commands/status.py +444 -0
- browser_cli/commands/task.py +71 -0
- browser_cli/constants.py +97 -0
- browser_cli/daemon/__init__.py +1 -0
- browser_cli/daemon/__main__.py +34 -0
- browser_cli/daemon/app.py +941 -0
- browser_cli/daemon/browser_service.py +998 -0
- browser_cli/daemon/client.py +280 -0
- browser_cli/daemon/models.py +63 -0
- browser_cli/daemon/runtime_presentation.py +167 -0
- browser_cli/daemon/server.py +103 -0
- browser_cli/daemon/state.py +15 -0
- browser_cli/daemon/transport.py +77 -0
- browser_cli/drivers/__init__.py +6 -0
- browser_cli/drivers/_extension/__init__.py +1 -0
- browser_cli/drivers/_extension/artifact_actions.py +154 -0
- browser_cli/drivers/_extension/dialog_actions.py +35 -0
- browser_cli/drivers/_extension/input_actions.py +63 -0
- browser_cli/drivers/_extension/locator_actions.py +100 -0
- browser_cli/drivers/_extension/observe_actions.py +285 -0
- browser_cli/drivers/_extension/page_actions.py +210 -0
- browser_cli/drivers/_extension/state_actions.py +134 -0
- browser_cli/drivers/base.py +391 -0
- browser_cli/drivers/extension_driver.py +33 -0
- browser_cli/drivers/models.py +21 -0
- browser_cli/drivers/playwright_driver.py +499 -0
- browser_cli/error_codes.py +27 -0
- browser_cli/errors.py +146 -0
- browser_cli/exit_codes.py +9 -0
- browser_cli/extension/__init__.py +32 -0
- browser_cli/extension/protocol.py +222 -0
- browser_cli/extension/session.py +393 -0
- browser_cli/network.py +147 -0
- browser_cli/outputs/__init__.py +1 -0
- browser_cli/outputs/json.py +10 -0
- browser_cli/outputs/render.py +7 -0
- browser_cli/packaged_skills/__init__.py +1 -0
- browser_cli/packaged_skills/browser-cli-converge/SKILL.md +62 -0
- browser_cli/packaged_skills/browser-cli-delivery/SKILL.md +61 -0
- browser_cli/packaged_skills/browser-cli-explore/SKILL.md +77 -0
- browser_cli/profiles/__init__.py +1 -0
- browser_cli/profiles/discovery.py +185 -0
- browser_cli/refs/__init__.py +17 -0
- browser_cli/refs/generator.py +386 -0
- browser_cli/refs/models.py +127 -0
- browser_cli/refs/registry.py +51 -0
- browser_cli/refs/resolver.py +214 -0
- browser_cli/tabs/__init__.py +5 -0
- browser_cli/tabs/registry.py +283 -0
- browser_cli/task_runtime/__init__.py +31 -0
- browser_cli/task_runtime/client.py +107 -0
- browser_cli/task_runtime/entrypoint.py +110 -0
- browser_cli/task_runtime/errors.py +17 -0
- browser_cli/task_runtime/flow.py +171 -0
- browser_cli/task_runtime/models.py +97 -0
- browser_cli/task_runtime/read.py +71 -0
- browser_cli/task_runtime/templates.py +58 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/METADATA +340 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/RECORD +104 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/WHEEL +5 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/entry_points.txt +2 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/licenses/LICENSE +21 -0
- browser_control_and_automation_cli-0.1.1.dev3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Shared task loading, validation, and execution helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from contextlib import nullcontext, redirect_stderr, redirect_stdout
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from browser_cli.task_runtime.client import BrowserCliTaskClient
|
|
14
|
+
from browser_cli.task_runtime.errors import TaskEntrypointError, TaskMetadataError
|
|
15
|
+
from browser_cli.task_runtime.flow import Flow
|
|
16
|
+
from browser_cli.task_runtime.models import FlowContext, validate_task_metadata
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_input_overrides(pairs: list[str] | None, inputs_json: str | None) -> dict[str, Any]:
|
|
20
|
+
merged: dict[str, Any] = {}
|
|
21
|
+
if inputs_json:
|
|
22
|
+
payload = json.loads(inputs_json)
|
|
23
|
+
if not isinstance(payload, dict):
|
|
24
|
+
raise TaskMetadataError("--inputs-json must decode to a JSON object.")
|
|
25
|
+
merged.update(payload)
|
|
26
|
+
for pair in pairs or []:
|
|
27
|
+
if "=" not in pair:
|
|
28
|
+
raise TaskMetadataError(f"Invalid --set value {pair!r}; expected KEY=VALUE.")
|
|
29
|
+
key, value = pair.split("=", 1)
|
|
30
|
+
merged[key] = value
|
|
31
|
+
return merged
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_task_dir(task_dir: Path) -> dict[str, Any]:
|
|
35
|
+
task_path = task_dir / "task.py"
|
|
36
|
+
meta_path = task_dir / "task.meta.json"
|
|
37
|
+
if not task_path.exists():
|
|
38
|
+
raise TaskEntrypointError(f"Task directory is missing task.py: {task_dir}")
|
|
39
|
+
if not meta_path.exists():
|
|
40
|
+
raise TaskMetadataError(f"Task directory is missing task.meta.json: {task_dir}")
|
|
41
|
+
payload = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
42
|
+
validate_task_metadata(payload, source=str(meta_path))
|
|
43
|
+
load_task_entrypoint(task_path, "run")
|
|
44
|
+
return payload
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_task_entrypoint(task_path: Path, entrypoint: str) -> Callable[..., dict[str, Any]]:
|
|
48
|
+
spec = importlib.util.spec_from_file_location(f"browser_cli_task_{task_path.stem}", task_path)
|
|
49
|
+
if spec is None or spec.loader is None:
|
|
50
|
+
raise TaskEntrypointError(f"Could not load task module: {task_path}")
|
|
51
|
+
module = importlib.util.module_from_spec(spec)
|
|
52
|
+
spec.loader.exec_module(module)
|
|
53
|
+
fn = getattr(module, entrypoint, None)
|
|
54
|
+
if fn is None or not callable(fn):
|
|
55
|
+
raise TaskEntrypointError(
|
|
56
|
+
f"Task entrypoint {entrypoint!r} is missing or not callable: {task_path}"
|
|
57
|
+
)
|
|
58
|
+
signature = inspect.signature(fn)
|
|
59
|
+
positional = [
|
|
60
|
+
param
|
|
61
|
+
for param in signature.parameters.values()
|
|
62
|
+
if param.kind
|
|
63
|
+
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
64
|
+
]
|
|
65
|
+
if len(positional) < 2:
|
|
66
|
+
raise TaskEntrypointError(
|
|
67
|
+
f"Task entrypoint must accept flow and inputs parameters: {task_path}"
|
|
68
|
+
)
|
|
69
|
+
extra_required = [param for param in positional[2:] if param.default is inspect.Parameter.empty]
|
|
70
|
+
if extra_required:
|
|
71
|
+
names = ", ".join(param.name for param in extra_required)
|
|
72
|
+
raise TaskEntrypointError(
|
|
73
|
+
f"Task entrypoint has unsupported required positional parameters ({names}): {task_path}"
|
|
74
|
+
)
|
|
75
|
+
return fn
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_task_entrypoint(
|
|
79
|
+
*,
|
|
80
|
+
task_path: Path,
|
|
81
|
+
entrypoint: str,
|
|
82
|
+
inputs: dict[str, Any],
|
|
83
|
+
artifacts_dir: Path,
|
|
84
|
+
automation_path: Path | None = None,
|
|
85
|
+
automation_name: str | None = None,
|
|
86
|
+
client: BrowserCliTaskClient | None = None,
|
|
87
|
+
stdout_handle: Any | None = None,
|
|
88
|
+
stderr_handle: Any | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
fn = load_task_entrypoint(task_path, entrypoint)
|
|
91
|
+
flow = Flow(
|
|
92
|
+
client=client or BrowserCliTaskClient(),
|
|
93
|
+
context=FlowContext(
|
|
94
|
+
task_path=task_path,
|
|
95
|
+
task_dir=task_path.parent,
|
|
96
|
+
artifacts_dir=artifacts_dir,
|
|
97
|
+
automation_path=automation_path,
|
|
98
|
+
automation_name=automation_name,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
if stdout_handle is None and stderr_handle is None:
|
|
102
|
+
result = fn(flow, inputs)
|
|
103
|
+
else:
|
|
104
|
+
stdout_ctx = redirect_stdout(stdout_handle) if stdout_handle is not None else nullcontext()
|
|
105
|
+
stderr_ctx = redirect_stderr(stderr_handle) if stderr_handle is not None else nullcontext()
|
|
106
|
+
with stdout_ctx, stderr_ctx:
|
|
107
|
+
result = fn(flow, inputs)
|
|
108
|
+
if not isinstance(result, dict):
|
|
109
|
+
raise TaskEntrypointError(f"Task entrypoint must return a dict: {task_path}")
|
|
110
|
+
return result
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Task runtime specific errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from browser_cli.errors import InvalidInputError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TaskRuntimeError(InvalidInputError):
|
|
9
|
+
"""Raised when a task runtime contract is invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaskMetadataError(InvalidInputError):
|
|
13
|
+
"""Raised when task metadata does not satisfy the expected schema."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TaskEntrypointError(InvalidInputError):
|
|
17
|
+
"""Raised when a task module entrypoint is missing or invalid."""
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Higher-level task runtime flow helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from browser_cli.task_runtime.client import BrowserCliTaskClient
|
|
10
|
+
from browser_cli.task_runtime.models import FlowContext, SnapshotResult
|
|
11
|
+
from browser_cli.task_runtime.read import ReadResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Flow:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
client: BrowserCliTaskClient | None = None,
|
|
19
|
+
context: FlowContext,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.client = client or BrowserCliTaskClient()
|
|
22
|
+
self.context = context
|
|
23
|
+
self._last_snapshot: SnapshotResult | None = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def artifacts_dir(self) -> Path:
|
|
27
|
+
self.context.artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
return self.context.artifacts_dir
|
|
29
|
+
|
|
30
|
+
def open(self, url: str) -> dict[str, Any]:
|
|
31
|
+
return self.client.open(url)
|
|
32
|
+
|
|
33
|
+
def read(
|
|
34
|
+
self,
|
|
35
|
+
url: str,
|
|
36
|
+
*,
|
|
37
|
+
output_mode: str = "html",
|
|
38
|
+
scroll_bottom: bool = False,
|
|
39
|
+
) -> ReadResult:
|
|
40
|
+
return self.client.read(url, output_mode=output_mode, scroll_bottom=scroll_bottom)
|
|
41
|
+
|
|
42
|
+
def command(self, action: str, **args: Any) -> dict[str, Any]:
|
|
43
|
+
return self.client.command(action, **args)
|
|
44
|
+
|
|
45
|
+
def search(self, query: str, *, engine: str = "duckduckgo") -> dict[str, Any]:
|
|
46
|
+
return self.client.search(query, engine=engine)
|
|
47
|
+
|
|
48
|
+
def snapshot(self, *, interactive: bool = False, full_page: bool = True) -> SnapshotResult:
|
|
49
|
+
self._last_snapshot = self.client.snapshot(interactive=interactive, full_page=full_page)
|
|
50
|
+
return self._last_snapshot
|
|
51
|
+
|
|
52
|
+
def last_snapshot(self) -> SnapshotResult:
|
|
53
|
+
if self._last_snapshot is None:
|
|
54
|
+
self._last_snapshot = self.snapshot()
|
|
55
|
+
return self._last_snapshot
|
|
56
|
+
|
|
57
|
+
def snapshot_find(self, *, role: str, name: str, nth: int = 0, refresh: bool = False) -> str:
|
|
58
|
+
snapshot = (
|
|
59
|
+
self.snapshot() if refresh or self._last_snapshot is None else self._last_snapshot
|
|
60
|
+
)
|
|
61
|
+
return snapshot.find_ref(role=role, name=name, nth=nth)
|
|
62
|
+
|
|
63
|
+
def click(self, ref: str) -> dict[str, Any]:
|
|
64
|
+
return self.client.click(ref)
|
|
65
|
+
|
|
66
|
+
def fill(self, ref: str, text: str, *, submit: bool = False) -> dict[str, Any]:
|
|
67
|
+
return self.client.fill(ref, text, submit=submit)
|
|
68
|
+
|
|
69
|
+
def select(self, ref: str, text: str) -> dict[str, Any]:
|
|
70
|
+
return self.client.select(ref, text)
|
|
71
|
+
|
|
72
|
+
def check(self, ref: str) -> dict[str, Any]:
|
|
73
|
+
return self.client.check(ref)
|
|
74
|
+
|
|
75
|
+
def uncheck(self, ref: str) -> dict[str, Any]:
|
|
76
|
+
return self.client.uncheck(ref)
|
|
77
|
+
|
|
78
|
+
def focus(self, ref: str) -> dict[str, Any]:
|
|
79
|
+
return self.client.focus(ref)
|
|
80
|
+
|
|
81
|
+
def hover(self, ref: str) -> dict[str, Any]:
|
|
82
|
+
return self.client.hover(ref)
|
|
83
|
+
|
|
84
|
+
def wait(self, seconds: float) -> dict[str, Any]:
|
|
85
|
+
return self.client.wait(seconds)
|
|
86
|
+
|
|
87
|
+
def wait_text(
|
|
88
|
+
self, text: str, *, timeout: float = 5.0, gone: bool = False, exact: bool = False
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
return self.client.wait_text(text, timeout=timeout, gone=gone, exact=exact)
|
|
91
|
+
|
|
92
|
+
def html(self) -> str:
|
|
93
|
+
return self.client.html()
|
|
94
|
+
|
|
95
|
+
def eval(self, code: str) -> Any:
|
|
96
|
+
return self.client.eval(code)
|
|
97
|
+
|
|
98
|
+
def eval_on(self, ref: str, code: str) -> Any:
|
|
99
|
+
return self.client.eval_on(ref, code)
|
|
100
|
+
|
|
101
|
+
def verify_text(self, text: str, *, exact: bool = False, timeout: float = 5.0) -> bool:
|
|
102
|
+
return bool(self.client.verify_text(text, exact=exact, timeout=timeout).get("passed"))
|
|
103
|
+
|
|
104
|
+
def verify_state(self, ref: str, state: str) -> bool:
|
|
105
|
+
return bool(self.client.verify_state(ref, state).get("passed"))
|
|
106
|
+
|
|
107
|
+
def verify_value(self, ref: str, expected: str) -> bool:
|
|
108
|
+
return bool(self.client.verify_value(ref, expected).get("passed"))
|
|
109
|
+
|
|
110
|
+
def scroll_until_stable(
|
|
111
|
+
self,
|
|
112
|
+
*,
|
|
113
|
+
max_rounds: int = 10,
|
|
114
|
+
wait_seconds: float = 2.0,
|
|
115
|
+
confirm_rounds: int = 2,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
stable_rounds = 0
|
|
118
|
+
history: list[dict[str, Any]] = []
|
|
119
|
+
for index in range(1, max_rounds + 1):
|
|
120
|
+
before = self.eval(
|
|
121
|
+
"""() => ({
|
|
122
|
+
scrollHeight: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight),
|
|
123
|
+
scrollY: window.scrollY,
|
|
124
|
+
viewportHeight: window.innerHeight
|
|
125
|
+
})"""
|
|
126
|
+
)
|
|
127
|
+
self.eval(
|
|
128
|
+
"""() => {
|
|
129
|
+
const h = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
|
|
130
|
+
window.scrollTo(0, h);
|
|
131
|
+
return h;
|
|
132
|
+
}"""
|
|
133
|
+
)
|
|
134
|
+
self.wait(wait_seconds)
|
|
135
|
+
after = self.eval(
|
|
136
|
+
"""() => ({
|
|
137
|
+
scrollHeight: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight),
|
|
138
|
+
scrollY: window.scrollY,
|
|
139
|
+
viewportHeight: window.innerHeight,
|
|
140
|
+
atBottom: window.innerHeight + window.scrollY >= Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - 4
|
|
141
|
+
})"""
|
|
142
|
+
)
|
|
143
|
+
round_result = {"round": index, "before": before, "after": after}
|
|
144
|
+
history.append(round_result)
|
|
145
|
+
if after["atBottom"] and after["scrollHeight"] <= before["scrollHeight"]:
|
|
146
|
+
stable_rounds += 1
|
|
147
|
+
if stable_rounds >= confirm_rounds:
|
|
148
|
+
return {"stabilized": True, "rounds": history}
|
|
149
|
+
else:
|
|
150
|
+
stable_rounds = 0
|
|
151
|
+
return {"stabilized": False, "rounds": history}
|
|
152
|
+
|
|
153
|
+
def write_text_artifact(self, relative_path: str, content: str) -> Path:
|
|
154
|
+
output_path = (self.artifacts_dir / relative_path).resolve()
|
|
155
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
output_path.write_text(content, encoding="utf-8")
|
|
157
|
+
return output_path
|
|
158
|
+
|
|
159
|
+
def write_json_artifact(self, relative_path: str, payload: Any) -> Path:
|
|
160
|
+
output_path = (self.artifacts_dir / relative_path).resolve()
|
|
161
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
163
|
+
return output_path
|
|
164
|
+
|
|
165
|
+
def screenshot(self, relative_path: str, *, full_page: bool = False) -> Path:
|
|
166
|
+
output_path = (self.artifacts_dir / relative_path).resolve()
|
|
167
|
+
self.client.screenshot(str(output_path), full_page=full_page)
|
|
168
|
+
return output_path
|
|
169
|
+
|
|
170
|
+
def close(self) -> dict[str, Any]:
|
|
171
|
+
return self.client.close()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Task runtime models and metadata validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from browser_cli.task_runtime.errors import TaskMetadataError
|
|
10
|
+
|
|
11
|
+
REQUIRED_TASK_META_SECTIONS = (
|
|
12
|
+
"task",
|
|
13
|
+
"environment",
|
|
14
|
+
"success_path",
|
|
15
|
+
"recovery_hints",
|
|
16
|
+
"failures",
|
|
17
|
+
"knowledge",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True, frozen=True)
|
|
22
|
+
class SnapshotRef:
|
|
23
|
+
ref: str
|
|
24
|
+
role: str
|
|
25
|
+
name: str | None = None
|
|
26
|
+
nth: int | None = None
|
|
27
|
+
text_content: str | None = None
|
|
28
|
+
interactive: bool = False
|
|
29
|
+
parent_ref: str | None = None
|
|
30
|
+
frame_path: tuple[int, ...] = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True, frozen=True)
|
|
34
|
+
class SnapshotResult:
|
|
35
|
+
page_id: str
|
|
36
|
+
snapshot_id: str
|
|
37
|
+
tree: str
|
|
38
|
+
refs: tuple[SnapshotRef, ...]
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_payload(cls, payload: dict[str, Any]) -> SnapshotResult:
|
|
42
|
+
refs = tuple(
|
|
43
|
+
SnapshotRef(
|
|
44
|
+
ref=str(item["ref"]),
|
|
45
|
+
role=str(item.get("role") or ""),
|
|
46
|
+
name=str(item["name"]) if item.get("name") is not None else None,
|
|
47
|
+
nth=int(item["nth"]) if item.get("nth") is not None else None,
|
|
48
|
+
text_content=str(item["text_content"])
|
|
49
|
+
if item.get("text_content") is not None
|
|
50
|
+
else None,
|
|
51
|
+
interactive=bool(item.get("interactive")),
|
|
52
|
+
parent_ref=str(item["parent_ref"]) if item.get("parent_ref") is not None else None,
|
|
53
|
+
frame_path=tuple(int(value) for value in (item.get("frame_path") or [])),
|
|
54
|
+
)
|
|
55
|
+
for item in payload.get("refs_summary", [])
|
|
56
|
+
)
|
|
57
|
+
return cls(
|
|
58
|
+
page_id=str(payload.get("page_id") or ""),
|
|
59
|
+
snapshot_id=str(payload.get("snapshot_id") or ""),
|
|
60
|
+
tree=str(payload.get("tree") or "(empty)"),
|
|
61
|
+
refs=refs,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def find_ref(self, *, role: str, name: str, nth: int = 0) -> str:
|
|
65
|
+
matches = [item.ref for item in self.refs if item.role == role and item.name == name]
|
|
66
|
+
if nth < 0 or nth >= len(matches):
|
|
67
|
+
raise TaskMetadataError(f"Could not find ref role={role!r} name={name!r} nth={nth}.")
|
|
68
|
+
return matches[nth]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(slots=True, frozen=True)
|
|
72
|
+
class FlowContext:
|
|
73
|
+
task_path: Path
|
|
74
|
+
task_dir: Path
|
|
75
|
+
artifacts_dir: Path
|
|
76
|
+
automation_path: Path | None = None
|
|
77
|
+
automation_name: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_task_metadata(payload: dict[str, Any], *, source: str) -> dict[str, Any]:
|
|
81
|
+
if not isinstance(payload, dict):
|
|
82
|
+
raise TaskMetadataError(f"Task metadata must be a JSON object: {source}")
|
|
83
|
+
missing = [key for key in REQUIRED_TASK_META_SECTIONS if key not in payload]
|
|
84
|
+
if missing:
|
|
85
|
+
joined = ", ".join(missing)
|
|
86
|
+
raise TaskMetadataError(f"Task metadata is missing required sections ({joined}): {source}")
|
|
87
|
+
task_section = payload.get("task")
|
|
88
|
+
if not isinstance(task_section, dict):
|
|
89
|
+
raise TaskMetadataError(f"Task metadata section 'task' must be an object: {source}")
|
|
90
|
+
for key in ("id", "name", "goal"):
|
|
91
|
+
value = task_section.get(key)
|
|
92
|
+
if not isinstance(value, str) or not value.strip():
|
|
93
|
+
raise TaskMetadataError(f"Task metadata requires non-empty task.{key}: {source}")
|
|
94
|
+
failures = payload.get("failures")
|
|
95
|
+
if not isinstance(failures, list):
|
|
96
|
+
raise TaskMetadataError(f"Task metadata section 'failures' must be an array: {source}")
|
|
97
|
+
return payload
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Shared one-shot read orchestration for the task runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from browser_cli.daemon.client import send_command
|
|
9
|
+
from browser_cli.daemon.transport import probe_socket
|
|
10
|
+
from browser_cli.errors import EmptyContentError
|
|
11
|
+
from browser_cli.profiles.discovery import ChromeEnvironment, discover_chrome_environment
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class ReadRequest:
|
|
16
|
+
url: str
|
|
17
|
+
output_mode: str
|
|
18
|
+
scroll_bottom: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class ReadResult:
|
|
23
|
+
body: str
|
|
24
|
+
used_fallback_profile: bool = False
|
|
25
|
+
fallback_profile_dir: str | None = None
|
|
26
|
+
fallback_reason: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def serialize_chrome_environment(chrome_environment: ChromeEnvironment) -> dict[str, str | None]:
|
|
30
|
+
return {
|
|
31
|
+
"executable_path": (
|
|
32
|
+
str(chrome_environment.executable_path)
|
|
33
|
+
if chrome_environment.executable_path is not None
|
|
34
|
+
else None
|
|
35
|
+
),
|
|
36
|
+
"user_data_dir": str(chrome_environment.user_data_dir),
|
|
37
|
+
"profile_directory": chrome_environment.profile_directory,
|
|
38
|
+
"profile_name": chrome_environment.profile_name,
|
|
39
|
+
"source": chrome_environment.source,
|
|
40
|
+
"fallback_reason": chrome_environment.fallback_reason,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def run_read_request(
|
|
45
|
+
request: ReadRequest,
|
|
46
|
+
*,
|
|
47
|
+
chrome_environment: ChromeEnvironment | None = None,
|
|
48
|
+
) -> ReadResult:
|
|
49
|
+
command_args = {
|
|
50
|
+
"url": request.url,
|
|
51
|
+
"output_mode": request.output_mode,
|
|
52
|
+
"scroll_bottom": request.scroll_bottom,
|
|
53
|
+
}
|
|
54
|
+
if not probe_socket():
|
|
55
|
+
resolved_environment = chrome_environment or discover_chrome_environment()
|
|
56
|
+
command_args["chrome_environment"] = serialize_chrome_environment(resolved_environment)
|
|
57
|
+
payload = await asyncio.to_thread(send_command, "read-page", command_args)
|
|
58
|
+
body = str(payload.get("data", {}).get("body") or "")
|
|
59
|
+
if not body.strip():
|
|
60
|
+
raise EmptyContentError()
|
|
61
|
+
used_fallback = bool(payload.get("data", {}).get("used_fallback_profile"))
|
|
62
|
+
return ReadResult(
|
|
63
|
+
body=body,
|
|
64
|
+
used_fallback_profile=used_fallback,
|
|
65
|
+
fallback_profile_dir=(
|
|
66
|
+
str(payload.get("data", {}).get("fallback_profile_dir"))
|
|
67
|
+
if used_fallback and payload.get("data", {}).get("fallback_profile_dir")
|
|
68
|
+
else None
|
|
69
|
+
),
|
|
70
|
+
fallback_reason=str(payload.get("data", {}).get("fallback_reason") or "") or None,
|
|
71
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Canonical task templates and example catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
TASK_TEMPLATE_FILES: dict[str, str] = {
|
|
6
|
+
"task.py": """from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run(flow, inputs):
|
|
10
|
+
url = str(inputs.get("url") or "https://example.com")
|
|
11
|
+
flow.open(url)
|
|
12
|
+
snapshot = flow.snapshot()
|
|
13
|
+
return {"ok": True, "url": url, "snapshot": snapshot.tree}
|
|
14
|
+
""",
|
|
15
|
+
"task.meta.json": """{
|
|
16
|
+
"task": {
|
|
17
|
+
"id": "my_task",
|
|
18
|
+
"name": "My Task",
|
|
19
|
+
"goal": "Describe the task goal"
|
|
20
|
+
},
|
|
21
|
+
"environment": {},
|
|
22
|
+
"success_path": {},
|
|
23
|
+
"recovery_hints": {},
|
|
24
|
+
"failures": [],
|
|
25
|
+
"knowledge": {}
|
|
26
|
+
}
|
|
27
|
+
""",
|
|
28
|
+
"automation.toml": """[automation]
|
|
29
|
+
id = "my_task"
|
|
30
|
+
name = "My Task"
|
|
31
|
+
|
|
32
|
+
[task]
|
|
33
|
+
path = "task.py"
|
|
34
|
+
meta_path = "task.meta.json"
|
|
35
|
+
entrypoint = "run"
|
|
36
|
+
|
|
37
|
+
[inputs]
|
|
38
|
+
|
|
39
|
+
[schedule]
|
|
40
|
+
mode = "manual"
|
|
41
|
+
timezone = "UTC"
|
|
42
|
+
""",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
EXAMPLE_CATALOG: tuple[tuple[str, str], ...] = (
|
|
46
|
+
("interactive_reveal_capture", "Capture progressively revealed content."),
|
|
47
|
+
("lazy_scroll_capture", "Scroll and capture lazy-loaded pages."),
|
|
48
|
+
("douyin_video_download", "Download a Douyin video via the browser-minted detail flow."),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_template_bundle() -> str:
|
|
53
|
+
parts: list[str] = []
|
|
54
|
+
for name, body in TASK_TEMPLATE_FILES.items():
|
|
55
|
+
parts.append(f"=== {name} ===")
|
|
56
|
+
parts.append(body.rstrip())
|
|
57
|
+
parts.append("")
|
|
58
|
+
return "\n".join(parts).rstrip() + "\n"
|