browserwright 0.6.2__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.
- browserwright/__init__.py +33 -0
- browserwright/__main__.py +6 -0
- browserwright/_executor/__init__.py +47 -0
- browserwright/_executor/__main__.py +9 -0
- browserwright/_executor/client.py +127 -0
- browserwright/_executor/process.py +652 -0
- browserwright/_executor/protocol.py +152 -0
- browserwright/api.py +66 -0
- browserwright/cdp.py +285 -0
- browserwright/cli.py +741 -0
- browserwright/daemon/__init__.py +8 -0
- browserwright/daemon/_ipc.py +444 -0
- browserwright/daemon/active_tab.py +183 -0
- browserwright/daemon/auth.py +395 -0
- browserwright/daemon/backends/__init__.py +59 -0
- browserwright/daemon/backends/base.py +120 -0
- browserwright/daemon/backends/cloud.py +222 -0
- browserwright/daemon/backends/env.py +119 -0
- browserwright/daemon/backends/extension.py +185 -0
- browserwright/daemon/backends/rdp.py +214 -0
- browserwright/daemon/cli.py +1437 -0
- browserwright/daemon/config.py +380 -0
- browserwright/daemon/doctor.py +179 -0
- browserwright/daemon/errors.py +34 -0
- browserwright/daemon/launch_chrome.py +353 -0
- browserwright/daemon/observability.py +181 -0
- browserwright/daemon/platforms.py +234 -0
- browserwright/daemon/resolver.py +72 -0
- browserwright/daemon/server/__init__.py +6 -0
- browserwright/daemon/server/daemon.py +229 -0
- browserwright/daemon/server/executor_registry.py +434 -0
- browserwright/daemon/server/extension_upstream.py +677 -0
- browserwright/daemon/server/facade.py +375 -0
- browserwright/daemon/server/facade_extension.py +969 -0
- browserwright/daemon/server/listener.py +1058 -0
- browserwright/daemon/server/proxy.py +1991 -0
- browserwright/daemon/server/relay.py +783 -0
- browserwright/daemon/server/state.py +432 -0
- browserwright/daemon/server/upstream.py +266 -0
- browserwright/daemon/userscripts.py +150 -0
- browserwright/discovery.py +213 -0
- browserwright/errors.py +177 -0
- browserwright/health.py +169 -0
- browserwright/install.py +628 -0
- browserwright/memory/__init__.py +15 -0
- browserwright/memory/_md.py +120 -0
- browserwright/memory/_yaml.py +217 -0
- browserwright/memory/global_mem.py +201 -0
- browserwright/memory/repl_mem.py +28 -0
- browserwright/memory/session_decisions.py +53 -0
- browserwright/memory/site_mem.py +381 -0
- browserwright/mode_b_client.py +590 -0
- browserwright/multitask.py +131 -0
- browserwright/output_schema.py +99 -0
- browserwright/primitives/__init__.py +67 -0
- browserwright/primitives/discovery_api.py +79 -0
- browserwright/primitives/http.py +42 -0
- browserwright/primitives/inspect.py +876 -0
- browserwright/primitives/interact.py +518 -0
- browserwright/primitives/page.py +556 -0
- browserwright/primitives/site.py +143 -0
- browserwright/release_install.py +466 -0
- browserwright/repl/__init__.py +6 -0
- browserwright/repl/_namespace.py +106 -0
- browserwright/repl/_smart_goto.py +236 -0
- browserwright/repl/inline.py +180 -0
- browserwright/repl/playwright_handle.py +449 -0
- browserwright/repl/snapshot.py +150 -0
- browserwright/session.py +229 -0
- browserwright/session_create.py +252 -0
- browserwright/session_ctx.py +24 -0
- browserwright/session_registry.py +133 -0
- browserwright/session_runtime.py +133 -0
- browserwright/site_skills_starter/github.com/SKILL.md +14 -0
- browserwright/site_skills_starter/github.com/memory.md +29 -0
- browserwright/site_skills_starter/github.com/tasks/list_issues.py +55 -0
- browserwright/site_skills_starter/google.com/SKILL.md +16 -0
- browserwright/site_skills_starter/google.com/memory.md +27 -0
- browserwright/site_skills_starter/google.com/tasks/search.py +53 -0
- browserwright/site_skills_starter/producthunt.com/SKILL.md +7 -0
- browserwright/site_skills_starter/producthunt.com/memory.md +26 -0
- browserwright/site_skills_starter/producthunt.com/tasks/today.py +64 -0
- browserwright/site_skills_starter/wikipedia.org/SKILL.md +7 -0
- browserwright/site_skills_starter/wikipedia.org/memory.md +22 -0
- browserwright/site_skills_starter/wikipedia.org/tasks/lookup.py +55 -0
- browserwright/site_skills_starter/ycombinator.com/SKILL.md +8 -0
- browserwright/site_skills_starter/ycombinator.com/memory.md +25 -0
- browserwright/site_skills_starter/ycombinator.com/tasks/front_page.py +63 -0
- browserwright/skill_doc.py +140 -0
- browserwright/skill_runtime.md +194 -0
- browserwright/subscriptions.py +213 -0
- browserwright/task_runner.py +125 -0
- browserwright/version.py +117 -0
- browserwright-0.6.2.dist-info/METADATA +12 -0
- browserwright-0.6.2.dist-info/RECORD +98 -0
- browserwright-0.6.2.dist-info/WHEEL +5 -0
- browserwright-0.6.2.dist-info/entry_points.txt +3 -0
- browserwright-0.6.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Parse Tampermonkey-style ``==UserScript==`` headers into structured records.
|
|
2
|
+
|
|
3
|
+
v1 capability surface is plain page JS (no GM_* APIs); unsupported metadata
|
|
4
|
+
directives are collected as warnings rather than rejected, so existing
|
|
5
|
+
userscripts paste in without crashing.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
DEFAULT_NAMESPACE = "bd.userscripts"
|
|
14
|
+
_RUN_AT = {
|
|
15
|
+
"document-start": "document_start",
|
|
16
|
+
"document-end": "document_end",
|
|
17
|
+
"document-idle": "document_idle",
|
|
18
|
+
"document_start": "document_start",
|
|
19
|
+
"document_end": "document_end",
|
|
20
|
+
"document_idle": "document_idle",
|
|
21
|
+
}
|
|
22
|
+
_SUPPORTED = {
|
|
23
|
+
"name",
|
|
24
|
+
"namespace",
|
|
25
|
+
"match",
|
|
26
|
+
"include",
|
|
27
|
+
"exclude",
|
|
28
|
+
"run-at",
|
|
29
|
+
"version",
|
|
30
|
+
"description",
|
|
31
|
+
}
|
|
32
|
+
_HEADER_RE = re.compile(
|
|
33
|
+
r"//\s*==UserScript==\s*\n(.*?)//\s*==/UserScript==", re.DOTALL)
|
|
34
|
+
_LINE_RE = re.compile(r"//\s*@(\S+)\s+(.*?)\s*$")
|
|
35
|
+
|
|
36
|
+
# Chrome match-pattern grammar: ``<scheme>://<host><path>`` (or ``<all_urls>``).
|
|
37
|
+
# Validating here lets the daemon reject typos loudly instead of shipping a
|
|
38
|
+
# pattern that makes ``chrome.userScripts.register`` reject the whole batch on
|
|
39
|
+
# the extension side (which would silently disable every resident script).
|
|
40
|
+
_MATCH_PATTERN_RE = re.compile(
|
|
41
|
+
r"^(\*|https?|file|ftp|wss?)://(\*|(\*\.)?[^/*]+)?(/.*)$"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_valid_match_pattern(pattern: str) -> bool:
|
|
46
|
+
return pattern == "<all_urls>" or bool(_MATCH_PATTERN_RE.match(pattern))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class UserscriptParseError(ValueError):
|
|
50
|
+
"""Raised when a userscript has no header or no match pattern."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Userscript:
|
|
55
|
+
name: str
|
|
56
|
+
namespace: str
|
|
57
|
+
matches: list[str]
|
|
58
|
+
exclude_matches: list[str]
|
|
59
|
+
run_at: str
|
|
60
|
+
version: str
|
|
61
|
+
description: str
|
|
62
|
+
code: str
|
|
63
|
+
warnings: list[str] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def identity(self) -> str:
|
|
67
|
+
return f"{self.namespace}/{self.name}"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def id(self) -> str:
|
|
71
|
+
return hashlib.sha1(self.identity.encode()).hexdigest()[:12]
|
|
72
|
+
|
|
73
|
+
def to_payload(self) -> dict:
|
|
74
|
+
return {
|
|
75
|
+
"id": self.id,
|
|
76
|
+
"identity": self.identity,
|
|
77
|
+
"name": self.name,
|
|
78
|
+
"namespace": self.namespace,
|
|
79
|
+
"matches": self.matches,
|
|
80
|
+
"excludeMatches": self.exclude_matches,
|
|
81
|
+
"runAt": self.run_at,
|
|
82
|
+
"version": self.version,
|
|
83
|
+
"description": self.description,
|
|
84
|
+
"code": self.code,
|
|
85
|
+
"warnings": self.warnings,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_userscript(text: str) -> Userscript:
|
|
90
|
+
match = _HEADER_RE.search(text)
|
|
91
|
+
if not match:
|
|
92
|
+
raise UserscriptParseError("missing ==UserScript== metadata block")
|
|
93
|
+
block = match.group(1)
|
|
94
|
+
code = text[match.end():].lstrip("\n")
|
|
95
|
+
|
|
96
|
+
name = ""
|
|
97
|
+
namespace = ""
|
|
98
|
+
version = ""
|
|
99
|
+
description = ""
|
|
100
|
+
run_at = "document_idle"
|
|
101
|
+
matches: list[str] = []
|
|
102
|
+
excludes: list[str] = []
|
|
103
|
+
warnings: list[str] = []
|
|
104
|
+
|
|
105
|
+
for line in block.splitlines():
|
|
106
|
+
line_match = _LINE_RE.match(line.strip())
|
|
107
|
+
if not line_match:
|
|
108
|
+
continue
|
|
109
|
+
key, value = line_match.group(1).lower(), line_match.group(2).strip()
|
|
110
|
+
if key == "name":
|
|
111
|
+
name = value
|
|
112
|
+
elif key == "namespace":
|
|
113
|
+
namespace = value
|
|
114
|
+
elif key in ("match", "include"):
|
|
115
|
+
if _is_valid_match_pattern(value):
|
|
116
|
+
matches.append(value)
|
|
117
|
+
else:
|
|
118
|
+
warnings.append(
|
|
119
|
+
f"@{key} {value!r} is not a valid match pattern (ignored)")
|
|
120
|
+
elif key == "exclude":
|
|
121
|
+
if _is_valid_match_pattern(value):
|
|
122
|
+
excludes.append(value)
|
|
123
|
+
else:
|
|
124
|
+
warnings.append(
|
|
125
|
+
f"@exclude {value!r} is not a valid match pattern (ignored)")
|
|
126
|
+
elif key == "run-at":
|
|
127
|
+
run_at = _RUN_AT.get(value, "document_idle")
|
|
128
|
+
elif key == "version":
|
|
129
|
+
version = value
|
|
130
|
+
elif key == "description":
|
|
131
|
+
description = value
|
|
132
|
+
elif key not in _SUPPORTED:
|
|
133
|
+
warnings.append(f"@{key} not supported in v1 (ignored)")
|
|
134
|
+
|
|
135
|
+
if not name:
|
|
136
|
+
raise UserscriptParseError("@name is required")
|
|
137
|
+
if not matches:
|
|
138
|
+
raise UserscriptParseError("at least one @match/@include is required")
|
|
139
|
+
|
|
140
|
+
return Userscript(
|
|
141
|
+
name=name,
|
|
142
|
+
namespace=namespace or DEFAULT_NAMESPACE,
|
|
143
|
+
matches=matches,
|
|
144
|
+
exclude_matches=excludes,
|
|
145
|
+
run_at=run_at,
|
|
146
|
+
version=version,
|
|
147
|
+
description=description,
|
|
148
|
+
code=code,
|
|
149
|
+
warnings=warnings,
|
|
150
|
+
)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""site-skills index + simple ranking (spec §E)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as _dt
|
|
5
|
+
import importlib.util
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from .memory import _md
|
|
12
|
+
from .memory.site_mem import site_skills_root, site_skills_roots
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _bundled_root() -> Path:
|
|
16
|
+
return Path(__file__).resolve().parent / "site_skills_starter"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _iter_site_dirs() -> list[Path]:
|
|
20
|
+
"""Project → ``$BS_HOME`` → subscriptions → bundled, dedup by site name.
|
|
21
|
+
|
|
22
|
+
The order encodes the precedence promise: project workspace is always
|
|
23
|
+
king, then per-user, then explicit subscriptions (the user opted in to
|
|
24
|
+
them by ``browserwright sub add``), then the bundled starter set as
|
|
25
|
+
fallback.
|
|
26
|
+
"""
|
|
27
|
+
from .subscriptions import iter_subscription_site_roots
|
|
28
|
+
roots = [*site_skills_roots(), *iter_subscription_site_roots(), _bundled_root()]
|
|
29
|
+
seen: set[str] = set()
|
|
30
|
+
out: list[Path] = []
|
|
31
|
+
for root in roots:
|
|
32
|
+
if not root.exists():
|
|
33
|
+
continue
|
|
34
|
+
for child in sorted(root.iterdir()):
|
|
35
|
+
if not child.is_dir():
|
|
36
|
+
continue
|
|
37
|
+
if child.name in seen:
|
|
38
|
+
continue
|
|
39
|
+
if not (child / "tasks").exists() and not (child / "memory.md").exists():
|
|
40
|
+
continue
|
|
41
|
+
seen.add(child.name)
|
|
42
|
+
out.append(child)
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_task_meta(task_py: Path) -> dict[str, Any]:
|
|
47
|
+
spec = importlib.util.spec_from_file_location(task_py.stem, task_py)
|
|
48
|
+
if not spec or not spec.loader:
|
|
49
|
+
return {"name": task_py.stem}
|
|
50
|
+
mod = importlib.util.module_from_spec(spec)
|
|
51
|
+
try:
|
|
52
|
+
spec.loader.exec_module(mod)
|
|
53
|
+
except Exception:
|
|
54
|
+
return {"name": task_py.stem, "load_error": True}
|
|
55
|
+
return {
|
|
56
|
+
"name": task_py.stem,
|
|
57
|
+
"desc": (getattr(mod, "__doc__", "") or "").strip().splitlines()[:1][0]
|
|
58
|
+
if getattr(mod, "__doc__", "") else "",
|
|
59
|
+
"args": getattr(mod, "ARGS", {}),
|
|
60
|
+
"output": getattr(mod, "OUTPUT", "Any"),
|
|
61
|
+
"tags": getattr(mod, "TAGS", []),
|
|
62
|
+
"requires_login": bool(getattr(mod, "REQUIRES_LOGIN", False)),
|
|
63
|
+
"last_verified": getattr(mod, "LAST_VERIFIED", None),
|
|
64
|
+
"broken_since": getattr(mod, "BROKEN_SINCE", None),
|
|
65
|
+
"path": str(task_py.resolve()),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_site_entry(site_dir: Path) -> dict[str, Any]:
|
|
70
|
+
fm = {}
|
|
71
|
+
if (site_dir / "memory.md").exists():
|
|
72
|
+
fm, _body = _md.parse_doc((site_dir / "memory.md").read_text(encoding="utf-8"))
|
|
73
|
+
desc = ""
|
|
74
|
+
if (site_dir / "SKILL.md").exists():
|
|
75
|
+
first = ((site_dir / "SKILL.md").read_text(encoding="utf-8")).strip().splitlines()
|
|
76
|
+
if first:
|
|
77
|
+
desc = first[0].lstrip("# ").strip()
|
|
78
|
+
tasks_dir = site_dir / "tasks"
|
|
79
|
+
tasks = []
|
|
80
|
+
if tasks_dir.exists():
|
|
81
|
+
for t in sorted(tasks_dir.glob("*.py")):
|
|
82
|
+
tasks.append(_load_task_meta(t))
|
|
83
|
+
return {
|
|
84
|
+
"site": site_dir.name,
|
|
85
|
+
"host_patterns": fm.get("host_patterns", []),
|
|
86
|
+
"aliases": fm.get("aliases", []),
|
|
87
|
+
"description_first_line": desc,
|
|
88
|
+
"tasks": tasks,
|
|
89
|
+
"path": str(site_dir.resolve()),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rebuild_index() -> dict[str, Any]:
|
|
94
|
+
out = {
|
|
95
|
+
"version": 1,
|
|
96
|
+
"generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(),
|
|
97
|
+
"sites": [_load_site_entry(d) for d in _iter_site_dirs()],
|
|
98
|
+
}
|
|
99
|
+
target = site_skills_root() / "index.json"
|
|
100
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
target.write_text(json.dumps(out, indent=2, default=str), encoding="utf-8")
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _load_index() -> dict[str, Any]:
|
|
106
|
+
target = site_skills_root() / "index.json"
|
|
107
|
+
if not target.exists():
|
|
108
|
+
return rebuild_index()
|
|
109
|
+
try:
|
|
110
|
+
return json.loads(target.read_text(encoding="utf-8"))
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
return rebuild_index()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---- ranking ----------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _tokens(s: str) -> set[str]:
|
|
119
|
+
return {p for p in re.split(r"[\s/_\-]+", (s or "").lower()) if p}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _jaccard(a: set[str], b: set[str]) -> float:
|
|
123
|
+
if not a or not b:
|
|
124
|
+
return 0.0
|
|
125
|
+
return len(a & b) / len(a | b)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _days_since(date_str: Optional[str]) -> int:
|
|
129
|
+
if not date_str:
|
|
130
|
+
return 9999
|
|
131
|
+
try:
|
|
132
|
+
d = _dt.date.fromisoformat(date_str[:10])
|
|
133
|
+
except ValueError:
|
|
134
|
+
return 9999
|
|
135
|
+
return (_dt.date.today() - d).days
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def score(query: str, site_entry: dict, task: dict) -> float:
|
|
139
|
+
q = (query or "").lower()
|
|
140
|
+
s = 0.0
|
|
141
|
+
for alias in site_entry.get("aliases", []):
|
|
142
|
+
if alias and alias.lower() in q:
|
|
143
|
+
s += 1.0
|
|
144
|
+
break
|
|
145
|
+
for h in site_entry.get("host_patterns", []):
|
|
146
|
+
if h and h.split(".")[0] in q:
|
|
147
|
+
s += 0.5
|
|
148
|
+
break
|
|
149
|
+
s += 0.3 * _jaccard(_tokens(task.get("desc", "")), _tokens(query or ""))
|
|
150
|
+
for t in task.get("tags", []):
|
|
151
|
+
if t and t.lower() in q:
|
|
152
|
+
s += 0.2
|
|
153
|
+
if task.get("broken_since"):
|
|
154
|
+
s -= 0.5
|
|
155
|
+
if _days_since(task.get("last_verified")) < 30:
|
|
156
|
+
s += 0.1
|
|
157
|
+
return s
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def list_tasks(*, site: Optional[str] = None, query: Optional[str] = None,
|
|
161
|
+
limit: int = 20) -> list[dict[str, Any]]:
|
|
162
|
+
index = _load_index()
|
|
163
|
+
out: list[dict[str, Any]] = []
|
|
164
|
+
for entry in index.get("sites", []):
|
|
165
|
+
if site and entry["site"] != site:
|
|
166
|
+
continue
|
|
167
|
+
for t in entry.get("tasks", []):
|
|
168
|
+
row = {
|
|
169
|
+
"site": entry["site"],
|
|
170
|
+
"name": t["name"],
|
|
171
|
+
"desc": t.get("desc", ""),
|
|
172
|
+
"args": t.get("args", {}),
|
|
173
|
+
"output": t.get("output", "Any"),
|
|
174
|
+
"tags": t.get("tags", []),
|
|
175
|
+
"requires_login": t.get("requires_login", False),
|
|
176
|
+
"last_verified": t.get("last_verified"),
|
|
177
|
+
"broken_since": t.get("broken_since"),
|
|
178
|
+
"path": t.get("path"),
|
|
179
|
+
}
|
|
180
|
+
if query:
|
|
181
|
+
row["match_score"] = round(score(query, entry, t), 3)
|
|
182
|
+
out.append(row)
|
|
183
|
+
if query:
|
|
184
|
+
out.sort(key=lambda r: r.get("match_score", 0), reverse=True)
|
|
185
|
+
return out[:limit]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_task_path(site: str, name: str) -> Path:
|
|
189
|
+
"""Return absolute path to ``site-skills/<site>/tasks/<name>.py``,
|
|
190
|
+
consulting project → $BS_HOME → subscriptions → bundled in that order.
|
|
191
|
+
Raises ``FileNotFoundError``.
|
|
192
|
+
|
|
193
|
+
Site-name normalisation (Bug 1, v0.3.1): if the literal ``site`` arg
|
|
194
|
+
doesn't resolve, retry with ``host_stem(site)`` so the caller can pass
|
|
195
|
+
a raw URL or hostname (e.g. ``news.ycombinator.com`` from a CLI
|
|
196
|
+
invocation) and still hit the eTLD+1-named bundled directory
|
|
197
|
+
(``ycombinator.com``).
|
|
198
|
+
"""
|
|
199
|
+
from .subscriptions import iter_subscription_site_roots
|
|
200
|
+
from .memory.site_mem import host_stem
|
|
201
|
+
roots = (*site_skills_roots(),
|
|
202
|
+
*iter_subscription_site_roots(),
|
|
203
|
+
_bundled_root())
|
|
204
|
+
candidates: list[str] = [site]
|
|
205
|
+
stem = host_stem(site)
|
|
206
|
+
if stem and stem != site:
|
|
207
|
+
candidates.append(stem)
|
|
208
|
+
for s in candidates:
|
|
209
|
+
for root in roots:
|
|
210
|
+
cand = root / s / "tasks" / f"{name}.py"
|
|
211
|
+
if cand.exists():
|
|
212
|
+
return cand
|
|
213
|
+
raise FileNotFoundError(f"{site}/{name}")
|
browserwright/errors.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Skill exception hierarchy (spec §A.4)."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BrowserwrightError(Exception):
|
|
5
|
+
"""Root of every exception Skill itself raises.
|
|
6
|
+
|
|
7
|
+
Every error can carry a ``fix`` — a short, concrete next-action string
|
|
8
|
+
("call X" / "run Y") so an agent reading the error has a recovery step,
|
|
9
|
+
not just a raw transport/protocol message. This generalizes the pattern
|
|
10
|
+
that ``NeedsUserConfirm.proposal`` established: errors are actionable.
|
|
11
|
+
|
|
12
|
+
Subclasses MAY set a class-level ``default_fix`` so a bare ``raise`` is
|
|
13
|
+
still actionable; an explicit ``fix=`` at the raise site overrides it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
exit_code = 3 # default: script raised
|
|
17
|
+
default_fix = ""
|
|
18
|
+
|
|
19
|
+
def __init__(self, *args, fix: str = ""):
|
|
20
|
+
super().__init__(*args)
|
|
21
|
+
# Explicit fix wins; otherwise fall back to the class default.
|
|
22
|
+
self.fix = fix or type(self).default_fix
|
|
23
|
+
if self.fix and args:
|
|
24
|
+
# Surface the next-action in __str__ so an agent that only logs
|
|
25
|
+
# the message still sees the recovery step.
|
|
26
|
+
self.args = (f"{args[0]} [fix: {self.fix}]",) + tuple(args[1:])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PageLoadFailed(BrowserwrightError):
|
|
30
|
+
exit_code = 3
|
|
31
|
+
default_fix = (
|
|
32
|
+
"retry with new_tab(url) then wait_for_load(); if it persists, "
|
|
33
|
+
"check the URL and network with http_get(url)"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def __init__(self, url: str = "", reason: str = "", fix: str = ""):
|
|
37
|
+
self.url, self.reason = url, reason
|
|
38
|
+
super().__init__(f"page load failed: {url} ({reason})", fix=fix)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ElementNotFound(BrowserwrightError):
|
|
42
|
+
exit_code = 3
|
|
43
|
+
default_fix = (
|
|
44
|
+
"capture_screenshot() to confirm the element is visible, then "
|
|
45
|
+
"click_at_xy(x, y); use snapshot() to list interactive elements"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def __init__(self, selector: str = "", timeout: float = 0.0, fix: str = ""):
|
|
49
|
+
self.selector, self.timeout = selector, timeout
|
|
50
|
+
super().__init__(f"element not found: {selector!r} after {timeout}s", fix=fix)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AuthWall(BrowserwrightError):
|
|
54
|
+
exit_code = 4
|
|
55
|
+
default_fix = "stop and ask the user to log in; do not type credentials from a screenshot"
|
|
56
|
+
|
|
57
|
+
def __init__(self, url: str = "", signals=None, fix: str = ""):
|
|
58
|
+
self.url, self.signals = url, list(signals or [])
|
|
59
|
+
super().__init__(f"auth wall at {url}: {self.signals}", fix=fix)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Captcha(BrowserwrightError):
|
|
63
|
+
exit_code = 5
|
|
64
|
+
default_fix = "stop and ask the user to solve the captcha; do not attempt to bypass it"
|
|
65
|
+
|
|
66
|
+
def __init__(self, kind: str = "unknown", url: str = "", fix: str = ""):
|
|
67
|
+
self.kind, self.url = kind, url
|
|
68
|
+
super().__init__(f"captcha ({kind}) at {url}", fix=fix)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NetworkError(BrowserwrightError):
|
|
72
|
+
exit_code = 3
|
|
73
|
+
default_fix = "verify the URL and connectivity, then retry; check http_get(url) for static pages"
|
|
74
|
+
|
|
75
|
+
def __init__(self, url: str = "", status=None, fix: str = ""):
|
|
76
|
+
self.url, self.status = url, status
|
|
77
|
+
super().__init__(f"network error: {url} (status={status})", fix=fix)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DaemonUnavailable(BrowserwrightError):
|
|
81
|
+
exit_code = 2
|
|
82
|
+
default_fix = (
|
|
83
|
+
"start the single global daemon: `browserwright-daemon serve` "
|
|
84
|
+
"(or run `browserwright doctor` to see what is missing)"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def __init__(self, detail: str = "", fix: str = ""):
|
|
88
|
+
self.detail = detail
|
|
89
|
+
super().__init__(f"daemon unavailable: {detail}", fix=fix)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class NoSession(BrowserwrightError):
|
|
93
|
+
"""No explicit session provided. Refuse rather than silently sharing a browser."""
|
|
94
|
+
|
|
95
|
+
exit_code = 2
|
|
96
|
+
default_fix = (
|
|
97
|
+
"run `browserwright session new --backend=<extension|rdp> --name=SESSION_LABEL` "
|
|
98
|
+
"then run `browserwright -s <id> -e 'print(snapshot())'`"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def __init__(self, detail: str = "", fix: str = ""):
|
|
102
|
+
self.detail = detail
|
|
103
|
+
super().__init__(
|
|
104
|
+
"no session: run `browserwright session new --backend=<extension|rdp> "
|
|
105
|
+
"--name=SESSION_LABEL` first (use the `=` form; --name is a short "
|
|
106
|
+
"session label), then pass -s <id> on every execute call. "
|
|
107
|
+
+ detail,
|
|
108
|
+
fix=fix,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class CDPError(BrowserwrightError):
|
|
113
|
+
exit_code = 3
|
|
114
|
+
default_fix = (
|
|
115
|
+
"if the message mentions an unknown method (-32601) the daemon is "
|
|
116
|
+
"likely stale — `browserwright-daemon stop` then re-run; otherwise check "
|
|
117
|
+
"the method name and params"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def __init__(self, method: str = "", params=None, cdp_message: str = "", fix: str = ""):
|
|
121
|
+
self.method, self.params, self.cdp_message = method, dict(params or {}), cdp_message
|
|
122
|
+
super().__init__(f"CDP {method} failed: {cdp_message}", fix=fix)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class NeedsUserConfirm(BrowserwrightError):
|
|
126
|
+
"""Raised by remember_preference (and similar) when the agent must surface
|
|
127
|
+
a confirm prompt to the user before re-calling with confirm=False."""
|
|
128
|
+
|
|
129
|
+
exit_code = 1
|
|
130
|
+
|
|
131
|
+
def __init__(self, what: str = "", proposal=None, fix: str = ""):
|
|
132
|
+
self.what, self.proposal = what, proposal
|
|
133
|
+
super().__init__(
|
|
134
|
+
f"needs user confirm: {what}",
|
|
135
|
+
# The proposal IS the next-action; mirror it into fix so the
|
|
136
|
+
# generic envelope is uniform across every error type.
|
|
137
|
+
fix=fix or "surface the proposal to the user, then re-call with confirm=True",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def serialize(exc: BaseException) -> dict:
|
|
142
|
+
"""Compact JSON-friendly representation for stderr / repl socket."""
|
|
143
|
+
out = {"type": type(exc).__name__, "msg": str(exc)}
|
|
144
|
+
for k in ("url", "selector", "timeout", "reason", "signals", "kind",
|
|
145
|
+
"status", "detail", "site", "task", "failed_check",
|
|
146
|
+
"method", "cdp_message", "what", "proposal", "fix"):
|
|
147
|
+
v = getattr(exc, k, None)
|
|
148
|
+
if v is not None and not isinstance(v, (type(None),)):
|
|
149
|
+
try:
|
|
150
|
+
import json as _json
|
|
151
|
+
_json.dumps(v) # ensure serializable
|
|
152
|
+
out[k] = v
|
|
153
|
+
except (TypeError, ValueError):
|
|
154
|
+
out[k] = repr(v)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def playwright_error_fix(exc: BaseException) -> str:
|
|
159
|
+
"""Best-effort recovery hint for raw Playwright exceptions.
|
|
160
|
+
|
|
161
|
+
This intentionally does not wrap or re-raise the original exception. It is
|
|
162
|
+
used at serialization boundaries so agent-authored ``try/except`` behavior
|
|
163
|
+
inside the executor stays native Playwright, while the surfaced error gains
|
|
164
|
+
a concrete next step.
|
|
165
|
+
"""
|
|
166
|
+
msg = str(exc)
|
|
167
|
+
lower = msg.lower()
|
|
168
|
+
exc_type = type(exc).__name__
|
|
169
|
+
if "frame detached" in lower or "target closed" in lower or "page closed" in lower:
|
|
170
|
+
return "call reset() to rebuild the browser connection, then re-snapshot and retry"
|
|
171
|
+
if "timeout" not in lower and exc_type != "TimeoutError":
|
|
172
|
+
return ""
|
|
173
|
+
if "locator" in lower or "click" in lower or "fill" in lower:
|
|
174
|
+
return "call snapshot() to confirm the target still exists, then re-snapshot and retry with the current ref"
|
|
175
|
+
if "goto" in lower or "navigation" in lower:
|
|
176
|
+
return "retry page.goto(url); Browserwright will use smart waiting, or verify the site with http_get(url)"
|
|
177
|
+
return "call snapshot() to inspect the current page state, then retry the action with the current ref"
|